Release 0.6.0: 3D view of every diagram; source organised by job #19
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.6.0 — 2026-10-11
|
||||||
|
|
||||||
|
- **3D view:** a **3D ▾** menu in the diagram toolbar, styled like Archify's own menus, switches any diagram to 3D. Nodes become raised blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Works for all five diagram kinds, in every theme and style.
|
||||||
|
- The menu also holds **Levels** (*Flat*, or *Stacked* by kind) and **Camera** (*Reset view*, *From above*).
|
||||||
|
- **Animation follows 2D:** the 3D view mirrors Archify's own animations live: the trace when the diagram opens (replayed when 3D is chosen), node pulses, the hover trace, and the Semantic Passport, Path and Lens flows. *Live* off and reduced motion keep it still.
|
||||||
|
- In 3D, clicking a block focuses it as in 2D (Semantic Passport, fading); hover shows the one-hop trace; trackpad scrolling pans, pinch and the mouse wheel zoom; the zoom buttons and <kbd>+</kbd>/<kbd>-</kbd>/<kbd>0</kbd> move the camera; **Export → 3D view** saves a PNG.
|
||||||
|
- **Performance:** the 3D view draws only when something moves (no GPU use while idle), uses lighter textures and pixel density, and the hidden 2D diagram is no longer painted. Hover and animations never rebuild textures.
|
||||||
|
- New setting `archify.defaultView` (`2d` or `3d`). Each open diagram remembers its view, camera and levels.
|
||||||
|
- The source code is reorganised by job (`commands/`, `views/`, `webview/`, `archify/`, `sources/`, `language/`, `util/`); no change in behaviour.
|
||||||
|
|
||||||
## 0.5.2 — 2026-10-10
|
## 0.5.2 — 2026-10-10
|
||||||
|
|
||||||
- **Right-click menus:** the Archify commands (Archify | New Diagram…, Archify | Open Preview, Archify | Render to HTML File…, Archify | Open in HTML Viewer) now sit in their own section at the very bottom of the Explorer and editor-tab menus, instead of between New File, New Folder and the other built-in entries.
|
- **Right-click menus:** the Archify commands (Archify | New Diagram…, Archify | Open Preview, Archify | Render to HTML File…, Archify | Open in HTML Viewer) now sit in their own section at the very bottom of the Explorer and editor-tab menus, instead of between New File, New Folder and the other built-in entries.
|
||||||
|
|||||||
@@ -70,6 +70,31 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
|
|||||||
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||||
- **Commands**: *Archify | New Diagram…*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
- **Commands**: *Archify | New Diagram…*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
||||||
|
|
||||||
|
## 3D view
|
||||||
|
|
||||||
|
Every diagram can also be seen in 3D. Open the **3D ▾** menu next to **Export** in the diagram's toolbar and choose **3D**; choose **2D** to go back. The button wears a cyan ring while the 3D view is on.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The 3D view is built from the diagram Archify draws, so it looks the same: each node becomes a raised block whose top is the node as Archify draws it, relationships become pipes along Archify's routes with their colours, dashes and arrowheads, and the canvas, frames, lanes and legend form the floor. It works for all five diagram kinds, in every theme and style (Classic, Signal Flow, Blueprint, Editorial).
|
||||||
|
|
||||||
|
The menu has three groups:
|
||||||
|
|
||||||
|
- **View**: 2D or 3D.
|
||||||
|
- **Levels**: *Flat* keeps every node on the floor; *Stacked* lifts nodes into levels by kind: clients and external systems on top, security below them, services in the middle, data on the floor.
|
||||||
|
- **Camera**: *Reset view* (or <kbd>0</kbd>) flies back to the whole diagram, wherever you have moved to; *From above* looks straight down, like 2D.
|
||||||
|
|
||||||
|
Moving around:
|
||||||
|
|
||||||
|
- Drag to turn, right-drag to move. On a trackpad, scroll with two fingers to move in any direction and pinch to zoom; a mouse wheel zooms. The diagram's zoom buttons and the <kbd>+</kbd> and <kbd>-</kbd> keys work too, beyond the 2D limits.
|
||||||
|
- Click a block to focus it, as in 2D: the Semantic Passport opens and unrelated parts fade. Hover a block to see its one-hop trace. Double-click a block to fly to it, or empty space to show everything.
|
||||||
|
|
||||||
|
Animation follows 2D exactly, because the 3D view reads Archify's own animations as they play: the trace (relationships drawing in, nodes pulsing) when the diagram opens or 3D is chosen, the hover trace, and the flows from the Semantic Passport, Path and Lens. *Live* off, or reduced motion, keeps it still.
|
||||||
|
|
||||||
|
**Export → 3D view** saves the current 3D view as a PNG. Each open diagram remembers whether it was in 3D, its camera and its levels; set `archify.defaultView` to `3d` to open diagrams in 3D.
|
||||||
|
|
||||||
|
The 3D view draws only when something moves, so an idle diagram costs no GPU time.
|
||||||
|
|
||||||
## Source evidence
|
## Source evidence
|
||||||
|
|
||||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||||
@@ -81,6 +106,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
|
|||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
|
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
|
||||||
|
| `archify.defaultView` | `2d` | `3d` opens diagrams in the 3D view. Each open diagram remembers its own choice. |
|
||||||
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
|
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
|
||||||
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
|
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
|
||||||
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
|
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
|
||||||
@@ -91,7 +117,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -105,6 +131,20 @@ npm run package # build archify-vscode-ext-<version>.vsix
|
|||||||
|
|
||||||
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
|
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
|
||||||
|
|
||||||
|
### Code layout
|
||||||
|
|
||||||
|
| Folder | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| `src/extension.ts` | Activation: creates the views and registers everything |
|
||||||
|
| `src/commands/` | Command handlers, grouped by job, and the editor context keys |
|
||||||
|
| `src/views/` | The diagram view (editor for `.archify`, side preview for JSON) and the HTML viewer |
|
||||||
|
| `src/webview/` | The page the views show: CSP, the bridge script, messages from the page |
|
||||||
|
| `src/archify/` | Running the bundled Archify CLI: render, validate, runtime settings |
|
||||||
|
| `src/sources/` | Reading diagram files: type detection, YAML/JSON, source evidence |
|
||||||
|
| `src/language/` | Editor help: Problems, brand completion, templates |
|
||||||
|
| `src/util/` | Logging, settings, file reading |
|
||||||
|
| `src/viewer3d/` | The 3D view; runs inside the Archify page and is bundled to `dist/viewer3d.js` with three.js |
|
||||||
|
|
||||||
### Updating the bundled renderer
|
### Updating the bundled renderer
|
||||||
|
|
||||||
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
|
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
+16
-5
@@ -33,16 +33,27 @@ if (tests) {
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const ctx = await esbuild.context({
|
const contexts = await Promise.all([
|
||||||
|
esbuild.context({
|
||||||
...common,
|
...common,
|
||||||
entryPoints: ['src/extension.ts'],
|
entryPoints: ['src/extension.ts'],
|
||||||
outfile: 'dist/extension.js',
|
outfile: 'dist/extension.js',
|
||||||
external: ['vscode'],
|
external: ['vscode'],
|
||||||
});
|
}),
|
||||||
|
// Runs inside the Archify page in the webview; three.js is bundled in.
|
||||||
|
esbuild.context({
|
||||||
|
...common,
|
||||||
|
format: 'iife',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
entryPoints: ['src/viewer3d/index.ts'],
|
||||||
|
outfile: 'dist/viewer3d.js',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
if (watch) {
|
if (watch) {
|
||||||
await ctx.watch();
|
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||||
} else {
|
} else {
|
||||||
await ctx.rebuild();
|
await Promise.all(contexts.map((ctx) => ctx.rebuild()));
|
||||||
await ctx.dispose();
|
await Promise.all(contexts.map((ctx) => ctx.dispose()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+67
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"three": "^0.186.1",
|
||||||
"yaml": "^2.9.1"
|
"yaml": "^2.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
"@vscode/test-electron": "^3.1.0",
|
"@vscode/test-electron": "^3.1.0",
|
||||||
"@vscode/vsce": "^3.0.0",
|
"@vscode/vsce": "^3.0.0",
|
||||||
@@ -246,6 +248,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -999,6 +1008,13 @@
|
|||||||
"@textlint/ast-node-types": "15.8.0"
|
"@textlint/ast-node-types": "15.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.20.4",
|
"version": "22.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
||||||
@@ -1023,6 +1039,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.186.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
|
||||||
|
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.3",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/vscode": {
|
"node_modules/@types/vscode": {
|
||||||
"version": "1.100.0",
|
"version": "1.100.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
||||||
@@ -1030,6 +1068,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typespec/ts-http-runtime": {
|
"node_modules/@typespec/ts-http-runtime": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
||||||
@@ -2162,6 +2207,13 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -3097,6 +3149,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -4423,6 +4482,12 @@
|
|||||||
"url": "https://bevry.me/fund"
|
"url": "https://bevry.me/fund"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.186.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
|
||||||
|
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
|
|||||||
+19
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify Diagram Viewer",
|
||||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, \u2026) and opens rendered Archify HTML.",
|
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"publisher": "root-at-skic",
|
"publisher": "root-at-skic",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "media/icon.png",
|
"icon": "media/icon.png",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "archify.newDiagram",
|
"command": "archify.newDiagram",
|
||||||
"title": "Archify | New Diagram\u2026",
|
"title": "Archify | New Diagram…",
|
||||||
"icon": "$(new-file)"
|
"icon": "$(new-file)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.renderToHtml",
|
"command": "archify.renderToHtml",
|
||||||
"title": "Archify | Render to HTML File\u2026",
|
"title": "Archify | Render to HTML File…",
|
||||||
"icon": "$(file-code)"
|
"icon": "$(file-code)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -257,6 +257,19 @@
|
|||||||
"default": "vscode",
|
"default": "vscode",
|
||||||
"description": "Theme used when showing Archify diagrams."
|
"description": "Theme used when showing Archify diagrams."
|
||||||
},
|
},
|
||||||
|
"archify.defaultView": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"2d",
|
||||||
|
"3d"
|
||||||
|
],
|
||||||
|
"enumDescriptions": [
|
||||||
|
"Open diagrams as Archify draws them.",
|
||||||
|
"Open diagrams in the 3D view. The 3D button in the diagram toolbar switches back."
|
||||||
|
],
|
||||||
|
"default": "2d",
|
||||||
|
"description": "How diagrams open. Each open diagram remembers its own choice."
|
||||||
|
},
|
||||||
"archify.preview.liveUpdate": {
|
"archify.preview.liveUpdate": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
@@ -340,6 +353,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
"@vscode/test-electron": "^3.1.0",
|
"@vscode/test-electron": "^3.1.0",
|
||||||
"@vscode/vsce": "^3.0.0",
|
"@vscode/vsce": "^3.0.0",
|
||||||
@@ -353,6 +367,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"three": "^0.186.1",
|
||||||
"yaml": "^2.9.1"
|
"yaml": "^2.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
|
import { DiagramSource, DiagramType } from '../sources/detect';
|
||||||
|
import { hasSourceEvidence, stripSourceEvidence } from '../sources/evidence';
|
||||||
|
|
||||||
export interface RuntimeOptions {
|
export interface RuntimeOptions {
|
||||||
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { RuntimeOptions } from './renderer';
|
import { log } from '../util/log';
|
||||||
import { ViewerTheme } from './webviewHtml';
|
import { RuntimeOptions } from './cli';
|
||||||
|
|
||||||
let output: vscode.LogOutputChannel | undefined;
|
|
||||||
|
|
||||||
export function log(): vscode.LogOutputChannel {
|
|
||||||
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gitRoots = new Map<string, Promise<string | undefined>>();
|
const gitRoots = new Map<string, Promise<string | undefined>>();
|
||||||
|
|
||||||
@@ -60,18 +53,3 @@ export async function runtimeFor(context: vscode.ExtensionContext, uri: vscode.U
|
|||||||
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
|
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
|
||||||
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
|
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewerTheme(): ViewerTheme {
|
|
||||||
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
|
||||||
if (setting === 'dark' || setting === 'light') return setting;
|
|
||||||
if (setting === 'diagram') return undefined;
|
|
||||||
const kind = vscode.window.activeColorTheme.kind;
|
|
||||||
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current text of a document: the open editor buffer, else the file on disk. */
|
|
||||||
export async function readText(uri: vscode.Uri): Promise<string> {
|
|
||||||
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
|
||||||
if (open) return open.getText();
|
|
||||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
|
||||||
|
import { DiagramViewManager } from '../views/diagramView';
|
||||||
|
|
||||||
|
/** What command handlers share: the extension context and the open views. */
|
||||||
|
export interface CommandContext {
|
||||||
|
extension: vscode.ExtensionContext;
|
||||||
|
views: DiagramViewManager;
|
||||||
|
htmlViewer: HtmlViewerProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||||
|
return /\.html?$/i.test(uri.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||||
|
export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||||
|
if (arg instanceof vscode.Uri) return arg;
|
||||||
|
return ctx.views.activePreview?.uri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||||
|
const uri = targetUri(ctx, arg);
|
||||||
|
if (!uri) vscode.window.showWarningMessage('Archify: open a .archify diagram first.');
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
||||||
|
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
||||||
|
|
||||||
|
export async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(uri);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { clearGitRootCache } from '../archify/runtime';
|
||||||
|
import { isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from '../sources/detect';
|
||||||
|
|
||||||
|
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||||
|
|
||||||
|
/** Keeps the archify.* context keys (editor title buttons, keybindings) up to date. */
|
||||||
|
export function registerContextKeys(context: vscode.ExtensionContext): void {
|
||||||
|
let contextTimer: NodeJS.Timeout | undefined;
|
||||||
|
const updateContext = () => {
|
||||||
|
const doc = vscode.window.activeTextEditor?.document;
|
||||||
|
const diagram = Boolean(
|
||||||
|
doc &&
|
||||||
|
(isArchifyFile(doc.fileName) ||
|
||||||
|
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
||||||
|
);
|
||||||
|
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
||||||
|
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
||||||
|
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
||||||
|
};
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
||||||
|
clearTimeout(contextTimer);
|
||||||
|
contextTimer = setTimeout(updateContext, 300);
|
||||||
|
}),
|
||||||
|
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||||
|
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
||||||
|
}),
|
||||||
|
{ dispose: () => clearTimeout(contextTimer) },
|
||||||
|
);
|
||||||
|
updateContext();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from '../language/templates';
|
||||||
|
import { diagramBaseName, isArchifyFile } from '../sources/detect';
|
||||||
|
import { readText } from '../util/files';
|
||||||
|
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||||
|
import { CommandContext, exists } from './context';
|
||||||
|
|
||||||
|
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
||||||
|
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||||
|
if (arg instanceof vscode.Uri) {
|
||||||
|
const stat = await vscode.workspace.fs.stat(arg);
|
||||||
|
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||||
|
}
|
||||||
|
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||||
|
if (folders.length <= 1) return folders[0]?.uri;
|
||||||
|
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newDiagramCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
||||||
|
const template = await vscode.window.showQuickPick(
|
||||||
|
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
||||||
|
{ title: 'Archify | New Diagram', placeHolder: 'Kind of diagram' },
|
||||||
|
);
|
||||||
|
if (!template) return;
|
||||||
|
const { file, defaultName } = template.template;
|
||||||
|
|
||||||
|
const folder = await newDiagramFolder(arg);
|
||||||
|
let target: vscode.Uri | undefined;
|
||||||
|
if (folder) {
|
||||||
|
const name = await vscode.window.showInputBox({
|
||||||
|
title: 'Archify | New Diagram',
|
||||||
|
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
||||||
|
value: defaultName,
|
||||||
|
validateInput: async (value) =>
|
||||||
|
invalidNameReason(value) ??
|
||||||
|
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
||||||
|
});
|
||||||
|
if (name === undefined) return;
|
||||||
|
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
||||||
|
} else {
|
||||||
|
// No workspace: let the user choose where to save.
|
||||||
|
target = await vscode.window.showSaveDialog({
|
||||||
|
title: 'Archify | New Diagram',
|
||||||
|
filters: { 'Archify Diagram': ['archify'] },
|
||||||
|
saveLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateText = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', file));
|
||||||
|
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
|
||||||
|
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
|
||||||
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
|
import { diagramBaseName, readDiagramSource } from '../sources/detect';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
import { readText } from '../util/files';
|
||||||
|
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||||
|
|
||||||
|
export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
const text = await readText(uri);
|
||||||
|
const source = readDiagramSource(text, uri.path);
|
||||||
|
if (!source) {
|
||||||
|
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
||||||
|
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
||||||
|
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
|
||||||
|
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
||||||
|
const target = await vscode.window.showSaveDialog({
|
||||||
|
defaultUri,
|
||||||
|
filters: { HTML: ['html'] },
|
||||||
|
saveLabel: 'Render',
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const runtime = await runtimeFor(ctx.extension, uri);
|
||||||
|
let result = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||||
|
() => renderToFile(runtime, source, target.fsPath),
|
||||||
|
);
|
||||||
|
if (!result.ok && isEvidenceError(result.error)) {
|
||||||
|
const choice = await vscode.window.showWarningMessage(
|
||||||
|
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
||||||
|
{ modal: true, detail: result.error },
|
||||||
|
'Render Without Source Links',
|
||||||
|
);
|
||||||
|
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
log().error(result.error ?? 'render failed');
|
||||||
|
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const choice = await vscode.window.showInformationMessage(
|
||||||
|
`Rendered ${path.basename(target.fsPath)}`,
|
||||||
|
'Open in Viewer',
|
||||||
|
'Open in Browser',
|
||||||
|
);
|
||||||
|
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
||||||
|
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
if (isHtmlUri(uri)) {
|
||||||
|
await vscode.env.openExternal(uri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await readText(uri);
|
||||||
|
const source = readDiagramSource(text, uri.path);
|
||||||
|
if (!source) {
|
||||||
|
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runtime = await runtimeFor(ctx.extension, uri);
|
||||||
|
const result = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||||
|
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
||||||
|
);
|
||||||
|
if (!result.ok || !result.html) {
|
||||||
|
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = path.join(ctx.extension.globalStorageUri.fsPath, 'browser');
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
|
||||||
|
await fs.writeFile(file, result.html, 'utf8');
|
||||||
|
await vscode.env.openExternal(vscode.Uri.file(file));
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { isArchifyFile } from '../sources/detect';
|
||||||
|
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||||
|
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||||
|
|
||||||
|
export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||||
|
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||||
|
ctx.views.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
||||||
|
if (isArchifyFile(uri.path)) {
|
||||||
|
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
||||||
|
viewColumn: vscode.ViewColumn.Beside,
|
||||||
|
preserveFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.views.show(uri, vscode.ViewColumn.Beside, true);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (uri) return openHtmlViewer(uri);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.refreshPreview', () => ctx.views.activePreview?.update(true)),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.showSource', async () => {
|
||||||
|
const preview = ctx.views.activePreview;
|
||||||
|
if (preview?.isEditor) {
|
||||||
|
// The diagram is the editor; open the YAML text beside it.
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preview) {
|
||||||
|
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||||
|
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = ctx.htmlViewer.activeUri;
|
||||||
|
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
+21
-243
@@ -1,254 +1,32 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './brandCompletion';
|
import { CommandContext } from './commands/context';
|
||||||
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
import { registerContextKeys } from './commands/contextKeys';
|
||||||
import { DiagnosticsController } from './diagnostics';
|
import { newDiagramCommands } from './commands/newDiagram';
|
||||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
import { renderCommands } from './commands/renderCommands';
|
||||||
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
|
import { viewCommands } from './commands/viewCommands';
|
||||||
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
|
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './language/brandCompletion';
|
||||||
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
|
import { DiagnosticsController } from './language/diagnostics';
|
||||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
|
import { log } from './util/log';
|
||||||
|
import { HtmlViewerProvider } from './views/htmlViewer';
|
||||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
import { DiagramViewManager } from './views/diagramView';
|
||||||
|
|
||||||
function isHtmlUri(uri: vscode.Uri): boolean {
|
|
||||||
return /\.html?$/i.test(uri.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const previews = new PreviewManager(context);
|
const ctx: CommandContext = {
|
||||||
const htmlViewer = new HtmlViewerProvider();
|
extension: context,
|
||||||
|
views: new DiagramViewManager(context),
|
||||||
|
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||||
|
};
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
previews,
|
ctx.views,
|
||||||
htmlViewer,
|
ctx.htmlViewer,
|
||||||
new DiagnosticsController(context),
|
new DiagnosticsController(context),
|
||||||
log(),
|
log(),
|
||||||
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
||||||
|
...viewCommands(ctx),
|
||||||
|
...newDiagramCommands(ctx),
|
||||||
|
...renderCommands(ctx),
|
||||||
);
|
);
|
||||||
|
registerContextKeys(context);
|
||||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
|
||||||
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
|
||||||
if (arg instanceof vscode.Uri) return arg;
|
|
||||||
return previews.activePreview?.uri ?? htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceUri(arg?: unknown): vscode.Uri | undefined {
|
|
||||||
const uri = targetUri(arg);
|
|
||||||
if (!uri) vscode.window.showWarningMessage('Archify: open a .archify diagram first.');
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context keys for editor title buttons and keybindings.
|
|
||||||
let contextTimer: NodeJS.Timeout | undefined;
|
|
||||||
const updateContext = () => {
|
|
||||||
const doc = vscode.window.activeTextEditor?.document;
|
|
||||||
const diagram = Boolean(
|
|
||||||
doc &&
|
|
||||||
(isArchifyFile(doc.fileName) ||
|
|
||||||
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
|
||||||
);
|
|
||||||
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
|
||||||
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
|
||||||
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
|
||||||
};
|
|
||||||
context.subscriptions.push(
|
|
||||||
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
|
||||||
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
|
||||||
clearTimeout(contextTimer);
|
|
||||||
contextTimer = setTimeout(updateContext, 300);
|
|
||||||
}),
|
|
||||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
|
||||||
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
|
||||||
}),
|
|
||||||
{ dispose: () => clearTimeout(contextTimer) },
|
|
||||||
);
|
|
||||||
updateContext();
|
|
||||||
|
|
||||||
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
|
||||||
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
|
||||||
|
|
||||||
const exists = async (uri: vscode.Uri) => {
|
|
||||||
try {
|
|
||||||
await vscode.workspace.fs.stat(uri);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
|
||||||
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
|
||||||
if (arg instanceof vscode.Uri) {
|
|
||||||
const stat = await vscode.workspace.fs.stat(arg);
|
|
||||||
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
|
||||||
}
|
|
||||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
|
||||||
if (folders.length <= 1) return folders[0]?.uri;
|
|
||||||
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.subscriptions.push(
|
|
||||||
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
|
||||||
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
|
||||||
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
|
||||||
if (isArchifyFile(uri.path)) {
|
|
||||||
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
|
||||||
viewColumn: vscode.ViewColumn.Beside,
|
|
||||||
preserveFocus: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
previews.show(uri, vscode.ViewColumn.Beside, true);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (uri) return openHtmlViewer(uri);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
|
||||||
const template = await vscode.window.showQuickPick(
|
|
||||||
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
|
||||||
{ title: 'Archify | New Diagram', placeHolder: 'Kind of diagram' },
|
|
||||||
);
|
|
||||||
if (!template) return;
|
|
||||||
const { file, defaultName } = template.template;
|
|
||||||
|
|
||||||
const folder = await newDiagramFolder(arg);
|
|
||||||
let target: vscode.Uri | undefined;
|
|
||||||
if (folder) {
|
|
||||||
const name = await vscode.window.showInputBox({
|
|
||||||
title: 'Archify | New Diagram',
|
|
||||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
|
||||||
value: defaultName,
|
|
||||||
validateInput: async (value) =>
|
|
||||||
invalidNameReason(value) ??
|
|
||||||
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
|
||||||
});
|
|
||||||
if (name === undefined) return;
|
|
||||||
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
|
||||||
} else {
|
|
||||||
// No workspace: let the user choose where to save.
|
|
||||||
target = await vscode.window.showSaveDialog({
|
|
||||||
title: 'Archify | New Diagram',
|
|
||||||
filters: { 'Archify Diagram': ['archify'] },
|
|
||||||
saveLabel: 'Create',
|
|
||||||
});
|
|
||||||
if (!target) return;
|
|
||||||
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
|
|
||||||
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
|
|
||||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
|
||||||
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.showSource', async () => {
|
|
||||||
const preview = previews.activePreview;
|
|
||||||
if (preview?.isEditor) {
|
|
||||||
// The diagram is the editor; open the YAML text beside it.
|
|
||||||
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (preview) {
|
|
||||||
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
|
||||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const html = htmlViewer.activeUri;
|
|
||||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
const text = await readText(uri);
|
|
||||||
const source = readDiagramSource(text, uri.path);
|
|
||||||
if (!source) {
|
|
||||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
|
||||||
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
|
||||||
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
|
|
||||||
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
|
||||||
const target = await vscode.window.showSaveDialog({
|
|
||||||
defaultUri,
|
|
||||||
filters: { HTML: ['html'] },
|
|
||||||
saveLabel: 'Render',
|
|
||||||
});
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const runtime = await runtimeFor(context, uri);
|
|
||||||
let result = await vscode.window.withProgress(
|
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
|
||||||
() => renderToFile(runtime, source, target.fsPath),
|
|
||||||
);
|
|
||||||
if (!result.ok && isEvidenceError(result.error)) {
|
|
||||||
const choice = await vscode.window.showWarningMessage(
|
|
||||||
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
|
||||||
{ modal: true, detail: result.error },
|
|
||||||
'Render Without Source Links',
|
|
||||||
);
|
|
||||||
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
|
||||||
else return;
|
|
||||||
}
|
|
||||||
if (!result.ok) {
|
|
||||||
log().error(result.error ?? 'render failed');
|
|
||||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const choice = await vscode.window.showInformationMessage(
|
|
||||||
`Rendered ${path.basename(target.fsPath)}`,
|
|
||||||
'Open in Viewer',
|
|
||||||
'Open in Browser',
|
|
||||||
);
|
|
||||||
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
|
||||||
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
if (isHtmlUri(uri)) {
|
|
||||||
await vscode.env.openExternal(uri);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const text = await readText(uri);
|
|
||||||
const source = readDiagramSource(text, uri.path);
|
|
||||||
if (!source) {
|
|
||||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const runtime = await runtimeFor(context, uri);
|
|
||||||
const result = await vscode.window.withProgress(
|
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
|
||||||
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
|
||||||
);
|
|
||||||
if (!result.ok || !result.html) {
|
|
||||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dir = path.join(context.globalStorageUri.fsPath, 'browser');
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
|
|
||||||
await fs.writeFile(file, result.html, 'utf8');
|
|
||||||
await vscode.env.openExternal(vscode.Uri.file(file));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {}
|
export function deactivate(): void {}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { isArchifyFile } from '../sources/detect';
|
||||||
|
import { log } from '../util/log';
|
||||||
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
|
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
|
||||||
import { isArchifyFile } from './detect';
|
|
||||||
import { log } from './runtime';
|
|
||||||
|
|
||||||
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
|
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
|
||||||
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { parse, ParseError } from 'jsonc-parser';
|
import { parse, ParseError } from 'jsonc-parser';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isArchifyFile, readDiagramSource } from './detect';
|
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
||||||
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
|
import { isArchifyFile, readDiagramSource } from '../sources/detect';
|
||||||
|
import { log } from '../util/log';
|
||||||
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||||
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
|
||||||
import { evidenceMode, log, runtimeFor } from './runtime';
|
|
||||||
|
|
||||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isMap, parseDocument } from 'yaml';
|
import { isMap, parseDocument } from 'yaml';
|
||||||
import { DiagramType } from './detect';
|
import { DiagramType } from '../sources/detect';
|
||||||
|
|
||||||
export interface DiagramTemplate {
|
export interface DiagramTemplate {
|
||||||
/** File name under templates/. */
|
/** File name under templates/. */
|
||||||
@@ -4,15 +4,6 @@ import { parse as parseYaml } from 'yaml';
|
|||||||
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
||||||
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
||||||
|
|
||||||
/** Node collection that may carry `sources` evidence, per diagram type. */
|
|
||||||
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
|
||||||
architecture: 'components',
|
|
||||||
workflow: 'nodes',
|
|
||||||
sequence: 'participants',
|
|
||||||
dataflow: 'nodes',
|
|
||||||
lifecycle: 'states',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
|
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
|
||||||
const ARCHIFY_EXT_RE = /\.archify$/i;
|
const ARCHIFY_EXT_RE = /\.archify$/i;
|
||||||
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
|
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
|
||||||
@@ -92,24 +83,3 @@ export function isArchifyHtml(text: string): boolean {
|
|||||||
const head = text.slice(0, 4096);
|
const head = text.slice(0, 4096);
|
||||||
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
|
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
|
||||||
const meta = diagram.meta as Record<string, unknown> | undefined;
|
|
||||||
if (meta && meta.repository) return true;
|
|
||||||
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
|
||||||
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a copy of the diagram with repository evidence removed. */
|
|
||||||
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const copy = structuredClone(diagram);
|
|
||||||
const meta = copy.meta as Record<string, unknown> | undefined;
|
|
||||||
if (meta) delete meta.repository;
|
|
||||||
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
|
||||||
if (Array.isArray(nodes)) {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node && typeof node === 'object') delete node.sources;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { DiagramType } from './detect';
|
||||||
|
|
||||||
|
/** Node collection that may carry `sources` evidence, per diagram type. */
|
||||||
|
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
||||||
|
architecture: 'components',
|
||||||
|
workflow: 'nodes',
|
||||||
|
sequence: 'participants',
|
||||||
|
dataflow: 'nodes',
|
||||||
|
lifecycle: 'states',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
||||||
|
const meta = diagram.meta as Record<string, unknown> | undefined;
|
||||||
|
if (meta && meta.repository) return true;
|
||||||
|
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
||||||
|
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a copy of the diagram with repository evidence removed. */
|
||||||
|
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const copy = structuredClone(diagram);
|
||||||
|
const meta = copy.meta as Record<string, unknown> | undefined;
|
||||||
|
if (meta) delete meta.repository;
|
||||||
|
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
||||||
|
if (Array.isArray(nodes)) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node && typeof node === 'object') delete node.sources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
/** Current text of a document: the open editor buffer, else the file on disk. */
|
||||||
|
export async function readText(uri: vscode.Uri): Promise<string> {
|
||||||
|
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||||
|
if (open) return open.getText();
|
||||||
|
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
let output: vscode.LogOutputChannel | undefined;
|
||||||
|
|
||||||
|
export function log(): vscode.LogOutputChannel {
|
||||||
|
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { ViewerTheme } from '../webview/page';
|
||||||
|
|
||||||
|
/** Diagram theme from archify.theme: forced, VS Code's, or the diagram's own. */
|
||||||
|
export function viewerTheme(): ViewerTheme {
|
||||||
|
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
||||||
|
if (setting === 'dark' || setting === 'light') return setting;
|
||||||
|
if (setting === 'diagram') return undefined;
|
||||||
|
const kind = vscode.window.activeColorTheme.kind;
|
||||||
|
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import { layerMarkup, rasterize } from './raster';
|
||||||
|
import { CameraState, Layers, Stage } from './stage';
|
||||||
|
import { DiagramModel, readModel } from './svgModel';
|
||||||
|
import { STYLE } from './style';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D mode for the Archify viewer. It adds a 3D menu to Archify's toolbar
|
||||||
|
* and, when on, replaces the 2D canvas with a three.js stage built from the
|
||||||
|
* live SVG. Archify keeps owning the diagram: clicks in 3D go to the SVG
|
||||||
|
* nodes, so focus, Semantic Passport, reach and lens work as in 2D; the
|
||||||
|
* stage redraws whenever Archify changes the SVG, theme or preset, and
|
||||||
|
* follows Archify's animations as they play.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Host {
|
||||||
|
defaultView: '2d' | '3d';
|
||||||
|
post(message: unknown): void;
|
||||||
|
getState(): Record<string, unknown>;
|
||||||
|
setState(patch: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavedView {
|
||||||
|
active?: boolean;
|
||||||
|
layered?: boolean;
|
||||||
|
camera?: CameraState;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__archifyHost?: Host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDRAW_MS = 220;
|
||||||
|
const MARGIN = 80;
|
||||||
|
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
|
||||||
|
const TEXTURE_SCALE = 2;
|
||||||
|
|
||||||
|
type MenuValue = '2d' | '3d' | 'flat' | 'stacked' | 'reset' | 'top';
|
||||||
|
|
||||||
|
interface MenuEntry {
|
||||||
|
value: MenuValue;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
/** Radio group; actions have none. */
|
||||||
|
group?: 'view' | 'levels';
|
||||||
|
shortcut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU: { heading: string; entries: MenuEntry[] }[] = [
|
||||||
|
{
|
||||||
|
heading: 'View',
|
||||||
|
entries: [
|
||||||
|
{ value: '2d', group: 'view', title: '2D', hint: 'The diagram as Archify draws it' },
|
||||||
|
{ value: '3d', group: 'view', title: '3D', hint: 'Blocks and pipes you can turn' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Levels',
|
||||||
|
entries: [
|
||||||
|
{ value: 'flat', group: 'levels', title: 'Flat', hint: 'Every node on the floor' },
|
||||||
|
{ value: 'stacked', group: 'levels', title: 'Stacked', hint: 'Clients on top, data at the bottom' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Camera',
|
||||||
|
entries: [
|
||||||
|
{ value: 'reset', title: 'Reset view', hint: 'Show the whole diagram', shortcut: '0' },
|
||||||
|
{ value: 'top', title: 'From above', hint: 'Look straight down, like 2D' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
class Viewer3D {
|
||||||
|
private stage: Stage | undefined;
|
||||||
|
private model: DiagramModel | undefined;
|
||||||
|
private active = false;
|
||||||
|
private redrawTimer: number | undefined;
|
||||||
|
private drawing: Promise<void> | undefined;
|
||||||
|
private pendingRedraw = false;
|
||||||
|
private readonly observer = new MutationObserver((records) => this.onMutation(records));
|
||||||
|
/** Keeps Archify's zoom buttons usable in 3D (see holdZoomButtons). */
|
||||||
|
private readonly zoomObserver = new MutationObserver(() => this.enableZoomButtons());
|
||||||
|
private zoomDisabled = new Map<HTMLButtonElement, boolean>();
|
||||||
|
private readonly button: HTMLButtonElement;
|
||||||
|
private readonly menu: HTMLDivElement;
|
||||||
|
private ambientReplayed = false;
|
||||||
|
private exportItem: HTMLButtonElement | undefined;
|
||||||
|
private saved: SavedView;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly host: Host,
|
||||||
|
private readonly container: HTMLElement,
|
||||||
|
private readonly svg: SVGSVGElement,
|
||||||
|
) {
|
||||||
|
this.saved = (host.getState().view3d as SavedView | undefined) ?? {};
|
||||||
|
({ button: this.button, menu: this.menu } = this.buildMenu());
|
||||||
|
this.addExportItem();
|
||||||
|
this.interceptViewerControls();
|
||||||
|
if (this.saved.active ?? host.defaultView === '3d') this.setActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- chrome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 3D menu: a toolbar button with a dropdown, built from Archify's own
|
||||||
|
* style-menu classes so it looks and behaves like the menus beside it.
|
||||||
|
*/
|
||||||
|
private buildMenu(): { button: HTMLButtonElement; menu: HTMLDivElement } {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'preset-wrap archify-3d-wrap';
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = 'btn-3d';
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = '3D view: switch 2D / 3D, levels and camera';
|
||||||
|
button.setAttribute('aria-label', '3D view options');
|
||||||
|
button.setAttribute('aria-haspopup', 'menu');
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
button.setAttribute('aria-controls', 'archify-3d-menu');
|
||||||
|
// Same make-up as Archify's Export button: a label and a chevron.
|
||||||
|
button.innerHTML = '3D <span class="toolbar-icon toolbar-chevron" aria-hidden="true"></span>';
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.id = 'archify-3d-menu';
|
||||||
|
menu.className = 'preset-menu archify-3d-menu';
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.setAttribute('aria-label', 'View options');
|
||||||
|
for (const section of MENU) {
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'preset-menu-heading';
|
||||||
|
heading.setAttribute('role', 'presentation');
|
||||||
|
heading.innerHTML = `<span>${section.heading}</span>`;
|
||||||
|
menu.appendChild(heading);
|
||||||
|
for (const entry of section.entries) {
|
||||||
|
const option = document.createElement('button');
|
||||||
|
option.type = 'button';
|
||||||
|
option.className = 'preset-option archify-3d-option';
|
||||||
|
option.tabIndex = -1;
|
||||||
|
option.dataset.archify3d = entry.value;
|
||||||
|
option.setAttribute('role', entry.group ? 'menuitemradio' : 'menuitem');
|
||||||
|
if (entry.group) option.setAttribute('aria-checked', 'false');
|
||||||
|
option.innerHTML =
|
||||||
|
`<span class="archify-3d-swatch" aria-hidden="true"><span class="archify-3d-swatch-icon archify-3d-i-${entry.value}"></span></span>` +
|
||||||
|
`<span class="preset-option-copy"><strong>${entry.title}</strong><small>${entry.hint}</small></span>` +
|
||||||
|
(entry.group
|
||||||
|
? '<span class="preset-option-check" aria-hidden="true"></span>'
|
||||||
|
: `<kbd class="archify-3d-kbd" aria-hidden="true">${entry.shortcut ?? ''}</kbd>`);
|
||||||
|
option.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (option.getAttribute('aria-disabled') === 'true') return;
|
||||||
|
this.choose(entry.value);
|
||||||
|
if (!entry.group) this.closeMenu();
|
||||||
|
else this.syncButtons();
|
||||||
|
});
|
||||||
|
menu.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (menu.classList.contains('open')) this.closeMenu();
|
||||||
|
else this.openMenu();
|
||||||
|
});
|
||||||
|
menu.addEventListener('keydown', (event) => this.menuKey(event));
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (menu.classList.contains('open') && !wrap.contains(event.target as Node)) this.closeMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && menu.classList.contains('open')) {
|
||||||
|
this.closeMenu();
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.append(button, menu);
|
||||||
|
// Beside Export, outside the tools capsule, as its twin.
|
||||||
|
const exportWrap = document.querySelector('.toolbar .export-wrap');
|
||||||
|
if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(wrap, exportWrap);
|
||||||
|
else document.querySelector('.toolbar')?.appendChild(wrap);
|
||||||
|
return { button, menu };
|
||||||
|
}
|
||||||
|
|
||||||
|
private options(): HTMLButtonElement[] {
|
||||||
|
return [...this.menu.querySelectorAll<HTMLButtonElement>('.archify-3d-option')].filter((o) => o.getAttribute('aria-disabled') !== 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
private openMenu(): void {
|
||||||
|
// Only one toolbar menu at a time: close Archify's own.
|
||||||
|
document.querySelectorAll('.toolbar .preset-menu.open, .toolbar .export-menu.open').forEach((m) => {
|
||||||
|
if (m !== this.menu) (m.previousElementSibling as HTMLElement | null)?.click();
|
||||||
|
});
|
||||||
|
this.syncButtons();
|
||||||
|
this.menu.classList.add('open');
|
||||||
|
this.button.setAttribute('aria-expanded', 'true');
|
||||||
|
const checked = this.options().find((o) => o.getAttribute('aria-checked') === 'true');
|
||||||
|
(checked ?? this.options()[0])?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeMenu(): void {
|
||||||
|
this.menu.classList.remove('open');
|
||||||
|
this.button.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
private menuKey(event: KeyboardEvent): void {
|
||||||
|
const options = this.options();
|
||||||
|
const index = options.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
const move = (to: number) => {
|
||||||
|
event.preventDefault();
|
||||||
|
options[(to + options.length) % options.length]?.focus();
|
||||||
|
};
|
||||||
|
if (event.key === 'ArrowDown') move(index + 1);
|
||||||
|
else if (event.key === 'ArrowUp') move(index - 1);
|
||||||
|
else if (event.key === 'Home') move(0);
|
||||||
|
else if (event.key === 'End') move(options.length - 1);
|
||||||
|
else if (event.key === 'Tab') this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private choose(value: MenuValue): void {
|
||||||
|
switch (value) {
|
||||||
|
case '2d':
|
||||||
|
case '3d':
|
||||||
|
if ((value === '3d') !== this.active) this.setActive(value === '3d');
|
||||||
|
return;
|
||||||
|
case 'flat':
|
||||||
|
case 'stacked':
|
||||||
|
this.stage?.setLayered(value === 'stacked');
|
||||||
|
this.save();
|
||||||
|
return;
|
||||||
|
case 'reset':
|
||||||
|
this.stage?.fit();
|
||||||
|
return;
|
||||||
|
case 'top':
|
||||||
|
this.stage?.top();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addExportItem(): void {
|
||||||
|
const png = document.querySelector('.export-menu button[data-format="png"]');
|
||||||
|
if (!png?.parentElement) return;
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
item.tabIndex = -1;
|
||||||
|
item.hidden = true;
|
||||||
|
item.dataset.archify3dExport = '';
|
||||||
|
item.innerHTML = '<span class="export-item-copy"><strong>3D view</strong><small class="hint">PNG of the current 3D view</small></span>';
|
||||||
|
item.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
document.getElementById('btn-export')?.click();
|
||||||
|
this.exportPng();
|
||||||
|
});
|
||||||
|
png.parentElement.insertBefore(item, png);
|
||||||
|
this.exportItem = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archify's zoom buttons and keys drive the 3D camera while 3D is on. */
|
||||||
|
private interceptViewerControls(): void {
|
||||||
|
this.container.addEventListener(
|
||||||
|
'click',
|
||||||
|
(event) => {
|
||||||
|
if (!this.active || !this.stage) return;
|
||||||
|
const button = (event.target as Element | null)?.closest?.('[data-view]');
|
||||||
|
const view = button?.getAttribute('data-view');
|
||||||
|
if (!view) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
if (view === 'in') this.stage.zoom(1.25);
|
||||||
|
else if (view === 'out') this.stage.zoom(0.8);
|
||||||
|
else if (view === 'reset') this.stage.fit();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'keydown',
|
||||||
|
(event) => {
|
||||||
|
if (!this.active || !this.stage || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
||||||
|
const actions: Record<string, () => void> = {
|
||||||
|
'0': () => this.stage!.fit(),
|
||||||
|
'+': () => this.stage!.zoom(1.25),
|
||||||
|
'=': () => this.stage!.zoom(1.25),
|
||||||
|
'-': () => this.stage!.zoom(0.8),
|
||||||
|
};
|
||||||
|
const action = actions[event.key];
|
||||||
|
if (!action) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
action();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncButtons(): void {
|
||||||
|
const layered = !!this.stage?.layered;
|
||||||
|
this.button.toggleAttribute('data-active', this.active);
|
||||||
|
for (const option of this.menu.querySelectorAll<HTMLButtonElement>('.archify-3d-option')) {
|
||||||
|
const value = option.dataset.archify3d as MenuValue;
|
||||||
|
if (value === '2d' || value === '3d') option.setAttribute('aria-checked', String((value === '3d') === this.active));
|
||||||
|
if (value === 'flat' || value === 'stacked') option.setAttribute('aria-checked', String((value === 'stacked') === layered));
|
||||||
|
// Levels and camera only apply to the 3D view.
|
||||||
|
if (value !== '2d' && value !== '3d') option.setAttribute('aria-disabled', String(!this.active));
|
||||||
|
}
|
||||||
|
if (this.exportItem) this.exportItem.hidden = !this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- mode
|
||||||
|
|
||||||
|
setActive(on: boolean): void {
|
||||||
|
this.active = on;
|
||||||
|
document.documentElement.classList.toggle('archify-3d-on', on);
|
||||||
|
if (on) {
|
||||||
|
if (!this.stage) this.createStage();
|
||||||
|
this.container.appendChild(this.stage!.element);
|
||||||
|
this.stage!.start();
|
||||||
|
this.observer.observe(this.svg, { subtree: true, attributes: true, childList: true, characterData: true });
|
||||||
|
this.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-preset'] });
|
||||||
|
this.holdZoomButtons(true);
|
||||||
|
void this.redraw().then(() => this.replayAmbient());
|
||||||
|
} else {
|
||||||
|
this.holdZoomButtons(false);
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.stage?.stop();
|
||||||
|
this.stage?.element.remove();
|
||||||
|
}
|
||||||
|
this.syncButtons();
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoomButtons(): HTMLButtonElement[] {
|
||||||
|
return [...this.container.querySelectorAll<HTMLButtonElement>('button[data-view="in"], button[data-view="out"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify disables its zoom buttons at the 2D limits (100% and 300%). The
|
||||||
|
* 3D camera has its own range, so the buttons stay enabled while 3D is on,
|
||||||
|
* and get Archify's state back when 3D is turned off.
|
||||||
|
*/
|
||||||
|
private holdZoomButtons(on: boolean): void {
|
||||||
|
if (on) {
|
||||||
|
this.zoomDisabled = new Map(this.zoomButtons().map((b) => [b, b.disabled]));
|
||||||
|
for (const button of this.zoomButtons()) this.zoomObserver.observe(button, { attributes: true, attributeFilter: ['disabled'] });
|
||||||
|
this.enableZoomButtons();
|
||||||
|
} else {
|
||||||
|
this.zoomObserver.disconnect();
|
||||||
|
for (const [button, disabled] of this.zoomDisabled) button.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enableZoomButtons(): void {
|
||||||
|
for (const button of this.zoomButtons()) if (button.disabled) button.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStage(): void {
|
||||||
|
const stage = new Stage();
|
||||||
|
stage.onPick = (id) => this.pick(id);
|
||||||
|
stage.onHover = (id) => this.hover(id);
|
||||||
|
stage.onCameraChange = () => this.save();
|
||||||
|
if (this.saved.layered) stage.setLayered(true, false);
|
||||||
|
if (this.saved.camera) stage.restoreCamera(this.saved.camera);
|
||||||
|
this.stage = stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify plays its ambient trace once, when the page opens. Opening 3D
|
||||||
|
* later replays it once, so the 3D view starts the way 2D did. Readers who
|
||||||
|
* paused motion (Live off) or prefer reduced motion get no replay.
|
||||||
|
*/
|
||||||
|
private replayAmbient(): void {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (this.ambientReplayed || !this.active) return;
|
||||||
|
// The load-time pass may still be playing (usually under the not-yet-built
|
||||||
|
// 3D view): wait for it to settle, then replay it in 3D.
|
||||||
|
if (html.getAttribute('data-ambient-motion') === 'running') {
|
||||||
|
const wait = new MutationObserver(() => {
|
||||||
|
if (html.getAttribute('data-ambient-motion') === 'running') return;
|
||||||
|
wait.disconnect();
|
||||||
|
this.replayAmbient();
|
||||||
|
});
|
||||||
|
wait.observe(html, { attributes: true, attributeFilter: ['data-ambient-motion'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ambientReplayed = true;
|
||||||
|
if (html.getAttribute('data-ambient-motion') !== 'settled' || html.getAttribute('data-motion') === 'still') return;
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
if (!this.svg.querySelector('[data-animate]')) return;
|
||||||
|
// Restart Archify's CSS trace: the "settled" state must be applied
|
||||||
|
// (style flushed) before "running" again, or the animations do not restart.
|
||||||
|
html.setAttribute('data-ambient-motion', 'settled');
|
||||||
|
void getComputedStyle(this.svg.querySelector('[data-animate]')!).animationName;
|
||||||
|
html.setAttribute('data-ambient-motion', 'running');
|
||||||
|
const settle = () => html.setAttribute('data-ambient-motion', 'settled');
|
||||||
|
const done = () => {
|
||||||
|
if (this.svg.getAnimations({ subtree: true }).some((anim) => anim.playState === 'running')) return;
|
||||||
|
this.svg.removeEventListener('animationend', done, true);
|
||||||
|
this.svg.removeEventListener('animationcancel', done, true);
|
||||||
|
settle();
|
||||||
|
};
|
||||||
|
this.svg.addEventListener('animationend', done, true);
|
||||||
|
this.svg.addEventListener('animationcancel', done, true);
|
||||||
|
// Safety net if an animation never reports its end.
|
||||||
|
window.setTimeout(settle, 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(): void {
|
||||||
|
this.saved = { active: this.active, layered: this.stage?.layered ?? this.saved.layered, camera: this.stage?.cameraState() ?? this.saved.camera };
|
||||||
|
this.host.setState({ view3d: this.saved });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- picking
|
||||||
|
|
||||||
|
/** Hands a 3D click to Archify, as if the reader had clicked the 2D node. */
|
||||||
|
private pick(id: string | undefined): void {
|
||||||
|
const target = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) : this.svg;
|
||||||
|
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hovered: Element | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands 3D hover to Archify as pointer events on the SVG node, so its
|
||||||
|
* intent trace (one-hop flows, fading the rest) plays as in 2D.
|
||||||
|
*/
|
||||||
|
private hover(id: string | undefined): void {
|
||||||
|
const next = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) ?? undefined : undefined;
|
||||||
|
if (next === this.hovered) return;
|
||||||
|
const init = { bubbles: true, cancelable: true, pointerType: 'mouse', isPrimary: true, view: window };
|
||||||
|
this.hovered?.dispatchEvent(new PointerEvent('pointerout', init));
|
||||||
|
next?.dispatchEvent(new PointerEvent('pointerover', init));
|
||||||
|
this.hovered = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- redraw
|
||||||
|
|
||||||
|
private onMutation(records: MutationRecord[]): void {
|
||||||
|
if (!records.some((r) => this.changesDrawing(r))) return;
|
||||||
|
window.clearTimeout(this.redrawTimer);
|
||||||
|
// Let Archify's 140–200ms state transitions finish before sampling styles.
|
||||||
|
this.redrawTimer = window.setTimeout(() => void this.redraw(), REDRAW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a mutation changes what the textures show. Archify's camera
|
||||||
|
* (the SVG root's style), its temporary overlays and the ambient-motion
|
||||||
|
* flag only change motion, which the stage follows live.
|
||||||
|
*/
|
||||||
|
private changesDrawing(record: MutationRecord): boolean {
|
||||||
|
const target = record.target as Element;
|
||||||
|
if (target === this.svg && record.attributeName === 'style') return false;
|
||||||
|
if (target === document.documentElement) return record.attributeName === 'data-theme' || record.attributeName === 'data-preset';
|
||||||
|
// Intent trace only fades and draws flows; the stage mirrors both live.
|
||||||
|
if (record.attributeName?.startsWith('data-intent-trace')) return false;
|
||||||
|
const inOverlay = (node: Node) => node instanceof Element && !!node.closest('[data-relationship-pulse-overlay], [data-intent-trace-overlay], [data-route-probe-overlay], [data-route-journey-overlay], [data-semantic-lens-overlay], [data-story-overlay], [data-story-carrier-overlay], [data-chapter-handoff-overlay], [data-relationship-hit-overlay]');
|
||||||
|
if (record.type === 'childList') {
|
||||||
|
const nodes = [...record.addedNodes, ...record.removedNodes].filter((n) => n.nodeType === Node.ELEMENT_NODE);
|
||||||
|
if (nodes.length && nodes.every((n) => inOverlay(n) || [...(n as Element).getAttributeNames()].some((a) => a.endsWith('-overlay')))) return false;
|
||||||
|
return nodes.length > 0 || !inOverlay(target);
|
||||||
|
}
|
||||||
|
return !inOverlay(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async redraw(): Promise<void> {
|
||||||
|
if (this.drawing) {
|
||||||
|
this.pendingRedraw = true;
|
||||||
|
return this.drawing;
|
||||||
|
}
|
||||||
|
this.drawing = this.draw().finally(() => {
|
||||||
|
this.drawing = undefined;
|
||||||
|
if (this.pendingRedraw) {
|
||||||
|
this.pendingRedraw = false;
|
||||||
|
void this.redraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.drawing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async draw(): Promise<void> {
|
||||||
|
if (!this.active || !this.stage) return;
|
||||||
|
await document.fonts?.ready;
|
||||||
|
const model = readModel(this.svg);
|
||||||
|
this.model = model;
|
||||||
|
const { viewBox } = model;
|
||||||
|
const area = { x: viewBox.x - MARGIN, y: viewBox.y - MARGIN, w: viewBox.w + MARGIN * 2, h: viewBox.h + MARGIN * 2 };
|
||||||
|
const scale = Math.min(TEXTURE_SCALE, 8192 / Math.max(area.w, area.h));
|
||||||
|
const root = getComputedStyle(document.documentElement);
|
||||||
|
const containerStyle = getComputedStyle(this.container);
|
||||||
|
const background = opaque(containerStyle.backgroundColor) ?? (root.getPropertyValue('--bg').trim() || '#020617');
|
||||||
|
const dot = root.getPropertyValue('--canvas-dot').trim();
|
||||||
|
|
||||||
|
const paintFloor = (ctx: CanvasRenderingContext2D) => {
|
||||||
|
ctx.fillStyle = background;
|
||||||
|
ctx.fillRect(area.x, area.y, area.w, area.h);
|
||||||
|
if (dot) {
|
||||||
|
ctx.fillStyle = dot;
|
||||||
|
for (let x = Math.ceil(area.x / 20) * 20; x < area.x + area.w; x += 20) {
|
||||||
|
for (let y = Math.ceil(area.y / 20) * 20; y < area.y + area.h; y += 20) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [floor, nodes, labels] = await Promise.all([
|
||||||
|
rasterize(layerMarkup(this.svg, viewBox, { keep: ['floor'] }, scale), viewBox, area, scale, paintFloor),
|
||||||
|
rasterize(layerMarkup(this.svg, viewBox, { keep: ['node'], opaqueNodes: true }, scale), viewBox, area, scale),
|
||||||
|
rasterize(layerMarkup(this.svg, viewBox, { keep: ['label'] }, scale), viewBox, area, scale),
|
||||||
|
]);
|
||||||
|
if (!this.active || this.model !== model) return;
|
||||||
|
const layers: Layers = { floor, nodes, labels };
|
||||||
|
const mask = root.getPropertyValue('--mask').trim() || '#0f172a';
|
||||||
|
this.stage.build(model, layers, { mask, dark: document.documentElement.getAttribute('data-theme') !== 'light' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- export
|
||||||
|
|
||||||
|
private exportPng(): void {
|
||||||
|
if (!this.stage) return;
|
||||||
|
const shot = new Image();
|
||||||
|
shot.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = shot.width;
|
||||||
|
canvas.height = shot.height;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.fillStyle = opaque(getComputedStyle(this.container).backgroundColor) ?? getComputedStyle(document.documentElement).getPropertyValue('--bg');
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(shot, 0, 0);
|
||||||
|
const data = canvas.toDataURL('image/png');
|
||||||
|
const title = (document.querySelector('.header h1')?.textContent ?? 'archify').trim().replace(/[^\w.-]+/g, '-').toLowerCase();
|
||||||
|
this.host.post({ type: 'save', filename: `${title || 'archify'}-3d.png`, base64: data.slice(data.indexOf(',') + 1) });
|
||||||
|
};
|
||||||
|
shot.src = this.stage.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A CSS colour if it is not fully transparent. */
|
||||||
|
function opaque(value: string): string | undefined {
|
||||||
|
if (!value || value === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(value)) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): void {
|
||||||
|
const host = window.__archifyHost;
|
||||||
|
const container = document.querySelector<HTMLElement>('.diagram-container');
|
||||||
|
const svg = container?.querySelector<SVGSVGElement>(':scope > svg');
|
||||||
|
if (!host || !container || !svg) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = STYLE;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
new Viewer3D(host, container, svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') start();
|
||||||
|
else window.addEventListener('load', start, { once: true });
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Box, Role, roleOf } from './svgModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns parts of the live Archify SVG into images. Each element's computed
|
||||||
|
* style is written inline into a standalone copy, so the image carries the
|
||||||
|
* current theme, preset, focus and dimming exactly as the 2D view shows them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Presentation properties copied from computed style. */
|
||||||
|
const PROPS = [
|
||||||
|
'fill', 'fill-opacity', 'fill-rule',
|
||||||
|
'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset',
|
||||||
|
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
||||||
|
// visibility is left out: the 3D view hides the 2D SVG with it.
|
||||||
|
'opacity', 'display', 'filter', 'paint-order',
|
||||||
|
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'text-anchor',
|
||||||
|
'dominant-baseline', 'text-transform',
|
||||||
|
];
|
||||||
|
|
||||||
|
let fontCss: string | undefined;
|
||||||
|
|
||||||
|
/** The page's @font-face rules (Archify embeds JetBrains Mono as data URIs). */
|
||||||
|
function fontFaces(): string {
|
||||||
|
if (fontCss !== undefined) return fontCss;
|
||||||
|
const rules: string[] = [];
|
||||||
|
for (const sheet of [...document.styleSheets]) {
|
||||||
|
let list: CSSRuleList;
|
||||||
|
try {
|
||||||
|
list = sheet.cssRules;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule.cssText);
|
||||||
|
}
|
||||||
|
fontCss = rules.join('\n');
|
||||||
|
return fontCss;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerOptions {
|
||||||
|
/** Roles whose elements are drawn; everything else is hidden. */
|
||||||
|
keep: Role[];
|
||||||
|
/** Draw kept nodes fully opaque; the 3D view applies their dimming itself. */
|
||||||
|
opaqueNodes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies computed styles from the live tree onto a clone with the same shape. */
|
||||||
|
function inlineStyles(live: Element, copy: Element, options: LayerOptions, inherited: Role | undefined, root: boolean): void {
|
||||||
|
const role = inherited ?? roleOf(live);
|
||||||
|
const isDefs = live.tagName === 'defs' || !!live.closest('defs');
|
||||||
|
const style = getComputedStyle(live);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const prop of PROPS) {
|
||||||
|
const value = style.getPropertyValue(prop);
|
||||||
|
if (value) parts.push(`${prop}:${value}`);
|
||||||
|
}
|
||||||
|
let hidden = false;
|
||||||
|
if (root) {
|
||||||
|
// The root carries viewer state (camera transform, our fade); keep only
|
||||||
|
// what its children inherit.
|
||||||
|
copy.setAttribute('style', parts.filter((p) => /^(font|letter|text)/.test(p)).join(';'));
|
||||||
|
} else {
|
||||||
|
if (!isDefs) {
|
||||||
|
if (role) hidden = !options.keep.includes(role);
|
||||||
|
else if (live.children.length === 0) hidden = !options.keep.includes('floor');
|
||||||
|
}
|
||||||
|
if (options.opaqueNodes && role === 'node' && !inherited) parts.push('opacity:1');
|
||||||
|
if (hidden) parts.push('display:none');
|
||||||
|
copy.setAttribute('style', parts.join(';'));
|
||||||
|
}
|
||||||
|
copy.removeAttribute('class');
|
||||||
|
if (hidden) return;
|
||||||
|
const liveKids = live.children;
|
||||||
|
const copyKids = copy.children;
|
||||||
|
for (let i = 0; i < liveKids.length && i < copyKids.length; i++) {
|
||||||
|
inlineStyles(liveKids[i], copyKids[i], options, role, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone SVG markup for one layer of the diagram, sized at `scale`
|
||||||
|
* pixels per user unit so the browser rasterizes it sharply.
|
||||||
|
*/
|
||||||
|
export function layerMarkup(svg: SVGSVGElement, viewBox: Box, options: LayerOptions, scale: number): string {
|
||||||
|
const copy = svg.cloneNode(true) as SVGSVGElement;
|
||||||
|
inlineStyles(svg, copy, options, undefined, true);
|
||||||
|
// Viewer-only children (focus overlays, hit areas) carry no data roles but
|
||||||
|
// are not part of the authored drawing.
|
||||||
|
copy.querySelectorAll('[data-viewer-only], .no-export').forEach((el) => el.remove());
|
||||||
|
copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
|
||||||
|
copy.setAttribute('width', String(Math.round(viewBox.w * scale)));
|
||||||
|
copy.setAttribute('height', String(Math.round(viewBox.h * scale)));
|
||||||
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||||
|
style.textContent = fontFaces();
|
||||||
|
copy.insertBefore(style, copy.firstChild);
|
||||||
|
return new XMLSerializer().serializeToString(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Raster {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
/** Pixels per SVG user unit. */
|
||||||
|
scale: number;
|
||||||
|
/** SVG area the canvas covers (the view box plus a margin). */
|
||||||
|
area: Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rasterizes SVG markup over `area` (in user units) at `scale` pixels per unit. */
|
||||||
|
export async function rasterize(markup: string, viewBox: Box, area: Box, scale: number, paint?: (ctx: CanvasRenderingContext2D) => void): Promise<Raster> {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(area.w * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(area.h * scale));
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.translate(-area.x, -area.y);
|
||||||
|
paint?.(ctx);
|
||||||
|
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }));
|
||||||
|
try {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = url;
|
||||||
|
await image.decode();
|
||||||
|
ctx.drawImage(image, viewBox.x, viewBox.y, viewBox.w, viewBox.h);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
return { canvas, scale, area };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
/** Styles for the 3D mode; they reuse Archify's tokens so every preset fits. */
|
||||||
|
export const STYLE = `
|
||||||
|
.diagram-container { position: relative; }
|
||||||
|
/* The 2D SVG stays in the page (its styles and animations drive the 3D
|
||||||
|
view) but is not painted while 3D is on. */
|
||||||
|
html.archify-3d-on .diagram-container > svg { visibility: hidden !important; pointer-events: none !important; }
|
||||||
|
html.archify-3d-on .diagram-container { min-height: max(420px, 62vh); }
|
||||||
|
.archify-3d-stage { position: absolute; inset: 0; z-index: 1; border-radius: inherit; overflow: hidden; }
|
||||||
|
.archify-3d-stage canvas { display: block; touch-action: none; cursor: grab; }
|
||||||
|
.archify-3d-stage canvas:active { cursor: grabbing; }
|
||||||
|
html.archify-3d-on .diagram-container > :not(svg):not(.archify-3d-stage) { z-index: 2; }
|
||||||
|
|
||||||
|
/* The 3D button is Export's twin: same pill, same type, same menu. */
|
||||||
|
.toolbar #btn-3d { padding-right: 0.95rem; padding-left: 1rem; color: var(--bg); font-weight: 550; }
|
||||||
|
.toolbar #btn-3d::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: var(--text);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||||
|
.toolbar #btn-3d:hover,
|
||||||
|
.toolbar #btn-3d[aria-expanded="true"] { color: var(--bg); }
|
||||||
|
.toolbar #btn-3d:hover::before,
|
||||||
|
.toolbar #btn-3d[aria-expanded="true"]::before { background: color-mix(in srgb, var(--text) 86%, var(--bg)); }
|
||||||
|
/* While the 3D view is on, the pill wears a cyan ring. */
|
||||||
|
.toolbar #btn-3d[data-active]::before { box-shadow: 0 0 0 2px var(--frontend-stroke), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||||
|
.toolbar .archify-3d-wrap .archify-3d-menu { right: 0; }
|
||||||
|
.toolbar .export-menu button[data-archify3d-export]::before { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
|
||||||
|
/* Menu rows: Archify's .preset-option layout, with an icon swatch. */
|
||||||
|
.toolbar .archify-3d-menu { width: 18.5rem; }
|
||||||
|
.toolbar .archify-3d-menu .preset-menu-heading { padding-top: 0.6rem; }
|
||||||
|
.toolbar .archify-3d-menu .preset-menu-heading:first-child { padding-top: 0.45rem; }
|
||||||
|
.archify-3d-swatch { display: grid; place-items: center; width: 2.375rem; height: 1.75rem; border-radius: 0.4375rem; color: var(--text-muted);
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 14%, transparent);
|
||||||
|
transition: box-shadow 0.14s ease, color 0.14s ease; }
|
||||||
|
.toolbar .archify-3d-option[aria-checked="true"] .archify-3d-swatch { color: var(--frontend-stroke); box-shadow: 0 0 0 2px var(--toolbar-menu-bg), 0 0 0 3.5px var(--frontend-stroke); }
|
||||||
|
.archify-3d-swatch-icon { width: 1.05rem; height: 1.05rem; background: currentColor; -webkit-mask: center / contain no-repeat; mask: center / contain no-repeat; }
|
||||||
|
.archify-3d-i-2d { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='14' height='12' rx='1.5'/%3E%3Cpath d='M6 8h3M6 11h5M12.5 8h1.5'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='14' height='12' rx='1.5'/%3E%3Cpath d='M6 8h3M6 11h5M12.5 8h1.5'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.archify-3d-i-3d { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.archify-3d-i-flat { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 6 17.5 9.8 10 13.6 2.5 9.8z'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 6 17.5 9.8 10 13.6 2.5 9.8z'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.archify-3d-i-stacked { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.archify-3d-i-reset { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 10a6.5 6.5 0 1 0 2-4.7'/%3E%3Cpath d='M3.5 3.5v3.5H7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 10a6.5 6.5 0 1 0 2-4.7'/%3E%3Cpath d='M3.5 3.5v3.5H7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.archify-3d-i-top { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.toolbar .archify-3d-option[aria-disabled="true"] { opacity: 0.42; cursor: default; }
|
||||||
|
.toolbar .archify-3d-option[aria-disabled="true"]:hover { background: transparent; }
|
||||||
|
.archify-3d-kbd { justify-self: end; min-width: 1rem; color: var(--text-faint); font: inherit; font-size: 0.6875rem; text-align: center; }
|
||||||
|
.archify-3d-kbd:empty { visibility: hidden; }
|
||||||
|
`;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Reads the semantic parts of a rendered Archify SVG: nodes, routed
|
||||||
|
* relationships, their labels and structural frames. Archify marks them
|
||||||
|
* with the same data attributes in all five diagram types, so the 3D view
|
||||||
|
* needs no per-type code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Box {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeShape extends Box {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
rx: number;
|
||||||
|
el: SVGGElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeShape {
|
||||||
|
key: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
path: SVGPathElement;
|
||||||
|
/** Points along the route, every SAMPLE_STEP user units. */
|
||||||
|
points: [number, number][];
|
||||||
|
length: number;
|
||||||
|
animated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelShape extends Box {
|
||||||
|
key: string;
|
||||||
|
el: SVGGElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameShape extends Box {
|
||||||
|
kind: string;
|
||||||
|
rx: number;
|
||||||
|
el: SVGRectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramModel {
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
viewBox: Box;
|
||||||
|
nodes: NodeShape[];
|
||||||
|
edges: EdgeShape[];
|
||||||
|
labels: LabelShape[];
|
||||||
|
frames: FrameShape[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_STEP = 3;
|
||||||
|
|
||||||
|
/** Role of an element for the 3D view; descendants share their ancestor's role. */
|
||||||
|
export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify's temporary viewer layers (focus pulse, intent trace, route probe,
|
||||||
|
* journey, lens, story) are marked data-…-overlay. They are animated in 3D
|
||||||
|
* from the live SVG and never baked into the textures.
|
||||||
|
*/
|
||||||
|
function isOverlay(el: Element): boolean {
|
||||||
|
for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */
|
||||||
|
export const FLOW_SELECTOR = [
|
||||||
|
'relationship-pulse',
|
||||||
|
'intent-trace',
|
||||||
|
'route-probe',
|
||||||
|
'route-journey',
|
||||||
|
'semantic-lens',
|
||||||
|
'story',
|
||||||
|
'story-carrier',
|
||||||
|
'chapter-handoff',
|
||||||
|
].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', ');
|
||||||
|
|
||||||
|
export function roleOf(el: Element): Role | undefined {
|
||||||
|
if (isOverlay(el)) return 'overlay';
|
||||||
|
if (el.hasAttribute('data-node-id')) return 'node';
|
||||||
|
if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge';
|
||||||
|
if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge';
|
||||||
|
if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge';
|
||||||
|
if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(el: Element, name: string): number {
|
||||||
|
return Number(el.getAttribute(name) ?? 0) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectBox(rect: SVGRectElement): Box & { rx: number } {
|
||||||
|
return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeShape(el: SVGGElement): NodeShape | undefined {
|
||||||
|
const id = el.getAttribute('data-node-id');
|
||||||
|
if (!id) return undefined;
|
||||||
|
const kind = el.getAttribute('data-node-kind') ?? 'backend';
|
||||||
|
// The node body is the semantic rect (c-<kind>); c-mask sits under it.
|
||||||
|
const rect =
|
||||||
|
el.querySelector<SVGRectElement>(`:scope > rect.c-${CSS.escape(kind)}`) ??
|
||||||
|
el.querySelector<SVGRectElement>(':scope > rect:not(.c-mask)') ??
|
||||||
|
el.querySelector<SVGRectElement>(':scope > rect');
|
||||||
|
if (rect) return { id, kind, el, ...rectBox(rect) };
|
||||||
|
const bbox = el.getBBox();
|
||||||
|
return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } {
|
||||||
|
const length = path.getTotalLength();
|
||||||
|
const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP));
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const p = path.getPointAtLength((length * i) / steps);
|
||||||
|
points.push([p.x, p.y]);
|
||||||
|
}
|
||||||
|
return { points, length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readModel(svg: SVGSVGElement): DiagramModel {
|
||||||
|
const vb = svg.viewBox.baseVal;
|
||||||
|
const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
|
||||||
|
|
||||||
|
const nodes = [...svg.querySelectorAll<SVGGElement>('g[data-node-id]')]
|
||||||
|
.map(nodeShape)
|
||||||
|
.filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0);
|
||||||
|
|
||||||
|
const edges: EdgeShape[] = [];
|
||||||
|
svg.querySelectorAll<SVGPathElement>('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => {
|
||||||
|
const from = path.getAttribute('data-edge-from') ?? '';
|
||||||
|
const to = path.getAttribute('data-edge-to') ?? '';
|
||||||
|
const group = path.parentElement;
|
||||||
|
const { points, length } = samplePath(path);
|
||||||
|
if (length <= 0) return;
|
||||||
|
edges.push({
|
||||||
|
key: path.getAttribute('data-edge-key') ?? `e${index}`,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
path,
|
||||||
|
points,
|
||||||
|
length,
|
||||||
|
animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels: LabelShape[] = [];
|
||||||
|
svg.querySelectorAll<SVGGElement>('g[data-edge-key]').forEach((el) => {
|
||||||
|
if (roleOf(el) !== 'label') return;
|
||||||
|
const rect = el.querySelector<SVGRectElement>(':scope > rect');
|
||||||
|
const box = rect ? rectBox(rect) : (() => {
|
||||||
|
const b = el.getBBox();
|
||||||
|
return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 };
|
||||||
|
})();
|
||||||
|
if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box });
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = [...svg.querySelectorAll<SVGRectElement>('rect[data-graph-role="structural-frame"]')].map((el) => ({
|
||||||
|
kind: el.getAttribute('data-composition-frame-kind') ?? 'region',
|
||||||
|
el,
|
||||||
|
...rectBox(el),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { svg, viewBox, nodes, edges, labels, frames };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity of an element including its ancestors below the SVG root. The
|
||||||
|
* root is left out: the 3D view hides the 2D SVG by fading the root.
|
||||||
|
*/
|
||||||
|
export function effectiveOpacity(el: Element, root: Element): number {
|
||||||
|
let value = 1;
|
||||||
|
for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) {
|
||||||
|
const style = getComputedStyle(cur);
|
||||||
|
if (style.display === 'none') return 0;
|
||||||
|
value *= Number(style.opacity || 1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isArchifyFile, readDiagramSource } from './detect';
|
import { renderDiagram } from '../archify/cli';
|
||||||
import { renderDiagram } from './renderer';
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
|
import { isArchifyFile, readDiagramSource } from '../sources/detect';
|
||||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
import { readText } from '../util/files';
|
||||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
import { log } from '../util/log';
|
||||||
|
import { viewerTheme } from '../util/settings';
|
||||||
|
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||||
|
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
|
||||||
|
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||||
|
|
||||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||||
/** Default editor for .archify files: the rendered diagram. */
|
/** Default editor for .archify files: the rendered diagram. */
|
||||||
@@ -34,7 +38,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
readonly isEditor = false,
|
readonly isEditor = false,
|
||||||
) {
|
) {
|
||||||
this.hash = state?.hash;
|
this.hash = state?.hash;
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||||
if (!isEditor) {
|
if (!isEditor) {
|
||||||
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
||||||
panel.title = `Preview ${path.basename(uri.path)}`;
|
panel.title = `Preview ${path.basename(uri.path)}`;
|
||||||
@@ -134,6 +138,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
hash: this.hash,
|
hash: this.hash,
|
||||||
state: this.state(),
|
state: this.state(),
|
||||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||||
|
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||||
});
|
});
|
||||||
// Nonces differ per render, so compare the renderer output instead.
|
// Nonces differ per render, so compare the renderer output instead.
|
||||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
|
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
|
||||||
@@ -158,7 +163,7 @@ function firstLines(text: string | undefined, count = 6): string {
|
|||||||
return (text ?? '').split('\n').slice(0, count).join('\n');
|
return (text ?? '').split('\n').slice(0, count).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PreviewManager
|
export class DiagramViewManager
|
||||||
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
||||||
{
|
{
|
||||||
/** Side previews, one per source file. */
|
/** Side previews, one per source file. */
|
||||||
@@ -204,7 +209,7 @@ export class PreviewManager
|
|||||||
|
|
||||||
/** Opening a .archify file lands here: the diagram is the editor. */
|
/** Opening a .archify file lands here: the diagram is the editor. */
|
||||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||||
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +223,7 @@ export class PreviewManager
|
|||||||
PREVIEW_VIEW_TYPE,
|
PREVIEW_VIEW_TYPE,
|
||||||
`Preview ${path.basename(uri.path)}`,
|
`Preview ${path.basename(uri.path)}`,
|
||||||
{ viewColumn, preserveFocus },
|
{ viewColumn, preserveFocus },
|
||||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: [] },
|
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
||||||
);
|
);
|
||||||
this.track(new DiagramPreview(this.context, panel, uri));
|
this.track(new DiagramPreview(this.context, panel, uri));
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isArchifyHtml } from './detect';
|
import { isArchifyHtml } from '../sources/detect';
|
||||||
import { viewerTheme } from './runtime';
|
import { viewerTheme } from '../util/settings';
|
||||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
|
||||||
|
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||||
|
|
||||||
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
|
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
|||||||
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
|
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
|
||||||
private readonly disposables: vscode.Disposable[];
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly extensionUri: vscode.Uri) {
|
||||||
this.disposables = [
|
this.disposables = [
|
||||||
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
|
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
|
||||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||||
@@ -47,7 +48,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
|||||||
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
|
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
const uri = document.uri;
|
const uri = document.uri;
|
||||||
let hash: string | undefined;
|
let hash: string | undefined;
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.extensionUri) };
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
let text: string;
|
let text: string;
|
||||||
@@ -70,6 +71,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
|||||||
notice: isArchifyHtml(text)
|
notice: isArchifyHtml(text)
|
||||||
? undefined
|
? undefined
|
||||||
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
|
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
|
||||||
|
viewer3d: isArchifyHtml(text) ? viewer3dOption(panel.webview, this.extensionUri) : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { log, repoRootFor } from './runtime';
|
import { repoRootFor } from '../archify/runtime';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
|
||||||
export type WebviewMessage =
|
export type WebviewMessage =
|
||||||
| { type: 'hash'; hash: string }
|
| { type: 'hash'; hash: string }
|
||||||
@@ -12,6 +12,8 @@ export interface PageOptions {
|
|||||||
state?: unknown;
|
state?: unknown;
|
||||||
/** Banner shown on top of the diagram. */
|
/** Banner shown on top of the diagram. */
|
||||||
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
||||||
|
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
||||||
|
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeNonce(): string {
|
export function makeNonce(): string {
|
||||||
@@ -46,7 +48,7 @@ export function contentSecurityPolicy(cspSource: string, nonce: string): string
|
|||||||
`media-src ${cspSource} data: blob:`,
|
`media-src ${cspSource} data: blob:`,
|
||||||
`font-src ${cspSource} data:`,
|
`font-src ${cspSource} data:`,
|
||||||
`style-src ${cspSource} 'unsafe-inline'`,
|
`style-src ${cspSource} 'unsafe-inline'`,
|
||||||
`script-src 'nonce-${nonce}'`,
|
`script-src ${cspSource} 'nonce-${nonce}'`,
|
||||||
'connect-src data: blob:',
|
'connect-src data: blob:',
|
||||||
'worker-src blob:',
|
'worker-src blob:',
|
||||||
].join('; ');
|
].join('; ');
|
||||||
@@ -58,11 +60,17 @@ export function contentSecurityPolicy(cspSource: string, nonce: string): string
|
|||||||
* webview cannot navigate or download on its own.
|
* webview cannot navigate or download on its own.
|
||||||
*/
|
*/
|
||||||
function bridgeScript(options: PageOptions): string {
|
function bridgeScript(options: PageOptions): string {
|
||||||
const config = { theme: options.theme, hash: options.hash, state: options.state, notice: options.notice };
|
const config = {
|
||||||
|
theme: options.theme,
|
||||||
|
hash: options.hash,
|
||||||
|
state: options.state,
|
||||||
|
notice: options.notice,
|
||||||
|
defaultView: options.viewer3d?.defaultView,
|
||||||
|
};
|
||||||
return `(function () {
|
return `(function () {
|
||||||
var config = ${scriptJson(config)};
|
var config = ${scriptJson(config)};
|
||||||
var vscode = acquireVsCodeApi();
|
var vscode = acquireVsCodeApi();
|
||||||
if (config.state) vscode.setState(config.state);
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state));
|
||||||
if (config.theme) {
|
if (config.theme) {
|
||||||
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -70,12 +78,20 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', function () {
|
window.addEventListener('hashchange', function () {
|
||||||
if (config.state) vscode.setState(Object.assign({}, config.state, { hash: location.hash }));
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state, { hash: location.hash }));
|
||||||
vscode.postMessage({ type: 'hash', hash: location.hash });
|
vscode.postMessage({ type: 'hash', hash: location.hash });
|
||||||
});
|
});
|
||||||
|
|
||||||
function post(message) { vscode.postMessage(message); }
|
function post(message) { vscode.postMessage(message); }
|
||||||
|
|
||||||
|
// For the 3D viewer, which cannot call acquireVsCodeApi a second time.
|
||||||
|
window.__archifyHost = {
|
||||||
|
defaultView: config.defaultView || '2d',
|
||||||
|
post: post,
|
||||||
|
getState: function () { return vscode.getState() || {}; },
|
||||||
|
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); }
|
||||||
|
};
|
||||||
|
|
||||||
function saveBlobUrl(url, filename) {
|
function saveBlobUrl(url, filename) {
|
||||||
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
@@ -189,6 +205,11 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
|||||||
// Allow the page's own inline scripts to run under the nonce policy.
|
// Allow the page's own inline scripts to run under the nonce policy.
|
||||||
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
||||||
|
|
||||||
|
if (options.viewer3d) {
|
||||||
|
const tag = `<script nonce="${options.nonce}" defer src="${escapeHtml(options.viewer3d.scriptUri)}"></script>\n`;
|
||||||
|
page = /<\/head>/i.test(page) ? page.replace(/<\/head>/i, `${tag}</head>`) : `${tag}${page}`;
|
||||||
|
}
|
||||||
|
|
||||||
const headMatch = /<head\b[^>]*>/i.exec(page);
|
const headMatch = /<head\b[^>]*>/i.exec(page);
|
||||||
if (headMatch) {
|
if (headMatch) {
|
||||||
const at = headMatch.index + headMatch[0].length;
|
const at = headMatch.index + headMatch[0].length;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
/** Folder of the bundled webview scripts; webviews may load files from it. */
|
||||||
|
export function viewerRoots(extensionUri: vscode.Uri): vscode.Uri[] {
|
||||||
|
return [vscode.Uri.joinPath(extensionUri, 'dist')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page option that adds the 3D viewer to an Archify page. */
|
||||||
|
export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri): { scriptUri: string; defaultView: '2d' | '3d' } {
|
||||||
|
const defaultView = vscode.workspace.getConfiguration('archify').get<string>('defaultView', '2d') === '3d' ? '3d' : '2d';
|
||||||
|
return {
|
||||||
|
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'viewer3d.js')).toString(),
|
||||||
|
defaultView,
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { brandValueAt, loadBrandCatalog } from '../src/brands';
|
import { brandValueAt, loadBrandCatalog } from '../src/language/brands';
|
||||||
import { diagnosticPointer } from '../src/jsonPointer';
|
import { diagnosticPointer } from '../src/language/jsonPointer';
|
||||||
|
|
||||||
const root = path.resolve(__dirname, '..', '..');
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
const catalogFile = path.join(root, 'vendor', 'archify', 'brand-marks', 'catalog.json');
|
const catalogFile = path.join(root, 'vendor', 'archify', 'brand-marks', 'catalog.json');
|
||||||
|
|||||||
+4
-3
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
|
import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/language/jsonPointer';
|
||||||
import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/jsonPointer';
|
import { isArchifyHtml, readDiagramSource, typeFromFilename } from '../src/sources/detect';
|
||||||
|
import { hasSourceEvidence, stripSourceEvidence } from '../src/sources/evidence';
|
||||||
|
|
||||||
test('reads diagram_type from content', () => {
|
test('reads diagram_type from content', () => {
|
||||||
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
||||||
@@ -49,7 +50,7 @@ test('json pointer ranges point at keys for containers and values for leaves', (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('.archify files are diagram sources typed by their content', async () => {
|
test('.archify files are diagram sources typed by their content', async () => {
|
||||||
const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/detect');
|
const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/sources/detect');
|
||||||
assert.ok(isArchifyFile('/w/My Platform.archify'));
|
assert.ok(isArchifyFile('/w/My Platform.archify'));
|
||||||
assert.ok(isDiagramFileName('/w/x.ARCHIFY'));
|
assert.ok(isDiagramFileName('/w/x.ARCHIFY'));
|
||||||
assert.ok(isDiagramFileName('/w/x.workflow.json'));
|
assert.ok(isDiagramFileName('/w/x.workflow.json'));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { prepareDiagramHtml } from '../src/webviewHtml';
|
import { prepareDiagramHtml } from '../src/webview/page';
|
||||||
|
|
||||||
const page = `<!DOCTYPE html><html><head>
|
const page = `<!DOCTYPE html><html><head>
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src *">
|
<meta http-equiv="Content-Security-Policy" content="default-src *">
|
||||||
@@ -12,7 +12,7 @@ test('adds one CSP and nonces every inline script', () => {
|
|||||||
const html = prepareDiagramHtml(page, { cspSource: 'vscode-resource:', nonce: 'N0NCE', theme: 'light' });
|
const html = prepareDiagramHtml(page, { cspSource: 'vscode-resource:', nonce: 'N0NCE', theme: 'light' });
|
||||||
const csps = html.match(/Content-Security-Policy/g) ?? [];
|
const csps = html.match(/Content-Security-Policy/g) ?? [];
|
||||||
assert.equal(csps.length, 1);
|
assert.equal(csps.length, 1);
|
||||||
assert.match(html, /script-src ('|')nonce-N0NCE/);
|
assert.match(html, /script-src vscode-resource: ('|')nonce-N0NCE/);
|
||||||
const scripts = html.match(/<script\b[^>]*>/g) ?? [];
|
const scripts = html.match(/<script\b[^>]*>/g) ?? [];
|
||||||
assert.equal(scripts.length, 4, 'bridge plus the three page scripts');
|
assert.equal(scripts.length, 4, 'bridge plus the three page scripts');
|
||||||
for (const tag of scripts) assert.match(tag, /nonce="N0NCE"/);
|
for (const tag of scripts) assert.match(tag, /nonce="N0NCE"/);
|
||||||
@@ -35,3 +35,29 @@ test('embedded config cannot close the script element', () => {
|
|||||||
});
|
});
|
||||||
assert.ok(!html.includes('</script><script>alert(1)'));
|
assert.ok(!html.includes('</script><script>alert(1)'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('adds the 3D viewer script after the page and before </head>, under the nonce', () => {
|
||||||
|
const html = prepareDiagramHtml(page, {
|
||||||
|
cspSource: 'x',
|
||||||
|
nonce: 'n',
|
||||||
|
theme: 'dark',
|
||||||
|
viewer3d: { scriptUri: 'https://file+.vscode-resource/ext/dist/viewer3d.js', defaultView: '3d' },
|
||||||
|
});
|
||||||
|
const tag = /<script nonce="n" defer src="([^"]+)"><\/script>/.exec(html);
|
||||||
|
assert.ok(tag, 'deferred script tag with the nonce');
|
||||||
|
assert.equal(tag[1], 'https://file+.vscode-resource/ext/dist/viewer3d.js');
|
||||||
|
assert.ok(tag.index < html.indexOf('</head>'));
|
||||||
|
assert.match(html, /"defaultView":"3d"/);
|
||||||
|
assert.match(html, /window\.__archifyHost = /);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pages without the 3D option get no viewer script', () => {
|
||||||
|
const html = prepareDiagramHtml(page, { cspSource: 'x', nonce: 'n', theme: 'dark' });
|
||||||
|
assert.ok(!html.includes('viewer3d.js'));
|
||||||
|
assert.ok(!html.includes('"defaultView":"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved view state is merged, not replaced, when the hash changes', () => {
|
||||||
|
const html = prepareDiagramHtml(page, { cspSource: 'x', nonce: 'n', theme: 'dark', state: { uri: 'file:///a.archify' } });
|
||||||
|
assert.match(html, /vscode\.setState\(Object\.assign\(\{\}, vscode\.getState\(\) \|\| \{\}, config\.state, \{ hash: location\.hash \}\)\)/);
|
||||||
|
});
|
||||||
@@ -3,8 +3,8 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { readDiagramSource } from '../src/detect';
|
import { renderDiagram, renderToFile, RuntimeOptions, validate } from '../src/archify/cli';
|
||||||
import { renderDiagram, renderToFile, RuntimeOptions, validate } from '../src/renderer';
|
import { readDiagramSource } from '../src/sources/detect';
|
||||||
|
|
||||||
const root = path.resolve(__dirname, '..', '..');
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
const fixtures = path.join(root, 'test', 'fixtures');
|
const fixtures = path.join(root, 'test', 'fixtures');
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { parse } from 'yaml';
|
import { parse } from 'yaml';
|
||||||
import { readDiagramSource } from '../src/detect';
|
import { validate } from '../src/archify/cli';
|
||||||
import { validate } from '../src/renderer';
|
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/language/templates';
|
||||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/templates';
|
import { readDiagramSource } from '../src/sources/detect';
|
||||||
|
|
||||||
const root = path.resolve(__dirname, '..', '..');
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
const templates = path.join(root, 'templates');
|
const templates = path.join(root, 'templates');
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user