From f128568e4e3960e00a1ad8935f4543d50b28ff10 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 12:11:46 +0200 Subject: [PATCH] Release Diagram Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 ++++ CHANGELOG.md | 5 + README.md | 14 +- SOURCE.md | 2 +- docs/ARCHITECTURE.md | 13 +- package-lock.json | 40 ++-- package.json | 13 +- playwright.config.ts | 22 +- public/CHANGELOG.md | 5 + public/LICENSES/npm-runtime-licenses.txt | 6 +- public/README.md | 14 +- public/SOURCE.md | 2 +- public/docs/ARCHITECTURE.md | 13 +- public/sw.js | 2 +- public/toolbox-app.json | 17 +- src/components/HelpDialog.tsx | 4 +- src/components/Workbench.tsx | 80 ++++++- src/core/diagram.ts | 265 ++++++++++++++++++++--- src/styles.css | 17 ++ src/toolbox/manifest.source.json | 38 +++- src/version.ts | 2 +- tests/browser/app.spec.ts | 2 +- tests/browser/responsive.spec.ts | 18 ++ tests/core/diagram.test.ts | 43 +++- 24 files changed, 599 insertions(+), 77 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 tests/browser/responsive.spec.ts diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,39 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Browser tests + run: npm run test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e8c37..9e46d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Add safe deterministic entity-relationship, mind-map and timeline grammars, + richer diagnostics and accessible outline/export coverage. + ## 0.1.0 - 2026-09-01 - Add bounded flowchart, sequence, class and state parser/layout. diff --git a/README.md b/README.md index b51c7f9..68025c9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,19 @@ # Diagram Tools +Diagram Tools is a local, script-free editor for bounded flowchart, sequence, +class, state, entity-relationship, mind-map and timeline sources. It provides +line diagnostics, reusable examples, accessible outlines, pan/zoom, local +source import/export, normalized JSON, inert SVG and local PNG export. Mermaid +directives, styling, click handlers and external resources are never executed. + Diagram Tools is a GPL-3.0-or-later local text/visual editor with an independent safe Mermaid-like subset for flowchart/graph, sequence, class and state diagrams. It produces deterministic SVG, a local 2× PNG, syntax diagnostics, scroll/drag pan, 25–300% zoom and an accessible textual outline. -Supported syntax is deliberately small: node declarations/shapes and four edge styles; participants/messages; class declarations/members/relations; state nodes/transitions. Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges, 2,400 px layout edge and 20 megapixel PNG. +Supported syntax is deliberately small: node declarations/shapes and four edge +styles; participants/messages/notes; class declarations, bounded member blocks +and relations; labelled state nodes, start/end boundaries and transitions; ER +attributes/relations; hierarchical mind maps; and timeline sections/events. +Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps: +50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20 megapixel PNG; +large vector canvases remain scrollable instead of clipping model content. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. diff --git a/SOURCE.md b/SOURCE.md index bbfcd24..02ec320 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,3 +1,3 @@ # Source identity -Diagram Tools 0.1.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256. +Diagram Tools 0.2.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2ec1b96..114ac82 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,3 +1,14 @@ # Architecture -`core/diagram.ts` parses one fixed grammar into nodes, edges and line diagnostics, then lays it out deterministically as escaped SVG. Sequence diagrams use lifelines; other modes use a bounded grid. React shows the last successful SVG after errors and delegates PNG rasterization to a local canvas. +`core/diagram.ts` parses fixed, bounded flowchart, sequence, class, state, ER, +mind-map and timeline grammars into nodes, edges and line diagnostics, then lays +them out deterministically as escaped SVG. ER attribute blocks and sequence +notes, class member blocks and labelled state transitions/boundaries become +inert model data; directives, click handlers, remote resources and styling +commands are rejected rather than interpreted. Layout dimensions are derived +from the bounded model rather than clipping nodes to a fixed viewport. + +React retains the last successful SVG after errors, imports/exports source and +the normalized JSON model, and delegates optional PNG rasterization to a local +canvas. Generated SVG contains only application-owned geometry and escaped +text. diff --git a/package-lock.json b/package-lock.json index 004f702..14b45b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,22 @@ { "name": "diagram-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diagram-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -42,24 +42,24 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", - "integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-helpers": { - "version": "0.1.0", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", - "integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz", + "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==", "license": "GPL-3.0-or-later" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz", - "integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -67,13 +67,13 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", - "integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index e77a02a..d643ec9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "diagram-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Create safe deterministic diagrams locally.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -9,6 +9,9 @@ "url": "git+https://git.add-ideas.de/lotobo/diagram-tools.git" }, "homepage": "https://git.add-ideas.de/lotobo/diagram-tools", + "bugs": { + "url": "https://git.add-ideas.de/lotobo/diagram-tools/issues" + }, "private": true, "type": "module", "engines": { @@ -34,14 +37,14 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 05fc1e4..a1e1af4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,7 +12,25 @@ export default defineConfig({ timeout: 180000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 30e8c37..9e46d45 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Add safe deterministic entity-relationship, mind-map and timeline grammars, + richer diagnostics and accessible outline/export coverage. + ## 0.1.0 - 2026-09-01 - Add bounded flowchart, sequence, class and state parser/layout. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 45a7c95..4f412f1 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -198,7 +198,7 @@ Declared licence: Apache-2.0 ============================================================================== -@add-ideas/toolbox-helpers@0.1.0 +@add-ideas/toolbox-helpers@0.2.0 Declared licence: GPL-3.0-or-later ============================================================================== --- LICENSE --- @@ -879,7 +879,7 @@ Public License instead of this License. But first, please read ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- diff --git a/public/README.md b/public/README.md index b51c7f9..68025c9 100644 --- a/public/README.md +++ b/public/README.md @@ -1,7 +1,19 @@ # Diagram Tools +Diagram Tools is a local, script-free editor for bounded flowchart, sequence, +class, state, entity-relationship, mind-map and timeline sources. It provides +line diagnostics, reusable examples, accessible outlines, pan/zoom, local +source import/export, normalized JSON, inert SVG and local PNG export. Mermaid +directives, styling, click handlers and external resources are never executed. + Diagram Tools is a GPL-3.0-or-later local text/visual editor with an independent safe Mermaid-like subset for flowchart/graph, sequence, class and state diagrams. It produces deterministic SVG, a local 2× PNG, syntax diagnostics, scroll/drag pan, 25–300% zoom and an accessible textual outline. -Supported syntax is deliberately small: node declarations/shapes and four edge styles; participants/messages; class declarations/members/relations; state nodes/transitions. Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges, 2,400 px layout edge and 20 megapixel PNG. +Supported syntax is deliberately small: node declarations/shapes and four edge +styles; participants/messages/notes; class declarations, bounded member blocks +and relations; labelled state nodes, start/end boundaries and transitions; ER +attributes/relations; hierarchical mind maps; and timeline sections/events. +Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps: +50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20 megapixel PNG; +large vector canvases remain scrollable instead of clipping model content. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. diff --git a/public/SOURCE.md b/public/SOURCE.md index bbfcd24..02ec320 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,3 +1,3 @@ # Source identity -Diagram Tools 0.1.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256. +Diagram Tools 0.2.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256. diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 2ec1b96..114ac82 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -1,3 +1,14 @@ # Architecture -`core/diagram.ts` parses one fixed grammar into nodes, edges and line diagnostics, then lays it out deterministically as escaped SVG. Sequence diagrams use lifelines; other modes use a bounded grid. React shows the last successful SVG after errors and delegates PNG rasterization to a local canvas. +`core/diagram.ts` parses fixed, bounded flowchart, sequence, class, state, ER, +mind-map and timeline grammars into nodes, edges and line diagnostics, then lays +them out deterministically as escaped SVG. ER attribute blocks and sequence +notes, class member blocks and labelled state transitions/boundaries become +inert model data; directives, click handlers, remote resources and styling +commands are rejected rather than interpreted. Layout dimensions are derived +from the bounded model rather than clipping nodes to a fixed viewport. + +React retains the last successful SVG after errors, imports/exports source and +the normalized JSON model, and delegates optional PNG rasterization to a local +canvas. Generated SVG contains only application-owned geometry and escaped +text. diff --git a/public/sw.js b/public/sw.js index 1c25ead..f5d3216 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = "diagram-tools-v0.1.0", +const CACHE = "diagram-tools-v0.2.0", APP = [ "./", "./index.html", diff --git a/public/toolbox-app.json b/public/toolbox-app.json index b60d590..9265620 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.diagram-tools", "name": "Diagram Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Create safe diagrams locally.", "entry": "./", "icon": "./favicon.svg", @@ -21,9 +21,22 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "text/plain", "extensions": [".txt", ".mmd", ".mermaid"] }, + { "mediaType": "application/json", "extensions": [".json"] } + ], + "produces": [ + { "mediaType": "image/svg+xml", "extensions": [".svg"] }, + { "mediaType": "image/png", "extensions": [".png"] }, + { "mediaType": "application/json", "extensions": [".json"] }, + { "mediaType": "text/plain", "extensions": [".txt", ".mmd"] } + ] + }, + "capabilities": { "required": [], "optional": [] }, "privacy": { "processing": "local", - "fileUploads": false, + "fileUploads": true, "telemetry": false, "label": "Diagram source and exports remain local." }, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 0a2ee0c..b496f05 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -18,7 +18,9 @@ export function HelpDialog({ onCancel={onClose} aria-label="About Diagram Tools" > - +

Diagram Tools

The syntax resembles a small familiar Mermaid subset but uses an diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 84c17dd..7ba2e6c 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -26,6 +26,31 @@ Idle --> Reading : open Reading --> Ready : parsed Reading -.-> Error : invalid Error --> Idle : reset`, + er: `erDiagram +CUSTOMER { + string id PK + string name +} +ORDER { + string id PK + number total +} +CUSTOMER ||--o{ ORDER : places`, + mindmap: `mindmap + ((Local tools)) + Author + Diagrams + Documents + Export + SVG + PNG`, + timeline: `timeline +title Local release +section Author +2026-08 : Draft +2026-09 : Verify +section Publish +2026-10 : Release`, } as const; export function Workbench() { const [source, setSource] = useState(EXAMPLES.flowchart), @@ -60,6 +85,23 @@ export function Workbench() { setStatus(e instanceof Error ? e.message : String(e)); } } + async function openSource(file: File | undefined) { + if (!file) return; + try { + if (file.size > 50_000) + throw new Error("Diagram source exceeds 50,000 characters."); + const text = await file.text(), + next = parseDiagram(text); + setSource(text); + setDiagram(next); + setSvg(renderSvg(next)); + setStatus(`Opened and rendered ${file.name} locally.`); + } catch (error) { + setStatus( + `${error instanceof Error ? error.message : String(error)} Last valid diagram remains visible.`, + ); + } + } return (

@@ -111,6 +153,36 @@ export function Workbench() { SVG + + +

Syntax diagnostics

{diagram.diagnostics.length ? ( @@ -126,9 +198,11 @@ export function Workbench() {

No syntax diagnostics.

)}

- Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges and a - 2,400 px layout edge. Unsupported lines produce diagnostics; - directives never execute. + Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20 + megapixel PNG export. Large vector canvases remain scrollable. + Flowchart, sequence, class, state, ER, mind-map and timeline + families share the inert model. Unsupported lines produce + diagnostics; directives never execute.

diff --git a/src/core/diagram.ts b/src/core/diagram.ts index 2c1f7fc..3d7aa51 100644 --- a/src/core/diagram.ts +++ b/src/core/diagram.ts @@ -1,8 +1,17 @@ -export type DiagramMode = "flowchart" | "sequence" | "class" | "state"; +export type DiagramMode = + "flowchart" | "sequence" | "class" | "state" | "er" | "mindmap" | "timeline"; export interface Node { id: string; label: string; - shape: "rect" | "round" | "diamond" | "actor" | "class" | "state"; + shape: + | "rect" + | "round" + | "diamond" + | "actor" + | "class" + | "state" + | "circle" + | "note"; members: string[]; } export interface Edge { @@ -47,9 +56,12 @@ export function parseDiagram(source: string): Diagram { } else if (/^sequenceDiagram$/iu.test(header)) mode = "sequence"; else if (/^classDiagram$/iu.test(header)) mode = "class"; else if (/^stateDiagram(?:-v2)?$/iu.test(header)) mode = "state"; + else if (/^erDiagram$/iu.test(header)) mode = "er"; + else if (/^mindmap$/iu.test(header)) mode = "mindmap"; + else if (/^timeline$/iu.test(header)) mode = "timeline"; else throw new Error( - "First statement must be flowchart/graph, sequenceDiagram, classDiagram or stateDiagram-v2.", + "First statement must be flowchart/graph, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, mindmap or timeline.", ); const nodes = new Map(), edges: Edge[] = [], @@ -65,6 +77,11 @@ export function parseDiagram(source: string): Diagram { } return node; }; + let erEntity: Node | undefined, + classEntity: Node | undefined, + generated = 0, + mindStack: { indent: number; id: string }[] = [], + timelinePrevious: string | undefined; for (let index = first + 1; index < lines.length; index++) { const raw = lines[index]!, line = raw.trim(); @@ -81,8 +98,85 @@ export function parseDiagram(source: string): Diagram { continue; } try { - if (mode === "sequence") parseSequence(line, ensure, edges); - else if (mode === "class") parseClass(line, ensure, edges); + if (mode === "er") { + if (line === "}") { + if (!erEntity) throw new Error("Unexpected ER attribute block end."); + erEntity = undefined; + } else if (erEntity) { + if (line.includes("{") || line.includes("}")) + throw new Error("Nested ER attribute blocks are not supported."); + if (erEntity.members.length >= 100) + throw new Error("ER entity exceeds 100 attributes."); + erEntity.members.push(line); + } else { + const opening = /^([A-Za-z_][\w-]*)\s*\{$/u.exec(line); + if (opening) erEntity = ensure(opening[1]!, opening[1], "class"); + else parseEr(line, ensure, edges); + } + } else if (mode === "class") { + if (line === "}") { + if (!classEntity) + throw new Error("Unexpected class member block end."); + classEntity = undefined; + } else if (classEntity) { + if (line.includes("{") || line.includes("}")) + throw new Error("Nested class member blocks are not supported."); + if (classEntity.members.length >= 100) + throw new Error("Class exceeds 100 members."); + classEntity.members.push(line); + } else { + const opening = + /^class\s+([A-Za-z_][\w-]*)(?:\s*\[([^\]]{1,200})\])?\s*\{$/iu.exec( + line, + ); + if (opening) + classEntity = ensure( + opening[1]!, + opening[2]?.trim() ?? opening[1], + "class", + ); + else parseClass(line, ensure, edges); + } + } else if (mode === "mindmap") { + const indent = raw.length - raw.trimStart().length, + label = line.replace(/^[-*+]\s*/u, "").trim(); + if (!label) continue; + const id = `mind-${++generated}`, + parent = [...mindStack] + .reverse() + .find((item) => item.indent < indent); + ensure(id, unwrapMindLabel(label), parent ? "round" : "circle"); + if (parent) + edges.push({ + from: parent.id, + to: id, + label: "", + dashed: false, + arrow: false, + }); + mindStack = mindStack.filter((item) => item.indent < indent); + mindStack.push({ indent, id }); + } else if (mode === "timeline") { + if (/^title\s+/iu.test(line)) continue; + const section = /^section\s+(.{1,200})$/iu.exec(line); + const id = `time-${++generated}`; + if (section) ensure(id, section[1]!.trim(), "round"); + else { + const event = /^(.{1,120}?)\s*:\s*(.{1,300})$/u.exec(line); + if (!event) throw new Error("Expected section Name or Time : Event."); + ensure(id, `${event[1]!.trim()} — ${event[2]!.trim()}`, "rect"); + } + if (timelinePrevious) + edges.push({ + from: timelinePrevious, + to: id, + label: "", + dashed: false, + arrow: true, + }); + timelinePrevious = id; + } else if (mode === "sequence") parseSequence(line, ensure, edges); + else if (mode === "state") parseState(line, ensure, edges); else parseGraph(line, ensure, edges, mode); } catch (error) { diagnostics.push( @@ -97,6 +191,19 @@ export function parseDiagram(source: string): Diagram { if (nodes.size > MAX_NODES) throw new Error("Diagram exceeds 200 nodes."); if (edges.length > MAX_EDGES) throw new Error("Diagram exceeds 500 edges."); } + if (erEntity) + diagnostics.push( + diag(lines.length, 1, "error", `Entity ${erEntity.id} has no closing }.`), + ); + if (classEntity) + diagnostics.push( + diag( + lines.length, + 1, + "error", + `Class ${classEntity.id} has no closing }.`, + ), + ); for (const edge of edges) { ensure( edge.from, @@ -113,6 +220,31 @@ export function parseDiagram(source: string): Diagram { diagnostics.push(diag(first + 1, 1, "warning", "Diagram has no nodes.")); return { mode, direction, nodes: [...nodes.values()], edges, diagnostics }; } +function parseEr( + line: string, + ensure: (id: string, label?: string, shape?: Node["shape"]) => Node, + edges: Edge[], +) { + const relation = + /^([A-Za-z_][\w-]*)\s+([|o}{.]+)--([|o}{.]+)\s+([A-Za-z_][\w-]*)(?:\s*:\s*(.{0,200}))?$/u.exec( + line, + ); + if (!relation) + throw new Error("Expected ENTITY { or ENTITY ||--o{ ENTITY : relation."); + ensure(relation[1]!, relation[1], "class"); + ensure(relation[4]!, relation[4], "class"); + edges.push({ + from: relation[1]!, + to: relation[4]!, + label: `${relation[2]}–${relation[3]}${relation[5] ? ` ${relation[5]!.trim()}` : ""}`, + dashed: relation[2]!.includes(".") || relation[3]!.includes("."), + arrow: false, + }); +} +function unwrapMindLabel(value: string) { + const match = /^(?:\(\((.*)\)\)|\((.*)\)|\[(.*)\)|\{(.*)\})$/u.exec(value); + return (match?.slice(1).find((item) => item !== undefined) ?? value).trim(); +} function parseGraph( line: string, ensure: (id: string, label?: string, shape?: Node["shape"]) => Node, @@ -140,20 +272,29 @@ function parseGraph( } function parseNode(source: string, mode: DiagramMode) { const match = - /^([A-Za-z_][\w-]*)(?:\[([^\]]{0,500})\]|\(([^)]{0,500})\)|\{([^}]{0,500})\})?$/u.exec( + /^(\[\s*\*\s*\]|[A-Za-z_][\w-]*)(?:\[([^\]]{0,500})\]|\(\(([^)]{0,500})\)\)|\(([^)]{0,500})\)|\{([^}]{0,500})\})?$/u.exec( source, ); if (!match) throw new Error(`Unsupported node/edge syntax: ${source}`); + const rawId = match[1]!; return { - id: match[1]!, - label: match[2] ?? match[3] ?? match[4] ?? match[1]!, - shape: match[4] - ? ("diamond" as const) - : match[3] - ? ("round" as const) - : mode === "state" - ? ("state" as const) - : ("rect" as const), + id: rawId === "[*]" ? "__boundary" : rawId, + label: + rawId === "[*]" + ? "●" + : (match[2] ?? match[3] ?? match[4] ?? match[5] ?? rawId), + shape: + rawId === "[*]" + ? ("circle" as const) + : match[5] + ? ("diamond" as const) + : match[3] + ? ("circle" as const) + : match[4] + ? ("round" as const) + : mode === "state" + ? ("state" as const) + : ("rect" as const), }; } function parseSequence( @@ -161,6 +302,24 @@ function parseSequence( ensure: (id: string, label?: string, shape?: Node["shape"]) => Node, edges: Edge[], ) { + const note = + /^note\s+(?:over|right of|left of)\s+([A-Za-z_][\w-]*)(?:\s*,\s*([A-Za-z_][\w-]*))?\s*:\s*(.{1,500})$/iu.exec( + line, + ); + if (note) { + const from = note[1]!, + to = note[2] ?? from; + ensure(from, from, "actor"); + ensure(to, to, "actor"); + edges.push({ + from, + to, + label: `Note: ${note[3]!.trim()}`, + dashed: true, + arrow: false, + }); + return; + } const participant = /^(?:participant|actor)\s+([A-Za-z_][\w-]*)(?:\s+as\s+(.{1,200}))?$/iu.exec( line, @@ -218,6 +377,47 @@ function parseClass( arrow: relation[2] !== "--", }); } + +function parseState( + line: string, + ensure: (id: string, label?: string, shape?: Node["shape"]) => Node, + edges: Edge[], +) { + const declaration = + /^state\s+(?:"([^"]{1,500})"\s+as\s+)?([A-Za-z_][\w-]*)$/iu.exec(line); + if (declaration) { + ensure(declaration[2]!, declaration[1]?.trim() ?? declaration[2], "state"); + return; + } + const labelled = /^([A-Za-z_][\w-]*)\s*:\s*(.{1,500})$/u.exec(line); + if (labelled) { + ensure(labelled[1]!, labelled[2]!.trim(), "state"); + return; + } + if (/^state\s+.+\{$/iu.test(line) || line === "}") + throw new Error("Nested composite states are not supported."); + const transition = + /^(\[\s*\*\s*\]|[A-Za-z_][\w-]*)\s*(-->|-.->)\s*(\[\s*\*\s*\]|[A-Za-z_][\w-]*)(?:\s*:\s*(.{1,500}))?$/u.exec( + line, + ); + if (!transition) + throw new Error( + 'Expected state "Label" as id, id : Label, or A --> B : event.', + ); + const fromBoundary = /^\[\s*\*\s*\]$/u.test(transition[1]!), + toBoundary = /^\[\s*\*\s*\]$/u.test(transition[3]!), + from = fromBoundary ? "__start" : transition[1]!, + to = toBoundary ? "__end" : transition[3]!; + ensure(from, fromBoundary ? "●" : from, fromBoundary ? "circle" : "state"); + ensure(to, toBoundary ? "●" : to, toBoundary ? "circle" : "state"); + edges.push({ + from, + to, + label: transition[4]?.trim() ?? "", + dashed: transition[2] === "-.->", + arrow: true, + }); +} function diag( line: number, column: number, @@ -229,14 +429,21 @@ function diag( export function renderSvg(diagram: Diagram): string { if (diagram.mode === "sequence") return renderSequence(diagram); - const nodeWidth = 180, - nodeHeight = diagram.mode === "class" ? 110 : 72, - gapX = 100, - gapY = 75, + const nodeWidth = 160, + memberRows = Math.min( + 8, + Math.max(0, ...diagram.nodes.map((node) => node.members.length)), + ), + nodeHeight = + diagram.mode === "class" || diagram.mode === "er" + ? Math.max(100, 58 + memberRows * 13) + : 68, + gapX = 50, + gapY = 45, columns = - diagram.direction === "LR" - ? Math.min(5, Math.max(1, diagram.nodes.length)) - : Math.min(4, Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length)))), + diagram.direction === "LR" || diagram.mode === "timeline" + ? Math.min(14, Math.max(1, diagram.nodes.length)) + : Math.min(14, Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length)))), positions = new Map(); diagram.nodes.forEach((node, index) => { const column = @@ -248,8 +455,8 @@ export function renderSvg(diagram: Diagram): string { }); }); const rows = Math.max(1, Math.ceil(diagram.nodes.length / columns)), - width = Math.min(2400, 100 + columns * (nodeWidth + gapX) - gapX), - height = Math.min(2400, 110 + rows * (nodeHeight + gapY) - gapY); + width = 100 + columns * (nodeWidth + gapX) - gapX, + height = 110 + rows * (nodeHeight + gapY) - gapY; const edges = diagram.edges .map((edge) => { const a = positions.get(edge.from)!, @@ -280,7 +487,9 @@ function renderNode( shape = node.shape === "diamond" ? `` - : ``; + : node.shape === "circle" + ? `` + : ``; const members = node.members .slice(0, 8) .map( @@ -292,8 +501,8 @@ function renderNode( } function renderSequence(d: Diagram) { const gap = 190, - width = Math.min(2400, 100 + Math.max(1, d.nodes.length) * gap), - height = Math.min(2400, 140 + Math.max(1, d.edges.length) * 65), + width = 100 + Math.max(1, d.nodes.length) * gap, + height = 140 + Math.max(1, d.edges.length) * 65, positions = new Map( d.nodes.map((node, index) => [node.id, 70 + index * gap]), ); @@ -308,7 +517,7 @@ function renderSequence(d: Diagram) { const x1 = positions.get(edge.from)!, x2 = positions.get(edge.to)!, y = 105 + index * 65; - return `${xml(edge.label)}`; + return `${xml(edge.label)}`; }) .join(""); return svgRoot(width, height, actors + messages); diff --git a/src/styles.css b/src/styles.css index f915475..c6c7b96 100644 --- a/src/styles.css +++ b/src/styles.css @@ -71,6 +71,23 @@ input { color: CanvasText; padding: 0.55rem 0.7rem; } +.file-button { + display: inline-flex; + align-items: center; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: Canvas; + color: CanvasText; + padding: 0.55rem 0.7rem; + cursor: pointer; +} +.file-button input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} button { cursor: pointer; } diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index b60d590..6c456f8 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.diagram-tools", "name": "Diagram Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Create safe diagrams locally.", "entry": "./", "icon": "./favicon.svg", @@ -21,9 +21,43 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { + "mediaType": "text/plain", + "extensions": [".txt", ".mmd", ".mermaid"] + }, + { + "mediaType": "application/json", + "extensions": [".json"] + } + ], + "produces": [ + { + "mediaType": "image/svg+xml", + "extensions": [".svg"] + }, + { + "mediaType": "image/png", + "extensions": [".png"] + }, + { + "mediaType": "application/json", + "extensions": [".json"] + }, + { + "mediaType": "text/plain", + "extensions": [".txt", ".mmd"] + } + ] + }, + "capabilities": { + "required": [], + "optional": [] + }, "privacy": { "processing": "local", - "fileUploads": false, + "fileUploads": true, "telemetry": false, "label": "Diagram source and exports remain local." }, diff --git a/src/version.ts b/src/version.ts index 76162f8..0ba6e37 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.2.0"; diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts index 0cb54f8..bae62e6 100644 --- a/tests/browser/app.spec.ts +++ b/tests/browser/app.spec.ts @@ -74,6 +74,6 @@ test("PWA theme, offline reload, and headers", async ({ const manifest = await request.get("/deep/nested/diagram/toolbox-app.json"); await expect(manifest.json()).resolves.toMatchObject({ id: "de.add-ideas.diagram-tools", - version: "0.1.0", + version: "0.2.0", }); }); diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..1ef2c7e --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("keeps the primary workspace inside a narrow viewport", async ({ + page, +}) => { + await page.goto("/deep/nested/diagram/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect( + page.locator("main .loading, main .workbench-loading"), + ).toHaveCount(0); + + const widths = await page.evaluate(() => ({ + content: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.viewport).toBeLessThanOrEqual(430); + expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1); +}); diff --git a/tests/core/diagram.test.ts b/tests/core/diagram.test.ts index ab7c5b3..5f0b57c 100644 --- a/tests/core/diagram.test.ts +++ b/tests/core/diagram.test.ts @@ -23,9 +23,29 @@ describe("diagram grammar", () => { parseDiagram("classDiagram\nclass A\nA : +field\nA <|-- B").nodes[0] ?.members, ).toEqual(["+field"]); - expect(parseDiagram("stateDiagram-v2\nIdle --> Ready : go").mode).toBe( - "state", + const state = parseDiagram( + 'stateDiagram-v2\n[*] --> Idle\nstate "Working" as Ready\nIdle --> Ready : go\nReady --> [*]', ); + expect(state.mode).toBe("state"); + expect(state.diagnostics).toEqual([]); + expect(state.edges.map((edge) => edge.label)).toContain("go"); + expect(state.nodes.map((node) => node.id)).toEqual([ + "__start", + "Idle", + "Ready", + "__end", + ]); + }); + it("supports bounded class member blocks and reports unclosed blocks", () => { + const diagram = parseDiagram( + "classDiagram\nclass Account {\n +String id\n +close()\n}\nAccount --> Ledger", + ); + expect(diagram.diagnostics).toEqual([]); + expect(diagram.nodes[0]?.members).toEqual(["+String id", "+close()"]); + expect( + parseDiagram("classDiagram\nclass Account {\n +String id").diagnostics[0] + ?.message, + ).toMatch(/no closing/iu); }); it("diagnoses directives without executing", () => { const d = parseDiagram( @@ -34,4 +54,23 @@ describe("diagram grammar", () => { expect(d.diagnostics).toHaveLength(2); expect(renderSvg(d)).not.toContain("bad.test"); }); + it("supports ER attributes, mind maps, timelines and sequence notes", () => { + const er = parseDiagram( + "erDiagram\nUSER {\n string id PK\n}\nUSER ||--o{ ORDER : places", + ); + expect(er.mode).toBe("er"); + expect(er.nodes.find((node) => node.id === "USER")?.members).toEqual([ + "string id PK", + ]); + expect(er.edges[0]?.label).toContain("places"); + expect(parseDiagram("mindmap\n Root\n Child").edges).toHaveLength(1); + expect( + parseDiagram("timeline\nsection Build\n2026 : Release").nodes, + ).toHaveLength(2); + expect( + parseDiagram( + "sequenceDiagram\nparticipant A\nparticipant B\nNote over A,B: inert", + ).edges[0], + ).toMatchObject({ arrow: false, dashed: true }); + }); });