diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1014ba7
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..fa33e72
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @devkyato
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..6182274
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,35 @@
+name: Bug report
+description: Report a reproducible problem in Flowchart Creator.
+title: "bug: "
+labels: [bug]
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: What happened?
+ description: Describe the problem and what you expected instead.
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ placeholder: |
+ 1. Add a Process shape
+ 2. Connect it to a Decision
+ 3. ...
+ validations:
+ required: true
+ - type: input
+ id: browser
+ attributes:
+ label: Browser and version
+ placeholder: Chrome 130, Firefox 132, Safari 18
+ validations:
+ required: true
+ - type: textarea
+ id: project
+ attributes:
+ label: Project JSON or screenshot
+ description: Attach a minimal exported project or screenshot when useful. Remove sensitive diagram content first.
+
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..70b4946
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,6 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security issue
+ url: https://github.com/devkyato/Nodes/security/advisories/new
+ about: Report vulnerabilities privately.
+
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..03ff568
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,24 @@
+name: Feature request
+description: Suggest a focused improvement to the editor.
+title: "feat: "
+labels: [enhancement]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What flowchart task is difficult today?
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed improvement
+ description: Describe the smallest useful behavior or interaction.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..8a71ee0
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,15 @@
+## Summary
+
+Describe the change and the user-facing reason for it.
+
+## Validation
+
+- [ ] `npm test`
+- [ ] `npm run build`
+- [ ] Checked keyboard and pointer interaction when relevant
+- [ ] Updated documentation or changelog when relevant
+
+## Screenshots
+
+Add before/after images for visible interface changes.
+
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..ef063c8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,9 @@
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
+ labels:
+ - maintenance
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..02a80c2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI
+
+on:
+ push:
+ branches: [main, "agent/**"]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm test
+ - run: npm run build
+
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..8d4a81b
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,40 @@
+name: Deploy Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm test
+ - run: npm run build
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v4
+ with:
+ path: dist
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..d4cfa51
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,29 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm test
+ - run: npm run build
+ - name: Create release archive
+ run: tar -czf "flowchart-creator-${GITHUB_REF_NAME}.tar.gz" -C dist .
+ - name: Publish GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release create "$GITHUB_REF_NAME" "flowchart-creator-${GITHUB_REF_NAME}.tar.gz" --verify-tag --generate-notes --title "Flowchart Creator ${GITHUB_REF_NAME}"
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d7d2003
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.tmp/
+dist/
+node_modules/
+*.log
+.DS_Store
+Thumbs.db
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1925971
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,22 @@
+# Changelog
+
+All notable changes are documented here. Releases follow [Semantic Versioning](https://semver.org/).
+
+## [Unreleased]
+
+## [1.0.0] - 2026-07-20
+
+### Added
+
+- Compact single-page SVG flowchart editor.
+- Twelve standard flowchart symbols and standalone text.
+- Shape resizing, styling, grouping, alignment, distribution, and layer ordering.
+- Straight, orthogonal, and curved attached connectors with labels and endpoint reconnection.
+- Multiline inline editing, keyboard shortcuts, pan, zoom, snap, and fit-to-screen.
+- Snapshot-based undo and redo for diagram-changing actions.
+- Local storage, JSON project download/import, and SVG/PNG export.
+- Dependency-free packaging and browser workflow tests.
+- Automated CI, GitHub Pages deployment, and tag-based GitHub releases.
+
+[Unreleased]: https://github.com/devkyato/Nodes/compare/v1.0.0...HEAD
+[1.0.0]: https://github.com/devkyato/Nodes/releases/tag/v1.0.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..547e484
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,30 @@
+# Contributing
+
+Thanks for improving Flowchart Creator. Keep changes focused, accessible, dependency-free at runtime, and compatible with opening `index.html` directly.
+
+## Development
+
+1. Create a topic branch from `main`.
+2. Run `npm ci`.
+3. Start the local server with `npm start`.
+4. Make the smallest complete change.
+5. Run `npm test` and `npm run build`.
+6. Update `CHANGELOG.md` for user-visible changes.
+
+## Pull requests
+
+- Explain the user-facing problem and the chosen behavior.
+- Include screenshots for visible interface changes.
+- Add browser coverage when changing core interaction behavior.
+- Keep generated output, browser profiles, and editor settings out of commits.
+- Use clear commit messages such as `feat:`, `fix:`, `test:`, `docs:`, `build:`, or `ci:`.
+
+## Design principles
+
+- Prefer direct manipulation and compact controls.
+- Preserve correct flowchart geometry.
+- Keep one coordinate system for nodes, connectors, selection, and export.
+- Make pointer behavior, keyboard behavior, and focus states agree.
+- Avoid network calls, telemetry, and new runtime dependencies.
+
+By contributing, you agree that your work may be distributed under the MIT License.
diff --git a/README.md b/README.md
index c4ee2e9..6b669a5 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,79 @@
-# Nodes
-A minimal, browser-based node and flowchart editor with editable shapes, connectors, local saving, and SVG/PNG export.
+# Flowchart Creator
+
+[](https://github.com/devkyato/Nodes/actions/workflows/ci.yml)
+[](https://github.com/devkyato/Nodes/actions/workflows/pages.yml)
+[](LICENSE)
+
+A compact, browser-based flowchart editor built with HTML, CSS, vanilla JavaScript, and SVG. It has no runtime dependencies, sends no diagram data to a server, and works by opening `index.html` directly.
+
+[Open Flowchart Creator](https://devkyato.github.io/Nodes/)
+
+## Highlights
+
+- Twelve standard flowchart symbols with accurate SVG geometry
+- Direct multiline text editing and standalone text
+- Straight, orthogonal, and curved connectors that stay attached
+- Multi-selection, alignment, distribution, grouping, and layer controls
+- Pointer, keyboard, zoom, pan, resize, copy, paste, undo, and redo support
+- Local browser saving plus portable JSON project files
+- Clean SVG and PNG export cropped to the diagram bounds
+- Responsive compact interface with no framework or external library
+
+## Use locally
+
+Open `index.html` in a modern browser. For a local HTTP server:
+
+```bash
+npm start
+```
+
+Then open `http://127.0.0.1:4173`.
+
+## Essential controls
+
+| Action | Control |
+| --- | --- |
+| Select multiple | Shift + click or drag a selection rectangle |
+| Pan | Hold Space and drag |
+| Zoom | Ctrl/Cmd + mouse wheel |
+| Edit text | Double-click a shape, label, or standalone text |
+| Add standalone text | Double-click empty canvas or use Text |
+| Move precisely | Arrow keys; hold Shift for 10 px |
+| Duplicate | Ctrl/Cmd + D |
+| Copy / paste | Ctrl/Cmd + C / Ctrl/Cmd + V |
+| Undo / redo | Ctrl/Cmd + Z / Ctrl/Cmd + Shift + Z |
+| Finish text editing | Click outside or Ctrl/Cmd + Enter |
+| Cancel text editing | Escape |
+
+## Project files
+
+```text
+index.html Application structure
+style.css Interface and responsive styling
+script.js State, rendering, interaction, persistence, export
+scripts/ Dependency-free local server and build packaging
+tests/ Headless-browser workflow verification
+.github/ CI, Pages, releases, and contribution templates
+docs/ Architecture and release notes for maintainers
+```
+
+## Quality checks
+
+```bash
+npm test
+npm run build
+```
+
+The browser smoke suite exercises rendering, connectors, zoomed dragging, resizing, text editing, history, persistence, and SVG/PNG export.
+
+## Privacy
+
+Projects remain in the browser unless the user downloads or imports a file. The application has no analytics, accounts, API calls, or remote storage.
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports should follow [SECURITY.md](SECURITY.md).
+
+## License
+
+[MIT](LICENSE)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..a5522cf
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,15 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are applied to the latest release.
+
+## Reporting a vulnerability
+
+Please use [GitHub private vulnerability reporting](https://github.com/devkyato/Nodes/security/advisories/new). Do not open a public issue containing an undisclosed vulnerability or sensitive diagram data.
+
+Include the affected browser, a minimal reproduction, and the expected impact. You should receive an acknowledgement within seven days.
+
+## Data handling
+
+Flowchart Creator runs entirely in the browser. It does not send diagram content to a remote service. Saved projects use the browser's local storage; downloaded project and image files are handled by the browser.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..f844976
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,29 @@
+# Architecture
+
+Flowchart Creator intentionally keeps the runtime surface small: one HTML document, one stylesheet, and one JavaScript module loaded as a classic script.
+
+## State and history
+
+The application state owns the document title, nodes, edges, selection, viewport, mode, snap preference, and snapshot history. Diagram-changing actions commit once at the end of an interaction, so pointer movement does not flood undo history.
+
+## Coordinate system
+
+Nodes and connectors use a fixed 2400 × 1600 logical workspace. The SVG viewport applies one pan-and-zoom transform. Pointer coordinates are converted back into logical coordinates before movement, resize, selection, or connector calculations. This keeps interaction correct at every zoom level.
+
+## Rendering
+
+Rendering is deterministic from state:
+
+1. Connectors render beneath nodes.
+2. Nodes render in `zIndex` order.
+3. Selection outlines, handles, ports, and temporary gestures render in a separate overlay.
+
+Each flowchart symbol is generated by the shared geometry renderer. Export reuses the same node and connector renderers, then omits the editing overlay and grid.
+
+## Persistence
+
+Projects serialize into a versioned JSON object. Import normalizes missing style fields against current defaults, allowing compatible older files to load. Browser saving uses one namespaced local-storage key.
+
+## Testing
+
+The smoke suite starts the dependency-free server, opens an installed Chromium-family browser through the DevTools protocol, and verifies core workflows without a test framework. CI runs the same suite before packaging and deployment.
diff --git a/docs/release-process.md b/docs/release-process.md
new file mode 100644
index 0000000..6f975a6
--- /dev/null
+++ b/docs/release-process.md
@@ -0,0 +1,9 @@
+# Release process
+
+1. Confirm `CHANGELOG.md` contains the release date and version.
+2. Update the version in `package.json` and refresh `package-lock.json`.
+3. Run `npm test` and `npm run build`.
+4. Merge the release changes into `main`.
+5. Create and push an annotated tag such as `v1.0.0`.
+
+The release workflow verifies the tag, packages the three-file application, and creates a GitHub release with generated notes. The Pages workflow deploys every successful change to `main` independently.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..d334a45
--- /dev/null
+++ b/index.html
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+ Flowchart Creator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6fd3207
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,16 @@
+{
+ "name": "flowchart-creator",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "flowchart-creator",
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..333a5ef
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "flowchart-creator",
+ "version": "1.0.0",
+ "private": true,
+ "description": "A compact, dependency-free SVG flowchart editor.",
+ "type": "module",
+ "scripts": {
+ "start": "node scripts/serve.mjs",
+ "build": "node scripts/build.mjs",
+ "check": "node --check script.js",
+ "test": "node --check script.js && node tests/browser-smoke.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "keywords": [
+ "flowchart",
+ "diagram",
+ "svg",
+ "vanilla-javascript"
+ ],
+ "license": "MIT"
+}
diff --git a/script.js b/script.js
new file mode 100644
index 0000000..8445644
--- /dev/null
+++ b/script.js
@@ -0,0 +1,1159 @@
+(() => {
+ "use strict";
+
+ const SVG_NS = "http://www.w3.org/2000/svg";
+ const CANVAS = { width: 2400, height: 1600, padding: 16 };
+ const GRID_SIZE = 20;
+ const STORAGE_KEY = "flowchart-creator-project-v1";
+ const DEFAULT_NODE_STYLE = Object.freeze({
+ fill: "#ffffff",
+ stroke: "#475569",
+ strokeWidth: 1.5,
+ strokeStyle: "solid",
+ opacity: 1,
+ fontSize: 14,
+ fontWeight: "400",
+ italic: false,
+ textAlign: "center",
+ textColor: "#1f2937",
+ cornerRadius: 6
+ });
+ const DEFAULT_EDGE_STYLE = Object.freeze({
+ stroke: "#475569",
+ strokeWidth: 1.5,
+ strokeStyle: "solid",
+ startArrow: "none",
+ endArrow: "arrow"
+ });
+ const SHAPES = [
+ ["terminator", "Start / End"],
+ ["process", "Process"],
+ ["input", "Input / Output"],
+ ["decision", "Decision"],
+ ["document", "Document"],
+ ["predefined", "Predefined"],
+ ["database", "Database"],
+ ["manual", "Manual Input"],
+ ["preparation", "Preparation"],
+ ["onpage", "On-page"],
+ ["offpage", "Off-page"],
+ ["delay", "Delay"]
+ ];
+ const DEFAULT_SIZES = {
+ terminator: [150, 62], process: [160, 76], input: [170, 76], decision: [145, 100],
+ document: [170, 88], predefined: [170, 76], database: [160, 92], manual: [170, 76],
+ preparation: [170, 76], onpage: [72, 72], offpage: [110, 92], delay: [150, 76], text: [180, 48]
+ };
+
+ const refs = {
+ shell: document.querySelector("#canvas-shell"),
+ svg: document.querySelector("#canvas"),
+ viewport: document.querySelector("#viewport"),
+ edgeLayer: document.querySelector("#edge-layer"),
+ nodeLayer: document.querySelector("#node-layer"),
+ overlayLayer: document.querySelector("#overlay-layer"),
+ shapeList: document.querySelector("#shape-list"),
+ properties: document.querySelector("#properties-panel"),
+ propertiesTitle: document.querySelector("#properties-title"),
+ propertiesContent: document.querySelector("#properties-content"),
+ title: document.querySelector("#document-title"),
+ zoom: document.querySelector("#zoom-display"),
+ status: document.querySelector("#status-text"),
+ selectionStatus: document.querySelector("#selection-status"),
+ snap: document.querySelector("#snap-toggle"),
+ editor: document.querySelector("#inline-editor"),
+ fileInput: document.querySelector("#file-input")
+ };
+
+ let state = createInitialState();
+ let interaction = null;
+ let spacePressed = false;
+ let clipboard = null;
+ let editing = null;
+ let nudgePending = false;
+
+ function uid(prefix = "item") {
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
+ }
+
+ function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function createNode(type, x, y, text = SHAPES.find(([key]) => key === type)?.[1] || "Text") {
+ const [width, height] = DEFAULT_SIZES[type] || DEFAULT_SIZES.process;
+ return {
+ id: uid("node"), type, x, y, width, height, rotation: 0, text,
+ style: clone(DEFAULT_NODE_STYLE), zIndex: 0, groupId: null
+ };
+ }
+
+ function createInitialState() {
+ const next = {
+ documentTitle: "Untitled Flowchart",
+ nodes: [], edges: [], selectedIds: [], zoom: 0.82, pan: { x: 160, y: 38 },
+ mode: "select", snap: true, history: [], historyIndex: -1
+ };
+ const labels = [
+ ["terminator", "START"],
+ ["input", "Display message:\n\"How many hours did you work?\""],
+ ["input", "Read Hours"],
+ ["input", "Display message:\n\"How much do you get paid per hour?\""],
+ ["input", "Read Pay Rate"],
+ ["process", "Multiply Hours by Pay Rate\nStore result in Gross Pay"],
+ ["input", "Display Gross Pay"],
+ ["terminator", "END"]
+ ];
+ labels.forEach(([type, text], index) => {
+ const node = createNode(type, 1120, 55 + index * 185, text);
+ if (type === "input") node.width = index === 1 || index === 3 ? 320 : 190;
+ if (type === "process") node.width = 300;
+ next.nodes.push(node);
+ if (index) {
+ next.edges.push({
+ id: uid("edge"), fromNodeId: next.nodes[index - 1].id, fromPort: "bottom",
+ toNodeId: node.id, toPort: "top", type: "straight", label: "", style: clone(DEFAULT_EDGE_STYLE)
+ });
+ }
+ });
+ next.history = [snapshot(next)];
+ next.historyIndex = 0;
+ return next;
+ }
+
+ function snapshot(source = state) {
+ return JSON.stringify({
+ documentTitle: source.documentTitle,
+ nodes: source.nodes,
+ edges: source.edges,
+ zoom: source.zoom,
+ pan: source.pan,
+ snap: source.snap
+ });
+ }
+
+ function commit(message = "Updated") {
+ const current = snapshot();
+ if (state.history[state.historyIndex] === current) return;
+ state.history = state.history.slice(0, state.historyIndex + 1);
+ state.history.push(current);
+ if (state.history.length > 100) state.history.shift();
+ state.historyIndex = state.history.length - 1;
+ setStatus(message);
+ updateToolbar();
+ }
+
+ function restoreHistory(index) {
+ if (index < 0 || index >= state.history.length) return;
+ const history = state.history;
+ const restored = JSON.parse(history[index]);
+ state = { ...state, ...restored, selectedIds: [], history, historyIndex: index, mode: "select" };
+ refs.title.value = state.documentTitle;
+ refs.snap.checked = state.snap;
+ finishTextEditing(false);
+ render();
+ }
+
+ function undo() { restoreHistory(state.historyIndex - 1); setStatus("Undo"); }
+ function redo() { restoreHistory(state.historyIndex + 1); setStatus("Redo"); }
+
+ function svgEl(tag, attributes = {}, text = "") {
+ const element = document.createElementNS(SVG_NS, tag);
+ Object.entries(attributes).forEach(([name, value]) => {
+ if (value !== null && value !== undefined && value !== "") element.setAttribute(name, String(value));
+ });
+ if (text) element.textContent = text;
+ return element;
+ }
+
+ function dashArray(style) {
+ if (style === "dashed") return "7 5";
+ if (style === "dotted") return "2 4";
+ return null;
+ }
+
+ function geometryElements(node, preview = false) {
+ const { width: w, height: h, type } = node;
+ const style = node.style || DEFAULT_NODE_STYLE;
+ const common = {
+ class: preview ? "" : "node-shape",
+ fill: style.fill,
+ stroke: style.stroke,
+ "stroke-width": style.strokeWidth,
+ "stroke-dasharray": dashArray(style.strokeStyle),
+ opacity: style.opacity,
+ "stroke-linejoin": "round"
+ };
+ const items = [];
+ if (type === "terminator") items.push(svgEl("rect", { ...common, width: w, height: h, rx: h / 2 }));
+ else if (type === "process") items.push(svgEl("rect", { ...common, width: w, height: h, rx: style.cornerRadius || 0 }));
+ else if (type === "input") items.push(svgEl("polygon", { ...common, points: `${w * .13},0 ${w},0 ${w * .87},${h} 0,${h}` }));
+ else if (type === "decision") items.push(svgEl("polygon", { ...common, points: `${w / 2},0 ${w},${h / 2} ${w / 2},${h} 0,${h / 2}` }));
+ else if (type === "document") items.push(svgEl("path", { ...common, d: `M0 0H${w}V${h * .78} C${w * .76} ${h * .58},${w * .58} ${h},${w * .34} ${h * .82} C${w * .2} ${h * .72},${w * .1} ${h * .78},0 ${h * .9}Z` }));
+ else if (type === "predefined") {
+ items.push(svgEl("rect", { ...common, width: w, height: h, rx: style.cornerRadius || 0 }));
+ items.push(svgEl("path", { d: `M${w * .12} 0V${h} M${w * .88} 0V${h}`, fill: "none", stroke: style.stroke, "stroke-width": style.strokeWidth, "stroke-dasharray": dashArray(style.strokeStyle), opacity: style.opacity, class: preview ? "" : "node-shape" }));
+ } else if (type === "database") {
+ items.push(svgEl("path", { ...common, d: `M0 ${h * .14} C0 ${-h * .02},${w} ${-h * .02},${w} ${h * .14} V${h * .86} C${w} ${h * 1.02},0 ${h * 1.02},0 ${h * .86}Z` }));
+ items.push(svgEl("ellipse", { cx: w / 2, cy: h * .14, rx: w / 2, ry: h * .14, fill: "none", stroke: style.stroke, "stroke-width": style.strokeWidth, opacity: style.opacity, class: preview ? "" : "node-shape" }));
+ } else if (type === "manual") items.push(svgEl("polygon", { ...common, points: `${w * .16},0 ${w},0 ${w * .86},${h} 0,${h}` }));
+ else if (type === "preparation") items.push(svgEl("polygon", { ...common, points: `${w * .15},0 ${w * .85},0 ${w},${h / 2} ${w * .85},${h} ${w * .15},${h} 0,${h / 2}` }));
+ else if (type === "onpage") items.push(svgEl("ellipse", { ...common, cx: w / 2, cy: h / 2, rx: w / 2, ry: h / 2 }));
+ else if (type === "offpage") items.push(svgEl("polygon", { ...common, points: `0,0 ${w},0 ${w},${h * .66} ${w / 2},${h} 0,${h * .66}` }));
+ else if (type === "delay") items.push(svgEl("path", { ...common, d: `M0 0H${w * .52} C${w * 1.12} 0,${w * 1.12} ${h},${w * .52} ${h}H0Z` }));
+ return items;
+ }
+
+ function wrappedLines(text, width, height, fontSize) {
+ const maxChars = Math.max(4, Math.floor((width - 24) / (fontSize * .56)));
+ const sourceLines = String(text || "").split("\n");
+ const result = [];
+ sourceLines.forEach((source) => {
+ if (!source) { result.push(""); return; }
+ const words = source.split(/\s+/);
+ let line = "";
+ words.forEach((word) => {
+ if (word.length > maxChars && !line) {
+ for (let index = 0; index < word.length; index += maxChars) result.push(word.slice(index, index + maxChars));
+ } else if (!line || `${line} ${word}`.length <= maxChars) line = line ? `${line} ${word}` : word;
+ else { result.push(line); line = word; }
+ });
+ if (line) result.push(line);
+ });
+ const lineHeight = fontSize * 1.25;
+ const maxLines = Math.max(1, Math.floor((height - 12) / lineHeight));
+ if (result.length > maxLines) {
+ result.length = maxLines;
+ const last = result[maxLines - 1];
+ result[maxLines - 1] = `${last.slice(0, Math.max(1, maxChars - 1))}…`;
+ }
+ return result;
+ }
+
+ function appendNodeText(group, node) {
+ const style = node.style || DEFAULT_NODE_STYLE;
+ const fontSize = Math.max(8, Number(style.fontSize) || 14);
+ const lines = wrappedLines(node.text, node.width, node.height, fontSize);
+ const lineHeight = fontSize * 1.25;
+ const anchor = style.textAlign === "left" ? "start" : style.textAlign === "right" ? "end" : "middle";
+ const x = style.textAlign === "left" ? 12 : style.textAlign === "right" ? node.width - 12 : node.width / 2;
+ const startY = node.height / 2 - ((lines.length - 1) * lineHeight) / 2 + fontSize * .34;
+ const text = svgEl("text", {
+ x, y: startY, fill: style.textColor || "#1f2937", "font-size": fontSize,
+ "font-family": "Inter, ui-sans-serif, system-ui, sans-serif", "font-weight": style.fontWeight,
+ "font-style": style.italic ? "italic" : "normal", "text-anchor": anchor,
+ "data-text-node": node.id, "pointer-events": "auto"
+ });
+ lines.forEach((line, index) => text.append(svgEl("tspan", { x, dy: index ? lineHeight : 0 }, line)));
+ group.append(text);
+ }
+
+ function renderNodeInto(node, parent) {
+ const group = svgEl("g", {
+ class: "node", "data-node-id": node.id,
+ transform: `translate(${node.x} ${node.y}) rotate(${node.rotation || 0} ${node.width / 2} ${node.height / 2})`
+ });
+ if (node.type !== "text") geometryElements(node).forEach((element) => group.append(element));
+ appendNodeText(group, node);
+ parent.append(group);
+ }
+
+ function portPoint(node, port) {
+ if (!node) return { x: 0, y: 0 };
+ if (port === "top") return { x: node.x + node.width / 2, y: node.y };
+ if (port === "right") return { x: node.x + node.width, y: node.y + node.height / 2 };
+ if (port === "bottom") return { x: node.x + node.width / 2, y: node.y + node.height };
+ return { x: node.x, y: node.y + node.height / 2 };
+ }
+
+ function portVector(port) {
+ return { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }[port] || [0, 0];
+ }
+
+ function connectorPath(edge, overrideEnd = null) {
+ const from = state.nodes.find((node) => node.id === edge.fromNodeId);
+ const to = state.nodes.find((node) => node.id === edge.toNodeId);
+ const a = portPoint(from, edge.fromPort);
+ const b = overrideEnd || portPoint(to, edge.toPort);
+ if (edge.type === "curved") {
+ const [avx, avy] = portVector(edge.fromPort);
+ const [bvx, bvy] = portVector(edge.toPort);
+ const distance = Math.max(50, Math.min(180, Math.hypot(b.x - a.x, b.y - a.y) * .45));
+ return `M${a.x} ${a.y} C${a.x + avx * distance} ${a.y + avy * distance},${b.x + bvx * distance} ${b.y + bvy * distance},${b.x} ${b.y}`;
+ }
+ if (edge.type === "elbow") {
+ const horizontalStart = edge.fromPort === "left" || edge.fromPort === "right";
+ if (horizontalStart) {
+ const midX = (a.x + b.x) / 2;
+ return `M${a.x} ${a.y} H${midX} V${b.y} H${b.x}`;
+ }
+ const midY = (a.y + b.y) / 2;
+ return `M${a.x} ${a.y} V${midY} H${b.x} V${b.y}`;
+ }
+ return `M${a.x} ${a.y} L${b.x} ${b.y}`;
+ }
+
+ function markerValue(kind, end) {
+ if (!kind || kind === "none") return null;
+ if (kind === "arrow") return `url(#arrow-${end})`;
+ if (kind === "circle") return "url(#circle-end)";
+ if (kind === "diamond") return "url(#diamond-end)";
+ return null;
+ }
+
+ function edgeLabelPoint(edge) {
+ const from = state.nodes.find((node) => node.id === edge.fromNodeId);
+ const to = state.nodes.find((node) => node.id === edge.toNodeId);
+ const a = portPoint(from, edge.fromPort);
+ const b = portPoint(to, edge.toPort);
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ }
+
+ function renderEdgeInto(edge, parent) {
+ if (!state.nodes.some((node) => node.id === edge.fromNodeId) || !state.nodes.some((node) => node.id === edge.toNodeId)) return;
+ const group = svgEl("g", { class: "edge", "data-edge-id": edge.id });
+ const style = edge.style || DEFAULT_EDGE_STYLE;
+ const d = connectorPath(edge);
+ group.append(svgEl("path", { class: "edge-hit", d }));
+ group.append(svgEl("path", {
+ class: "edge-path", d, stroke: style.stroke, "stroke-width": style.strokeWidth,
+ "stroke-dasharray": dashArray(style.strokeStyle), "marker-start": markerValue(style.startArrow, "start"),
+ "marker-end": markerValue(style.endArrow, "end")
+ }));
+ if (edge.label) {
+ const point = edgeLabelPoint(edge);
+ const width = Math.max(34, edge.label.length * 7 + 10);
+ group.append(svgEl("rect", { class: "edge-label-bg", x: point.x - width / 2, y: point.y - 12, width, height: 22, rx: 3, fill: "#ffffff", opacity: .92 }));
+ group.append(svgEl("text", { class: "edge-label", x: point.x, y: point.y + 4, "text-anchor": "middle", "font-size": 12, fill: "#334155", "data-text-edge": edge.id }, edge.label));
+ }
+ parent.append(group);
+ }
+
+ function render() {
+ refs.viewport.setAttribute("transform", `translate(${state.pan.x} ${state.pan.y}) scale(${state.zoom})`);
+ refs.edgeLayer.replaceChildren();
+ refs.nodeLayer.replaceChildren();
+ refs.overlayLayer.replaceChildren();
+ state.edges.forEach((edge) => renderEdgeInto(edge, refs.edgeLayer));
+ [...state.nodes].sort((a, b) => a.zIndex - b.zIndex).forEach((node) => renderNodeInto(node, refs.nodeLayer));
+ renderOverlays();
+ refs.zoom.textContent = `${Math.round(state.zoom * 100)}%`;
+ refs.selectionStatus.textContent = `${state.selectedIds.length} selected`;
+ refs.shell.dataset.mode = state.mode;
+ renderProperties();
+ updateToolbar();
+ }
+
+ function renderOverlays() {
+ if (!state.nodes.length && !state.edges.length) {
+ refs.overlayLayer.append(svgEl("text", { class: "empty-hint", x: CANVAS.width / 2, y: CANVAS.height / 2, "text-anchor": "middle" }, "Add a shape or double-click to add text"));
+ }
+ const selectedNodes = state.nodes.filter((node) => state.selectedIds.includes(node.id));
+ selectedNodes.forEach((node) => renderNodeOverlay(node));
+ if (state.mode === "connect") {
+ state.nodes.filter((node) => !state.selectedIds.includes(node.id)).forEach((node) => renderPorts(node));
+ }
+ const selectedEdge = state.edges.find((edge) => state.selectedIds.includes(edge.id));
+ if (selectedEdge && state.selectedIds.length === 1) renderEdgeOverlay(selectedEdge);
+ if (interaction?.type === "marquee") {
+ const box = normalizedRect(interaction.startWorld, interaction.currentWorld);
+ refs.overlayLayer.append(svgEl("rect", { class: "marquee", ...box }));
+ }
+ if (interaction?.type === "connect") {
+ const edge = { fromNodeId: interaction.nodeId, fromPort: interaction.port, toNodeId: interaction.nodeId, toPort: interaction.port, type: "straight" };
+ refs.overlayLayer.append(svgEl("path", { d: connectorPath(edge, interaction.currentWorld), fill: "none", stroke: "#2563eb", "stroke-width": 1.5, "stroke-dasharray": "5 4", "marker-end": "url(#arrow-end)", "vector-effect": "non-scaling-stroke" }));
+ }
+ if (interaction?.type === "reconnect") {
+ const edge = state.edges.find((item) => item.id === interaction.edgeId);
+ if (edge) {
+ const fixedNode = state.nodes.find((node) => node.id === (interaction.end === "from" ? edge.toNodeId : edge.fromNodeId));
+ const fixedPort = interaction.end === "from" ? edge.toPort : edge.fromPort;
+ const fixed = portPoint(fixedNode, fixedPort);
+ const a = interaction.end === "from" ? interaction.currentWorld : fixed;
+ const b = interaction.end === "from" ? fixed : interaction.currentWorld;
+ refs.overlayLayer.append(svgEl("path", { d: `M${a.x} ${a.y} L${b.x} ${b.y}`, fill: "none", stroke: "#2563eb", "stroke-width": 1.5, "stroke-dasharray": "5 4", "vector-effect": "non-scaling-stroke" }));
+ }
+ }
+ }
+
+ function renderNodeOverlay(node) {
+ refs.overlayLayer.append(svgEl("rect", {
+ class: "selection-outline", x: node.x - 3, y: node.y - 3, width: node.width + 6, height: node.height + 6, rx: 2
+ }));
+ if (state.selectedIds.length === 1) {
+ const handles = {
+ nw: [node.x, node.y], n: [node.x + node.width / 2, node.y], ne: [node.x + node.width, node.y],
+ e: [node.x + node.width, node.y + node.height / 2], se: [node.x + node.width, node.y + node.height],
+ s: [node.x + node.width / 2, node.y + node.height], sw: [node.x, node.y + node.height], w: [node.x, node.y + node.height / 2]
+ };
+ Object.entries(handles).forEach(([handle, [cx, cy]]) => refs.overlayLayer.append(svgEl("rect", {
+ class: "resize-handle", "data-resize-id": node.id, "data-handle": handle, x: cx - 4, y: cy - 4, width: 8, height: 8
+ })));
+ }
+ renderPorts(node);
+ }
+
+ function renderPorts(node) {
+ ["top", "right", "bottom", "left"].forEach((port) => {
+ const point = portPoint(node, port);
+ refs.overlayLayer.append(svgEl("circle", { class: "port", "data-port-node": node.id, "data-port": port, cx: point.x, cy: point.y, r: 5 }));
+ });
+ }
+
+ function renderEdgeOverlay(edge) {
+ const from = portPoint(state.nodes.find((node) => node.id === edge.fromNodeId), edge.fromPort);
+ const to = portPoint(state.nodes.find((node) => node.id === edge.toNodeId), edge.toPort);
+ refs.overlayLayer.append(svgEl("circle", { class: "endpoint-handle", "data-edge-end": "from", "data-edge-id": edge.id, cx: from.x, cy: from.y, r: 5 }));
+ refs.overlayLayer.append(svgEl("circle", { class: "endpoint-handle", "data-edge-end": "to", "data-edge-id": edge.id, cx: to.x, cy: to.y, r: 5 }));
+ }
+
+ function createShapePalette() {
+ SHAPES.forEach(([type, label]) => {
+ const item = document.createElement("div");
+ item.className = "shape-item";
+ item.tabIndex = 0;
+ item.draggable = true;
+ item.dataset.shape = type;
+ item.title = `Add ${label}`;
+ item.setAttribute("role", "button");
+ const preview = svgEl("svg", { viewBox: "0 0 70 46", "aria-hidden": "true" });
+ const node = { type, width: 64, height: 40, style: { ...DEFAULT_NODE_STYLE, strokeWidth: 1.2 } };
+ const group = svgEl("g", { transform: "translate(3 3)" });
+ geometryElements(node, true).forEach((element) => group.append(element));
+ preview.append(group);
+ const text = document.createElement("span");
+ text.textContent = label;
+ item.append(preview, text);
+ refs.shapeList.append(item);
+ });
+ }
+
+ function addNode(type, point) {
+ const [width, height] = DEFAULT_SIZES[type] || DEFAULT_SIZES.process;
+ const node = createNode(type, clamp(point.x - width / 2, CANVAS.padding, CANVAS.width - width - CANVAS.padding), clamp(point.y - height / 2, CANVAS.padding, CANVAS.height - height - CANVAS.padding));
+ if (state.snap) { node.x = snap(node.x); node.y = snap(node.y); }
+ state.nodes.push(node);
+ state.selectedIds = [node.id];
+ commit(`Added ${SHAPES.find(([key]) => key === type)?.[1] || "shape"}`);
+ render();
+ return node;
+ }
+
+ function visibleCenter() {
+ const rect = refs.svg.getBoundingClientRect();
+ return screenToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2);
+ }
+
+ function screenToWorld(clientX, clientY) {
+ const rect = refs.svg.getBoundingClientRect();
+ return { x: (clientX - rect.left - state.pan.x) / state.zoom, y: (clientY - rect.top - state.pan.y) / state.zoom };
+ }
+
+ function worldToScreen(x, y) {
+ const rect = refs.svg.getBoundingClientRect();
+ return { x: rect.left + state.pan.x + x * state.zoom, y: rect.top + state.pan.y + y * state.zoom };
+ }
+
+ function normalizedRect(a, b) {
+ return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
+ }
+
+ function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
+ function snap(value) { return Math.round(value / GRID_SIZE) * GRID_SIZE; }
+
+ function setMode(mode) {
+ state.mode = mode;
+ document.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode));
+ refs.shell.dataset.mode = mode;
+ setStatus(mode === "connect" ? "Drag between connection points" : mode === "text" ? "Click to add text" : "Select tool");
+ render();
+ }
+
+ function selectNode(nodeId, additive = false) {
+ const node = state.nodes.find((item) => item.id === nodeId);
+ let ids = [nodeId];
+ if (node?.groupId && !additive) ids = state.nodes.filter((item) => item.groupId === node.groupId).map((item) => item.id);
+ if (additive) {
+ state.selectedIds = state.selectedIds.includes(nodeId) ? state.selectedIds.filter((id) => id !== nodeId) : [...state.selectedIds, ...ids.filter((id) => !state.selectedIds.includes(id))];
+ } else if (!state.selectedIds.includes(nodeId)) state.selectedIds = ids;
+ }
+
+ function onPointerDown(event) {
+ if (event.button !== 0 && event.button !== 1) return;
+ refs.shell.focus({ preventScroll: true });
+ const world = screenToWorld(event.clientX, event.clientY);
+ const resize = event.target.closest?.("[data-resize-id]");
+ const port = event.target.closest?.("[data-port-node]");
+ const endpoint = event.target.closest?.("[data-edge-end]");
+ const nodeElement = event.target.closest?.("[data-node-id]");
+ const edgeElement = event.target.closest?.("[data-edge-id]");
+
+ if (spacePressed || event.button === 1) {
+ interaction = { type: "pan", startClient: { x: event.clientX, y: event.clientY }, initialPan: { ...state.pan } };
+ refs.shell.classList.add("panning");
+ } else if (resize) {
+ const node = state.nodes.find((item) => item.id === resize.dataset.resizeId);
+ interaction = { type: "resize", nodeId: node.id, handle: resize.dataset.handle, startWorld: world, initial: clone(node) };
+ } else if (endpoint) {
+ interaction = { type: "reconnect", edgeId: endpoint.dataset.edgeId, end: endpoint.dataset.edgeEnd, currentWorld: world };
+ } else if (port && (state.mode === "connect" || state.selectedIds.includes(port.dataset.portNode))) {
+ interaction = { type: "connect", nodeId: port.dataset.portNode, port: port.dataset.port, startWorld: world, currentWorld: world };
+ } else if (nodeElement) {
+ const nodeId = nodeElement.dataset.nodeId;
+ selectNode(nodeId, event.shiftKey);
+ const initial = new Map(state.nodes.filter((node) => state.selectedIds.includes(node.id)).map((node) => [node.id, { x: node.x, y: node.y }]));
+ interaction = { type: "move", startWorld: world, initial, changed: false };
+ render();
+ } else if (edgeElement) {
+ const edgeId = edgeElement.dataset.edgeId;
+ state.selectedIds = event.shiftKey ? [...new Set([...state.selectedIds, edgeId])] : [edgeId];
+ render();
+ } else if (state.mode === "text") {
+ const node = addNode("text", world);
+ beginTextEditing("node", node.id);
+ } else {
+ if (!event.shiftKey) state.selectedIds = [];
+ interaction = { type: "marquee", startWorld: world, currentWorld: world, additive: event.shiftKey };
+ render();
+ }
+ if (interaction) refs.svg.setPointerCapture(event.pointerId);
+ event.preventDefault();
+ }
+
+ function onPointerMove(event) {
+ if (!interaction) return;
+ const world = screenToWorld(event.clientX, event.clientY);
+ if (interaction.type === "pan") {
+ state.pan.x = interaction.initialPan.x + event.clientX - interaction.startClient.x;
+ state.pan.y = interaction.initialPan.y + event.clientY - interaction.startClient.y;
+ applyViewport();
+ return;
+ }
+ if (interaction.type === "move") {
+ let dx = world.x - interaction.startWorld.x;
+ let dy = world.y - interaction.startWorld.y;
+ interaction.initial.forEach((position, id) => {
+ const node = state.nodes.find((item) => item.id === id);
+ if (!node) return;
+ let x = position.x + dx;
+ let y = position.y + dy;
+ if (state.snap) { x = snap(x); y = snap(y); }
+ node.x = clamp(x, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding);
+ node.y = clamp(y, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding);
+ });
+ interaction.changed = Math.abs(dx) > .5 || Math.abs(dy) > .5;
+ render();
+ return;
+ }
+ if (interaction.type === "resize") {
+ resizeNode(interaction, world);
+ render();
+ return;
+ }
+ interaction.currentWorld = world;
+ renderOverlaysOnly();
+ }
+
+ function resizeNode(active, world) {
+ const node = state.nodes.find((item) => item.id === active.nodeId);
+ if (!node) return;
+ const minWidth = node.type === "text" ? 60 : 48;
+ const minHeight = node.type === "text" ? 28 : 36;
+ const dx = world.x - active.startWorld.x;
+ const dy = world.y - active.startWorld.y;
+ let { x, y, width, height } = active.initial;
+ if (active.handle.includes("e")) width = Math.max(minWidth, active.initial.width + dx);
+ if (active.handle.includes("s")) height = Math.max(minHeight, active.initial.height + dy);
+ if (active.handle.includes("w")) { width = Math.max(minWidth, active.initial.width - dx); x = active.initial.x + active.initial.width - width; }
+ if (active.handle.includes("n")) { height = Math.max(minHeight, active.initial.height - dy); y = active.initial.y + active.initial.height - height; }
+ if (state.snap) { x = snap(x); y = snap(y); width = Math.max(minWidth, snap(width)); height = Math.max(minHeight, snap(height)); }
+ node.x = clamp(x, CANVAS.padding, CANVAS.width - minWidth - CANVAS.padding);
+ node.y = clamp(y, CANVAS.padding, CANVAS.height - minHeight - CANVAS.padding);
+ node.width = Math.min(width, CANVAS.width - node.x - CANVAS.padding);
+ node.height = Math.min(height, CANVAS.height - node.y - CANVAS.padding);
+ }
+
+ function onPointerUp(event) {
+ if (!interaction) return;
+ const active = interaction;
+ const world = screenToWorld(event.clientX, event.clientY);
+ interaction = null;
+ refs.shell.classList.remove("panning");
+ if (active.type === "move" && active.changed) commit("Moved selection");
+ else if (active.type === "resize") commit("Resized shape");
+ else if (active.type === "marquee") completeMarquee(active, world);
+ else if (active.type === "connect") completeConnection(active, world);
+ else if (active.type === "reconnect") completeReconnect(active, world);
+ render();
+ }
+
+ function completeMarquee(active, world) {
+ const box = normalizedRect(active.startWorld, world);
+ const hits = state.nodes.filter((node) => node.x >= box.x && node.y >= box.y && node.x + node.width <= box.x + box.width && node.y + node.height <= box.y + box.height).map((node) => node.id);
+ state.selectedIds = active.additive ? [...new Set([...state.selectedIds, ...hits])] : hits;
+ }
+
+ function nearestPort(world, excludeNodeId = null) {
+ let best = null;
+ state.nodes.forEach((node) => {
+ if (node.id === excludeNodeId) return;
+ ["top", "right", "bottom", "left"].forEach((port) => {
+ const point = portPoint(node, port);
+ const distance = Math.hypot(point.x - world.x, point.y - world.y);
+ if (distance <= 34 / state.zoom && (!best || distance < best.distance)) best = { nodeId: node.id, port, distance };
+ });
+ });
+ return best;
+ }
+
+ function completeConnection(active, world) {
+ const target = nearestPort(world, active.nodeId);
+ if (!target) { setStatus("Connector cancelled"); return; }
+ const edge = {
+ id: uid("edge"), fromNodeId: active.nodeId, fromPort: active.port,
+ toNodeId: target.nodeId, toPort: target.port, type: "straight", label: "", style: clone(DEFAULT_EDGE_STYLE)
+ };
+ state.edges.push(edge);
+ state.selectedIds = [edge.id];
+ commit("Created connector");
+ }
+
+ function completeReconnect(active, world) {
+ const edge = state.edges.find((item) => item.id === active.edgeId);
+ if (!edge) return;
+ const exclude = active.end === "from" ? edge.toNodeId : edge.fromNodeId;
+ const target = nearestPort(world, exclude);
+ if (!target) { setStatus("Endpoint unchanged"); return; }
+ if (active.end === "from") { edge.fromNodeId = target.nodeId; edge.fromPort = target.port; }
+ else { edge.toNodeId = target.nodeId; edge.toPort = target.port; }
+ commit("Reconnected endpoint");
+ }
+
+ function renderOverlaysOnly() {
+ refs.overlayLayer.replaceChildren();
+ renderOverlays();
+ }
+
+ function applyViewport() {
+ refs.viewport.setAttribute("transform", `translate(${state.pan.x} ${state.pan.y}) scale(${state.zoom})`);
+ refs.zoom.textContent = `${Math.round(state.zoom * 100)}%`;
+ }
+
+ function zoomAt(factor, clientX, clientY) {
+ const rect = refs.svg.getBoundingClientRect();
+ const cx = clientX ?? rect.left + rect.width / 2;
+ const cy = clientY ?? rect.top + rect.height / 2;
+ const world = screenToWorld(cx, cy);
+ const next = clamp(state.zoom * factor, .2, 3);
+ state.zoom = Math.round(next * 100) / 100;
+ state.pan.x = cx - rect.left - world.x * state.zoom;
+ state.pan.y = cy - rect.top - world.y * state.zoom;
+ render();
+ }
+
+ function fitToScreen() {
+ const rect = refs.svg.getBoundingClientRect();
+ const bounds = diagramBounds();
+ const pad = 70;
+ state.zoom = clamp(Math.min((rect.width - pad * 2) / bounds.width, (rect.height - pad * 2) / bounds.height), .2, 1.5);
+ state.pan.x = (rect.width - bounds.width * state.zoom) / 2 - bounds.x * state.zoom;
+ state.pan.y = (rect.height - bounds.height * state.zoom) / 2 - bounds.y * state.zoom;
+ render();
+ setStatus("Fit diagram to screen");
+ }
+
+ function diagramBounds() {
+ if (!state.nodes.length) return { x: 0, y: 0, width: CANVAS.width, height: CANVAS.height };
+ const minX = Math.min(...state.nodes.map((node) => node.x));
+ const minY = Math.min(...state.nodes.map((node) => node.y));
+ const maxX = Math.max(...state.nodes.map((node) => node.x + node.width));
+ const maxY = Math.max(...state.nodes.map((node) => node.y + node.height));
+ return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
+ }
+
+ function beginTextEditing(kind, id) {
+ finishTextEditing(true);
+ const item = kind === "node" ? state.nodes.find((node) => node.id === id) : state.edges.find((edge) => edge.id === id);
+ if (!item) return;
+ editing = { kind, id, original: item.text ?? item.label ?? "", cancelled: false };
+ let box;
+ let style = DEFAULT_NODE_STYLE;
+ if (kind === "node") {
+ const topLeft = worldToScreen(item.x, item.y);
+ box = { left: topLeft.x, top: topLeft.y, width: item.width * state.zoom, height: item.height * state.zoom };
+ style = item.style || style;
+ } else {
+ const point = edgeLabelPoint(item);
+ const screen = worldToScreen(point.x, point.y);
+ box = { left: screen.x - 90, top: screen.y - 22, width: 180, height: 44 };
+ }
+ Object.assign(refs.editor.style, {
+ display: "block", left: `${box.left}px`, top: `${box.top}px`, width: `${Math.max(70, box.width)}px`,
+ height: `${Math.max(34, box.height)}px`, fontSize: `${Math.max(11, (style.fontSize || 14) * state.zoom)}px`,
+ fontWeight: style.fontWeight, fontStyle: style.italic ? "italic" : "normal", textAlign: style.textAlign || "center"
+ });
+ refs.editor.value = kind === "node" ? item.text : item.label;
+ refs.editor.focus();
+ refs.editor.select();
+ }
+
+ function finishTextEditing(save = true) {
+ if (!editing) return;
+ const active = editing;
+ editing = null;
+ refs.editor.style.display = "none";
+ const item = active.kind === "node" ? state.nodes.find((node) => node.id === active.id) : state.edges.find((edge) => edge.id === active.id);
+ if (!item) return;
+ const value = active.cancelled || !save ? active.original : refs.editor.value;
+ if (active.kind === "node") item.text = value;
+ else item.label = value;
+ if (save && !active.cancelled && value !== active.original) commit("Edited text");
+ render();
+ }
+
+ function selectedNodes() { return state.nodes.filter((node) => state.selectedIds.includes(node.id)); }
+ function selectedEdges() { return state.edges.filter((edge) => state.selectedIds.includes(edge.id)); }
+
+ function deleteSelection() {
+ if (!state.selectedIds.length) return;
+ const nodeIds = new Set(selectedNodes().map((node) => node.id));
+ state.nodes = state.nodes.filter((node) => !nodeIds.has(node.id));
+ state.edges = state.edges.filter((edge) => !state.selectedIds.includes(edge.id) && !nodeIds.has(edge.fromNodeId) && !nodeIds.has(edge.toNodeId));
+ state.selectedIds = [];
+ commit("Deleted selection");
+ render();
+ }
+
+ function copySelection() {
+ const nodes = selectedNodes();
+ if (!nodes.length) return;
+ const ids = new Set(nodes.map((node) => node.id));
+ clipboard = { nodes: clone(nodes), edges: clone(state.edges.filter((edge) => ids.has(edge.fromNodeId) && ids.has(edge.toNodeId))) };
+ setStatus(`Copied ${nodes.length} object${nodes.length === 1 ? "" : "s"}`);
+ }
+
+ function pasteSelection() {
+ if (!clipboard?.nodes?.length) return;
+ const idMap = new Map();
+ const groupMap = new Map();
+ const nodes = clipboard.nodes.map((source) => {
+ const node = clone(source);
+ idMap.set(node.id, uid("node"));
+ node.id = idMap.get(node.id);
+ node.x = clamp(node.x + 30, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding);
+ node.y = clamp(node.y + 30, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding);
+ if (node.groupId) {
+ if (!groupMap.has(node.groupId)) groupMap.set(node.groupId, uid("group"));
+ node.groupId = groupMap.get(node.groupId);
+ }
+ node.zIndex = state.nodes.length + 1;
+ return node;
+ });
+ const edges = clipboard.edges.map((source) => ({ ...clone(source), id: uid("edge"), fromNodeId: idMap.get(source.fromNodeId), toNodeId: idMap.get(source.toNodeId) }));
+ state.nodes.push(...nodes);
+ state.edges.push(...edges);
+ state.selectedIds = nodes.map((node) => node.id);
+ clipboard = { nodes: clone(nodes), edges: clone(edges) };
+ commit("Pasted selection");
+ render();
+ }
+
+ function duplicateSelection() { copySelection(); pasteSelection(); }
+
+ function moveSelection(dx, dy) {
+ const nodes = selectedNodes();
+ if (!nodes.length) return;
+ nodes.forEach((node) => {
+ node.x = clamp(node.x + dx, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding);
+ node.y = clamp(node.y + dy, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding);
+ });
+ nudgePending = true;
+ render();
+ }
+
+ function clearCanvas() {
+ if (!state.nodes.length && !state.edges.length) return;
+ state.nodes = [];
+ state.edges = [];
+ state.selectedIds = [];
+ commit("Cleared canvas — undo is available");
+ render();
+ }
+
+ function renderProperties() {
+ const nodes = selectedNodes();
+ const edges = selectedEdges();
+ refs.properties.hidden = !state.selectedIds.length;
+ if (!state.selectedIds.length) return;
+ if (nodes.length > 1 && !edges.length) return renderMultiProperties(nodes);
+ if (nodes.length === 1 && !edges.length) return renderNodeProperties(nodes[0]);
+ if (edges.length === 1 && !nodes.length) return renderEdgeProperties(edges[0]);
+ refs.propertiesTitle.textContent = "Selection";
+ refs.propertiesContent.innerHTML = `Mixed objects selected
`;
+ }
+
+ function option(value, label, current) { return ``; }
+
+ function renderNodeProperties(node) {
+ const style = node.style;
+ refs.propertiesTitle.textContent = node.type === "text" ? "Text" : "Shape";
+ refs.propertiesContent.innerHTML = `
+ ${node.type === "text" ? "" : ``}
+
+
+ Layer
+ `;
+ }
+
+ function renderEdgeProperties(edge) {
+ const style = edge.style;
+ refs.propertiesTitle.textContent = "Connector";
+ refs.propertiesContent.innerHTML = `
+ Line
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ function arrowOptions(current) {
+ return [["none", "None"], ["arrow", "Arrow"], ["circle", "Circle"], ["diamond", "Diamond"]].map(([value, label]) => option(value, label, current)).join("");
+ }
+
+ function renderMultiProperties(nodes) {
+ refs.propertiesTitle.textContent = `${nodes.length} Shapes`;
+ refs.propertiesContent.innerHTML = `
+ Align
+ Distribute
+
+ `;
+ }
+
+ function escapeHtml(value) {
+ return String(value || "").replace(/[&<>"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[character]));
+ }
+
+ function handlePropertyChange(event) {
+ const node = selectedNodes()[0];
+ const edge = selectedEdges()[0];
+ if (event.target.dataset.prop && node) {
+ const key = event.target.dataset.prop;
+ node.style[key] = event.target.type === "number" ? Number(event.target.value) : event.target.value;
+ } else if (event.target.dataset.nodeProp && node) {
+ const key = event.target.dataset.nodeProp;
+ let value = Number(event.target.value);
+ if (["width", "height"].includes(key)) value = Math.max(key === "width" ? 40 : 28, value);
+ node[key] = value;
+ node.x = clamp(node.x, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding);
+ node.y = clamp(node.y, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding);
+ } else if (event.target.dataset.edgeProp && edge) {
+ edge[event.target.dataset.edgeProp] = event.target.value;
+ } else if (event.target.dataset.edgeStyle && edge) {
+ const key = event.target.dataset.edgeStyle;
+ edge.style[key] = event.target.type === "number" ? Number(event.target.value) : event.target.value;
+ } else return;
+ commit("Changed properties");
+ render();
+ }
+
+ function handlePropertiesClick(event) {
+ const propButton = event.target.closest("[data-prop-button]");
+ const arrange = event.target.closest("[data-arrange]");
+ const action = event.target.closest("[data-panel-action]");
+ if (propButton) {
+ const node = selectedNodes()[0];
+ if (!node) return;
+ const key = propButton.dataset.propButton;
+ node.style[key] = propButton.dataset.value === "true" ? true : propButton.dataset.value === "false" ? false : propButton.dataset.value;
+ commit("Changed text style"); render();
+ } else if (arrange) arrangeSelection(arrange.dataset.arrange);
+ else if (action) handlePanelAction(action.dataset.panelAction);
+ }
+
+ function arrangeSelection(kind) {
+ const nodes = selectedNodes();
+ if (nodes.length < 2) return;
+ const minX = Math.min(...nodes.map((node) => node.x));
+ const maxX = Math.max(...nodes.map((node) => node.x + node.width));
+ const minY = Math.min(...nodes.map((node) => node.y));
+ const maxY = Math.max(...nodes.map((node) => node.y + node.height));
+ if (kind === "left") nodes.forEach((node) => { node.x = minX; });
+ if (kind === "center") nodes.forEach((node) => { node.x = (minX + maxX - node.width) / 2; });
+ if (kind === "right") nodes.forEach((node) => { node.x = maxX - node.width; });
+ if (kind === "top") nodes.forEach((node) => { node.y = minY; });
+ if (kind === "middle") nodes.forEach((node) => { node.y = (minY + maxY - node.height) / 2; });
+ if (kind === "bottom") nodes.forEach((node) => { node.y = maxY - node.height; });
+ if (kind === "horizontal") distribute(nodes, "x", "width");
+ if (kind === "vertical") distribute(nodes, "y", "height");
+ commit(`${kind[0].toUpperCase()}${kind.slice(1)} arrangement`);
+ render();
+ }
+
+ function distribute(nodes, axis, sizeKey) {
+ if (nodes.length < 3) return;
+ const sorted = [...nodes].sort((a, b) => a[axis] - b[axis]);
+ const first = sorted[0][axis];
+ const lastEnd = sorted.at(-1)[axis] + sorted.at(-1)[sizeKey];
+ const totalSize = sorted.reduce((sum, node) => sum + node[sizeKey], 0);
+ const gap = (lastEnd - first - totalSize) / (sorted.length - 1);
+ let cursor = first;
+ sorted.forEach((node) => { node[axis] = cursor; cursor += node[sizeKey] + gap; });
+ }
+
+ function handlePanelAction(action) {
+ if (action === "delete") return deleteSelection();
+ if (action === "duplicate") return duplicateSelection();
+ if (action === "group") {
+ const groupId = uid("group"); selectedNodes().forEach((node) => { node.groupId = groupId; }); commit("Grouped selection"); render(); return;
+ }
+ if (action === "ungroup") { selectedNodes().forEach((node) => { node.groupId = null; }); commit("Ungrouped selection"); render(); return; }
+ changeLayer(action);
+ }
+
+ function changeLayer(action) {
+ const node = selectedNodes()[0];
+ if (!node) return;
+ const sorted = [...state.nodes].sort((a, b) => a.zIndex - b.zIndex);
+ const index = sorted.findIndex((item) => item.id === node.id);
+ if (action === "front") sorted.splice(index, 1), sorted.push(node);
+ if (action === "back") sorted.splice(index, 1), sorted.unshift(node);
+ if (action === "forward" && index < sorted.length - 1) [sorted[index], sorted[index + 1]] = [sorted[index + 1], sorted[index]];
+ if (action === "backward" && index > 0) [sorted[index], sorted[index - 1]] = [sorted[index - 1], sorted[index]];
+ sorted.forEach((item, position) => { item.zIndex = position; });
+ commit("Changed layer order"); render();
+ }
+
+ function projectData() {
+ return {
+ version: 1, documentTitle: state.documentTitle, canvas: clone(CANVAS), nodes: clone(state.nodes),
+ edges: clone(state.edges), zoom: state.zoom, pan: clone(state.pan), snap: state.snap
+ };
+ }
+
+ function normalizeProject(data) {
+ if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) throw new Error("Invalid project file");
+ return {
+ documentTitle: String(data.documentTitle || "Untitled Flowchart"),
+ nodes: data.nodes.map((node, index) => ({ ...createNode(node.type || "process", 0, 0, ""), ...node, style: { ...DEFAULT_NODE_STYLE, ...(node.style || {}) }, zIndex: Number(node.zIndex ?? index) })),
+ edges: data.edges.map((edge) => ({ ...edge, id: edge.id || uid("edge"), type: edge.type || "straight", label: edge.label || "", style: { ...DEFAULT_EDGE_STYLE, ...(edge.style || {}) } })),
+ zoom: clamp(Number(data.zoom) || 1, .2, 3), pan: { x: Number(data.pan?.x) || 0, y: Number(data.pan?.y) || 0 }, snap: data.snap !== false
+ };
+ }
+
+ function loadProject(data, message = "Loaded project") {
+ const normalized = normalizeProject(data);
+ state = { ...state, ...normalized, selectedIds: [], history: [], historyIndex: -1, mode: "select" };
+ state.history = [snapshot()]; state.historyIndex = 0;
+ refs.title.value = state.documentTitle; refs.snap.checked = state.snap;
+ render(); setStatus(message);
+ }
+
+ function saveLocal() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(projectData()));
+ setStatus("Saved in this browser");
+ }
+
+ function loadLocal() {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) { setStatus("No saved project in this browser"); return; }
+ try { loadProject(JSON.parse(raw), "Loaded saved project"); } catch { setStatus("The saved project could not be loaded"); }
+ }
+
+ function safeFileName(extension) {
+ const name = state.documentTitle.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "flowchart";
+ return `${name}.${extension}`;
+ }
+
+ function downloadBlob(blob, fileName) {
+ const link = document.createElement("a");
+ const url = URL.createObjectURL(blob);
+ link.href = url; link.download = fileName; link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ function downloadJson() {
+ downloadBlob(new Blob([JSON.stringify(projectData(), null, 2)], { type: "application/json" }), safeFileName("json"));
+ setStatus("Downloaded project JSON");
+ }
+
+ function exportSvgString() {
+ const bounds = diagramBounds();
+ const padding = 32;
+ const root = svgEl("svg", {
+ xmlns: SVG_NS, viewBox: `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`,
+ width: Math.ceil(bounds.width + padding * 2), height: Math.ceil(bounds.height + padding * 2)
+ });
+ const defs = svgEl("defs");
+ defs.innerHTML = ``;
+ root.append(defs);
+ const edgeGroup = svgEl("g");
+ state.edges.forEach((edge) => renderEdgeInto(edge, edgeGroup));
+ edgeGroup.querySelectorAll(".edge-hit").forEach((hit) => hit.remove());
+ root.append(edgeGroup);
+ const nodeGroup = svgEl("g");
+ [...state.nodes].sort((a, b) => a.zIndex - b.zIndex).forEach((node) => renderNodeInto(node, nodeGroup));
+ root.append(nodeGroup);
+ return `\n${new XMLSerializer().serializeToString(root)}`;
+ }
+
+ function exportSvg() {
+ if (!state.nodes.length) { setStatus("Add an object before exporting"); return; }
+ downloadBlob(new Blob([exportSvgString()], { type: "image/svg+xml;charset=utf-8" }), safeFileName("svg"));
+ setStatus("Exported SVG");
+ }
+
+ function exportPng() {
+ if (!state.nodes.length) { setStatus("Add an object before exporting"); return; }
+ const svg = exportSvgString();
+ const image = new Image();
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
+ image.onload = () => {
+ const scale = Math.min(3, 2400 / Math.max(image.width, 1));
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.ceil(image.width * scale); canvas.height = Math.ceil(image.height * scale);
+ const context = canvas.getContext("2d");
+ context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvas.width, canvas.height);
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
+ canvas.toBlob((blob) => { if (blob) downloadBlob(blob, safeFileName("png")); }, "image/png");
+ URL.revokeObjectURL(url); setStatus("Exported PNG");
+ };
+ image.onerror = () => { URL.revokeObjectURL(url); setStatus("PNG export failed"); };
+ image.src = url;
+ }
+
+ function handleToolbar(action) {
+ const actions = {
+ undo, redo, delete: deleteSelection, duplicate: duplicateSelection, clear: clearCanvas,
+ "zoom-out": () => zoomAt(1 / 1.15), "zoom-in": () => zoomAt(1.15), "zoom-reset": () => { state.zoom = 1; render(); },
+ fit: fitToScreen, save: saveLocal, load: loadLocal, "download-json": downloadJson,
+ "import-json": () => refs.fileInput.click(), "export-svg": exportSvg, "export-png": exportPng
+ };
+ actions[action]?.();
+ }
+
+ function updateToolbar() {
+ const disabled = {
+ undo: state.historyIndex <= 0, redo: state.historyIndex >= state.history.length - 1,
+ delete: !state.selectedIds.length, duplicate: !selectedNodes().length
+ };
+ Object.entries(disabled).forEach(([action, value]) => {
+ const button = document.querySelector(`[data-action="${action}"]`);
+ if (button) button.disabled = value;
+ });
+ }
+
+ function setStatus(message) { refs.status.textContent = message; }
+
+ function onKeyDown(event) {
+ const typing = /INPUT|TEXTAREA|SELECT/.test(event.target.tagName);
+ if (event.code === "Space" && !typing) { spacePressed = true; event.preventDefault(); }
+ if (typing) return;
+ const command = event.ctrlKey || event.metaKey;
+ if (command && event.key.toLowerCase() === "z") { event.preventDefault(); event.shiftKey ? redo() : undo(); }
+ else if (command && event.key.toLowerCase() === "y") { event.preventDefault(); redo(); }
+ else if (command && event.key.toLowerCase() === "d") { event.preventDefault(); duplicateSelection(); }
+ else if (command && event.key.toLowerCase() === "c") { event.preventDefault(); copySelection(); }
+ else if (command && event.key.toLowerCase() === "v") { event.preventDefault(); pasteSelection(); }
+ else if (command && event.key.toLowerCase() === "s") { event.preventDefault(); saveLocal(); }
+ else if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); deleteSelection(); }
+ else if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
+ event.preventDefault();
+ const distance = event.shiftKey ? 10 : 1;
+ moveSelection(event.key === "ArrowLeft" ? -distance : event.key === "ArrowRight" ? distance : 0, event.key === "ArrowUp" ? -distance : event.key === "ArrowDown" ? distance : 0);
+ } else if (!command && event.key.toLowerCase() === "v") setMode("select");
+ else if (!command && event.key.toLowerCase() === "c") setMode("connect");
+ else if (!command && event.key.toLowerCase() === "t") setMode("text");
+ }
+
+ function onKeyUp(event) {
+ if (event.code === "Space") spacePressed = false;
+ if (nudgePending && event.key.startsWith("Arrow")) { nudgePending = false; commit("Moved selection"); }
+ }
+
+ function bindEvents() {
+ document.querySelector(".toolbar").addEventListener("click", (event) => {
+ const mode = event.target.closest("[data-mode]");
+ const action = event.target.closest("[data-action]");
+ if (mode) setMode(mode.dataset.mode);
+ if (action) handleToolbar(action.dataset.action);
+ });
+ refs.shapeList.addEventListener("click", (event) => {
+ const item = event.target.closest("[data-shape]");
+ if (item) addNode(item.dataset.shape, visibleCenter());
+ });
+ refs.shapeList.addEventListener("keydown", (event) => {
+ if (["Enter", " "].includes(event.key) && event.target.dataset.shape) { event.preventDefault(); addNode(event.target.dataset.shape, visibleCenter()); }
+ });
+ refs.shapeList.addEventListener("dragstart", (event) => {
+ const item = event.target.closest("[data-shape]");
+ if (item) event.dataTransfer.setData("text/x-flowchart-shape", item.dataset.shape);
+ });
+ refs.svg.addEventListener("dragover", (event) => { if (event.dataTransfer.types.includes("text/x-flowchart-shape")) event.preventDefault(); });
+ refs.svg.addEventListener("drop", (event) => { const type = event.dataTransfer.getData("text/x-flowchart-shape"); if (type) { event.preventDefault(); addNode(type, screenToWorld(event.clientX, event.clientY)); } });
+ refs.svg.addEventListener("pointerdown", onPointerDown);
+ refs.svg.addEventListener("pointermove", onPointerMove);
+ refs.svg.addEventListener("pointerup", onPointerUp);
+ refs.svg.addEventListener("pointercancel", onPointerUp);
+ refs.svg.addEventListener("dblclick", (event) => {
+ const node = event.target.closest?.("[data-node-id]");
+ const edgeText = event.target.closest?.("[data-text-edge]");
+ if (node) beginTextEditing("node", node.dataset.nodeId);
+ else if (edgeText) beginTextEditing("edge", edgeText.dataset.textEdge);
+ else if (event.target.closest?.(".workspace-bg, .grid")) {
+ const textNode = addNode("text", screenToWorld(event.clientX, event.clientY)); beginTextEditing("node", textNode.id);
+ }
+ event.preventDefault();
+ });
+ refs.svg.addEventListener("wheel", (event) => {
+ if (!event.ctrlKey && !event.metaKey) return;
+ event.preventDefault(); zoomAt(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX, event.clientY);
+ }, { passive: false });
+ refs.editor.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") { event.preventDefault(); editing.cancelled = true; finishTextEditing(false); }
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); finishTextEditing(true); }
+ });
+ refs.editor.addEventListener("blur", () => finishTextEditing(true));
+ refs.propertiesContent.addEventListener("change", handlePropertyChange);
+ refs.propertiesContent.addEventListener("click", handlePropertiesClick);
+ refs.title.addEventListener("change", () => { state.documentTitle = refs.title.value.trim() || "Untitled Flowchart"; refs.title.value = state.documentTitle; commit("Renamed document"); });
+ refs.snap.addEventListener("change", () => { state.snap = refs.snap.checked; commit(state.snap ? "Snap enabled" : "Snap disabled"); render(); });
+ refs.fileInput.addEventListener("change", async () => {
+ const file = refs.fileInput.files[0]; refs.fileInput.value = "";
+ if (!file) return;
+ try { loadProject(JSON.parse(await file.text()), "Imported project"); } catch { setStatus("That file is not a valid project"); }
+ });
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("keyup", onKeyUp);
+ window.addEventListener("blur", () => { spacePressed = false; });
+ new ResizeObserver(() => {
+ const rect = refs.svg.getBoundingClientRect();
+ refs.svg.setAttribute("viewBox", `0 0 ${Math.max(1, rect.width)} ${Math.max(1, rect.height)}`);
+ }).observe(refs.shell);
+ }
+
+ createShapePalette();
+ bindEvents();
+ refs.title.value = state.documentTitle;
+ refs.snap.checked = state.snap;
+ setMode("select");
+ requestAnimationFrame(fitToScreen);
+})();
diff --git a/scripts/build.mjs b/scripts/build.mjs
new file mode 100644
index 0000000..2ebb65d
--- /dev/null
+++ b/scripts/build.mjs
@@ -0,0 +1,15 @@
+import { copyFile, mkdir, rm } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const output = resolve(root, "dist");
+const files = ["index.html", "style.css", "script.js"];
+
+if (dirname(output) !== root) throw new Error("Refusing to build outside the project root");
+
+await rm(output, { recursive: true, force: true });
+await mkdir(output);
+await Promise.all(files.map((file) => copyFile(join(root, file), join(output, file))));
+
+console.log(`Built ${files.length} static files in dist/`);
diff --git a/scripts/serve.mjs b/scripts/serve.mjs
new file mode 100644
index 0000000..7cb8562
--- /dev/null
+++ b/scripts/serve.mjs
@@ -0,0 +1,33 @@
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { createServer } from "node:http";
+import { dirname, extname, join, normalize } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const port = Number(process.env.PORT) || 4173;
+const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" };
+
+const server = createServer(async (request, response) => {
+ const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
+ const relative = pathname === "/" ? "index.html" : pathname.slice(1);
+ const filePath = normalize(join(root, relative));
+
+ if (!filePath.startsWith(root)) {
+ response.writeHead(403).end("Forbidden");
+ return;
+ }
+
+ try {
+ const details = await stat(filePath);
+ if (!details.isFile()) throw new Error("Not a file");
+ response.writeHead(200, { "Content-Type": `${types[extname(filePath)] || "application/octet-stream"}; charset=utf-8`, "Cache-Control": "no-store" });
+ createReadStream(filePath).pipe(response);
+ } catch {
+ response.writeHead(404).end("Not found");
+ }
+});
+
+server.listen(port, "127.0.0.1", () => {
+ console.log(`Flowchart Creator: http://127.0.0.1:${port}`);
+});
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..155c5d7
--- /dev/null
+++ b/style.css
@@ -0,0 +1,251 @@
+:root {
+ color-scheme: light;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #252a31;
+ background: #f5f6f8;
+ font-synthesis: none;
+ --accent: #2563eb;
+ --accent-soft: #eff6ff;
+ --border: #d9dde3;
+ --muted: #68717d;
+ --panel: #ffffff;
+ --toolbar-height: 50px;
+}
+
+* { box-sizing: border-box; }
+
+html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
+
+button, input, select, textarea { font: inherit; }
+
+button {
+ min-height: 28px;
+ padding: 4px 8px;
+ color: #343a43;
+ background: #fff;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+button:hover:not(:disabled) { background: #f2f4f7; border-color: #d9dde3; }
+button:active:not(:disabled) { background: #e8ebef; }
+button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
+ outline: 2px solid #93b4ff;
+ outline-offset: 1px;
+}
+button:disabled { color: #a9afb8; cursor: default; background: transparent; }
+button.active { color: #164db8; background: var(--accent-soft); border-color: #bcd0fb; }
+
+#app {
+ display: grid;
+ grid-template-columns: 164px minmax(0, 1fr) auto;
+ grid-template-rows: var(--toolbar-height) minmax(0, 1fr) 25px;
+ width: 100vw;
+ height: 100vh;
+}
+
+.toolbar {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+ padding: 6px 8px;
+ background: #fff;
+ border-bottom: 1px solid var(--border);
+ z-index: 5;
+}
+
+.document-title {
+ width: 170px;
+ min-width: 110px;
+ height: 30px;
+ padding: 4px 7px;
+ font-size: 13px;
+ font-weight: 600;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ text-overflow: ellipsis;
+}
+.document-title:hover, .document-title:focus { border-color: var(--border); }
+.toolbar-separator { width: 1px; height: 24px; margin: 0 2px; background: var(--border); }
+.toolbar-spacer { flex: 1; min-width: 4px; }
+.tool-group { display: flex; align-items: center; gap: 1px; }
+.toolbar button { font-size: 12px; }
+.zoom-group button { min-width: 28px; }
+#zoom-display { width: 48px; padding-inline: 3px; }
+
+.shape-panel, .properties-panel {
+ min-height: 0;
+ background: var(--panel);
+ overflow: auto;
+ z-index: 3;
+}
+.shape-panel { grid-column: 1; border-right: 1px solid var(--border); }
+.properties-panel { grid-column: 3; width: 228px; border-left: 1px solid var(--border); }
+
+.panel-heading {
+ position: sticky;
+ top: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 36px;
+ padding: 0 10px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ background: rgba(255,255,255,.96);
+ border-bottom: 1px solid #eceef1;
+ z-index: 2;
+}
+.snap-toggle { display: flex; align-items: center; gap: 4px; font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--muted); cursor: pointer; }
+.snap-toggle input { margin: 0; accent-color: var(--accent); }
+
+.shape-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; padding: 7px; }
+.shape-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ min-width: 0;
+ height: 66px;
+ padding: 5px 2px;
+ color: #4a515b;
+ background: #fff;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ cursor: grab;
+ user-select: none;
+}
+.shape-item:hover { background: #f7f9fc; border-color: #dce2ea; }
+.shape-item:active { cursor: grabbing; }
+.shape-item svg { width: 42px; height: 30px; overflow: visible; pointer-events: none; }
+.shape-item span { width: 100%; overflow: hidden; font-size: 10px; line-height: 1.1; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
+
+.canvas-shell {
+ position: relative;
+ grid-column: 2;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: #eef0f3;
+ outline: none;
+}
+.canvas-shell[data-mode="select"] { cursor: default; }
+.canvas-shell[data-mode="connect"] { cursor: crosshair; }
+.canvas-shell[data-mode="text"] { cursor: text; }
+.canvas-shell.panning { cursor: grabbing; }
+#canvas { display: block; width: 100%; height: 100%; user-select: none; touch-action: none; }
+.workspace-bg { fill: #fff; stroke: #cfd4dc; stroke-width: 1; }
+.grid { fill: url(#small-grid); pointer-events: none; }
+.node { cursor: move; }
+.node text, .edge-label { cursor: text; pointer-events: auto; }
+.node-shape { vector-effect: non-scaling-stroke; }
+.edge-path { fill: none; cursor: pointer; vector-effect: non-scaling-stroke; }
+.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; vector-effect: non-scaling-stroke; }
+.edge-label-bg { fill: #fff; opacity: .92; }
+.selection-outline { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; }
+.resize-handle, .endpoint-handle { fill: #fff; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
+.resize-handle { cursor: nwse-resize; }
+.resize-handle[data-handle="n"], .resize-handle[data-handle="s"] { cursor: ns-resize; }
+.resize-handle[data-handle="e"], .resize-handle[data-handle="w"] { cursor: ew-resize; }
+.resize-handle[data-handle="ne"], .resize-handle[data-handle="sw"] { cursor: nesw-resize; }
+.port { fill: #fff; stroke: var(--accent); stroke-width: 1.5; cursor: crosshair; vector-effect: non-scaling-stroke; }
+.port:hover { fill: var(--accent); }
+.marquee { fill: rgba(37,99,235,.08); stroke: var(--accent); stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; }
+.empty-hint { fill: #89919c; font-size: 14px; pointer-events: none; }
+.inline-editor {
+ position: fixed;
+ display: none;
+ min-width: 56px;
+ min-height: 32px;
+ padding: 3px 5px;
+ color: #242932;
+ background: rgba(255,255,255,.96);
+ border: 1px solid var(--accent);
+ border-radius: 3px;
+ outline: none;
+ resize: none;
+ overflow: hidden;
+ line-height: 1.25;
+ text-align: center;
+ z-index: 20;
+}
+
+.properties-content { padding: 9px; font-size: 12px; }
+.property-section { padding-bottom: 10px; margin-bottom: 10px; border-bottom: 1px solid #eceef1; }
+.property-section:last-child { border-bottom: 0; margin-bottom: 0; }
+.property-section-title { margin-bottom: 7px; color: #737b87; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
+.property-row { display: grid; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 7px; min-height: 30px; }
+.property-row > label { color: #5c6470; }
+.property-row input[type="number"], .property-row input[type="text"], .property-row select {
+ width: 100%;
+ height: 26px;
+ min-width: 0;
+ padding: 2px 5px;
+ color: #303640;
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+}
+.property-row input[type="color"] { width: 34px; height: 25px; padding: 1px; border: 1px solid var(--border); border-radius: 3px; background: #fff; }
+.property-actions, .segment { display: flex; gap: 3px; flex-wrap: wrap; }
+.property-actions button, .segment button { flex: 1; min-width: 28px; border-color: var(--border); font-size: 11px; }
+.segment button.active { border-color: #bcd0fb; }
+.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+.mini-field label { display: block; margin-bottom: 3px; color: #737b87; font-size: 10px; }
+.mini-field input { width: 100%; height: 26px; padding: 2px 5px; border: 1px solid var(--border); border-radius: 3px; }
+
+.statusbar {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ min-width: 0;
+ padding: 0 9px;
+ color: #6d7580;
+ background: #fff;
+ border-top: 1px solid var(--border);
+ font-size: 10px;
+ z-index: 5;
+}
+#status-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+#selection-status { margin-left: auto; }
+
+@media (max-width: 1060px) {
+ .file-actions button { padding-inline: 5px; }
+ .toolbar-separator { display: none; }
+ .document-title { width: 140px; }
+}
+
+@media (max-width: 840px) {
+ :root { --toolbar-height: 84px; }
+ #app { grid-template-columns: 1fr; grid-template-rows: var(--toolbar-height) 82px minmax(0, 1fr) 25px; }
+ .toolbar { align-content: center; flex-wrap: wrap; overflow: hidden; }
+ .document-title { width: 155px; }
+ .toolbar-spacer { display: none; }
+ .shape-panel { grid-column: 1; grid-row: 2; border-right: 0; border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; }
+ .shape-panel .panel-heading { display: none; }
+ .shape-list { display: flex; width: max-content; height: 81px; padding: 6px; }
+ .shape-item { width: 70px; height: 68px; }
+ .canvas-shell { grid-column: 1; grid-row: 3; }
+ .properties-panel { position: absolute; right: 8px; top: 94px; bottom: 33px; width: min(228px, calc(100vw - 16px)); border: 1px solid var(--border); border-radius: 5px; box-shadow: 0 5px 20px rgba(22,30,43,.12); }
+ .statusbar { grid-row: 4; }
+}
+
+@media (max-width: 540px) {
+ :root { --toolbar-height: 116px; }
+ .toolbar { align-items: flex-start; padding: 5px; }
+ .toolbar button { font-size: 11px; }
+ .document-title { flex: 1; min-width: 140px; }
+ .file-actions { width: 100%; overflow-x: auto; }
+ .properties-panel { top: 123px; }
+ #canvas-size-status { display: none; }
+}
+
+@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs
new file mode 100644
index 0000000..3bebcf0
--- /dev/null
+++ b/tests/browser-smoke.mjs
@@ -0,0 +1,266 @@
+import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const browserCandidates = process.platform === "win32"
+ ? [
+ process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Google/Chrome/Application/chrome.exe"),
+ process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Microsoft/Edge/Application/msedge.exe")
+ ]
+ : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
+const browserPath = browserCandidates.find((candidate) => candidate && existsSync(candidate));
+
+if (!browserPath) {
+ console.error("Browser smoke test requires Chrome, Edge, or Chromium.");
+ process.exit(1);
+}
+
+function freePort() {
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address();
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+async function waitForJson(url, attempts = 80) {
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) return response.json();
+ } catch {}
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+class DevToolsClient {
+ constructor(url) {
+ this.url = url;
+ this.sequence = 0;
+ this.pending = new Map();
+ this.listeners = new Map();
+ this.errors = [];
+ }
+
+ async connect() {
+ this.socket = new WebSocket(this.url);
+ this.socket.addEventListener("message", ({ data }) => this.onMessage(JSON.parse(data)));
+ await new Promise((resolve, reject) => {
+ this.socket.addEventListener("open", resolve, { once: true });
+ this.socket.addEventListener("error", reject, { once: true });
+ });
+ }
+
+ onMessage(message) {
+ if (message.id) {
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ message.error ? pending.reject(new Error(message.error.message)) : pending.resolve(message.result);
+ return;
+ }
+ if (message.method === "Runtime.exceptionThrown") this.errors.push(message.params.exceptionDetails.text);
+ if (message.method === "Log.entryAdded" && message.params.entry.level === "error") this.errors.push(message.params.entry.text);
+ const listeners = this.listeners.get(message.method) || [];
+ listeners.splice(0).forEach((resolve) => resolve(message.params));
+ }
+
+ send(method, params = {}) {
+ const id = ++this.sequence;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.socket.send(JSON.stringify({ id, method, params }));
+ });
+ }
+
+ wait(method, timeout = 10000) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeout);
+ const listeners = this.listeners.get(method) || [];
+ listeners.push((params) => { clearTimeout(timer); resolve(params); });
+ this.listeners.set(method, listeners);
+ });
+ }
+
+ async evaluate(expression) {
+ const result = await this.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
+ return result.result.value;
+ }
+
+ close() { this.socket.close(); }
+}
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+const serverPort = await freePort();
+const debugPort = await freePort();
+const profile = mkdtempSync(join(tmpdir(), "flowchart-browser-"));
+const server = spawn(process.execPath, [join(root, "scripts/serve.mjs")], { cwd: root, env: { ...process.env, PORT: String(serverPort) }, stdio: "ignore" });
+const browser = spawn(browserPath, [
+ "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--no-sandbox",
+ `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, "about:blank"
+], { stdio: "ignore" });
+
+let client;
+try {
+ const targets = await waitForJson(`http://127.0.0.1:${debugPort}/json/list`);
+ const page = targets.find((target) => target.type === "page");
+ assert(page?.webSocketDebuggerUrl, "Browser page target was not available");
+ client = new DevToolsClient(page.webSocketDebuggerUrl);
+ await client.connect();
+ await Promise.all([client.send("Page.enable"), client.send("Runtime.enable"), client.send("Log.enable")]);
+ const loaded = client.wait("Page.loadEventFired");
+ await client.send("Page.navigate", { url: `http://127.0.0.1:${serverPort}` });
+ await loaded;
+
+ const initial = await client.evaluate(`({
+ title: document.title,
+ textLength: document.body.innerText.trim().length,
+ shapes: document.querySelectorAll('.shape-item').length,
+ nodes: document.querySelectorAll('#node-layer .node').length,
+ edges: document.querySelectorAll('#edge-layer .edge').length,
+ overlay: Boolean(document.querySelector('[data-nextjs-dialog], .vite-error-overlay, #webpack-dev-server-client-overlay'))
+ })`);
+ assert(initial.title === "Flowchart Creator", "Document title is incorrect");
+ assert(initial.textLength > 50, "Page content is unexpectedly blank");
+ assert(initial.shapes === 12, "Shape palette did not render all symbols");
+ assert(initial.nodes === 8 && initial.edges === 7, "Starter diagram did not render correctly");
+ assert(!initial.overlay, "An error overlay is visible");
+
+ const connectionResult = await client.evaluate(`(() => {
+ document.querySelector('[data-mode="connect"]').click();
+ const svg = document.querySelector('#canvas');
+ svg.setPointerCapture = () => {};
+ const nodes = [...document.querySelectorAll('#node-layer .node')];
+ const center = (element) => { const box = element.getBoundingClientRect(); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; };
+ const fromElement = document.querySelector('[data-port-node="' + nodes[0].dataset.nodeId + '"][data-port="bottom"]');
+ const toElement = document.querySelector('[data-port-node="' + nodes[1].dataset.nodeId + '"][data-port="top"]');
+ const from = center(fromElement);
+ const to = center(toElement);
+ fromElement.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: from.x, clientY: from.y, button: 0, pointerId: 1 }));
+ svg.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: to.x, clientY: to.y, button: 0, pointerId: 1 }));
+ svg.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: to.x, clientY: to.y, button: 0, pointerId: 1 }));
+ return { edges: document.querySelectorAll('#edge-layer .edge').length, status: document.querySelector('#status-text').textContent };
+ })()`);
+ assert(connectionResult.edges === 8, `Connector creation failed: ${JSON.stringify(connectionResult)} ${client.errors.join("; ")}`);
+ await client.evaluate("document.querySelector('[data-action=\"undo\"]').click()");
+ assert(await client.evaluate("document.querySelectorAll('#edge-layer .edge').length") === 7, "Connector undo failed");
+
+ const afterAdd = await client.evaluate(`(() => {
+ document.querySelector('[data-shape="decision"]').click();
+ return { nodes: document.querySelectorAll('#node-layer .node').length, panel: !document.querySelector('#properties-panel').hidden };
+ })()`);
+ assert(afterAdd.nodes === 9 && afterAdd.panel, "Adding a shape did not update the editor");
+
+ const edited = await client.evaluate(`(() => {
+ document.querySelector('#node-layer .node:last-child').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
+ const editor = document.querySelector('#inline-editor');
+ const opened = getComputedStyle(editor).display !== 'none';
+ editor.value = 'Approved?';
+ editor.dispatchEvent(new FocusEvent('blur'));
+ return { opened, text: document.querySelector('#node-layer .node:last-child text').textContent };
+ })()`);
+ assert(edited.opened && edited.text.includes("Approved?"), `Inline text editing failed: ${JSON.stringify(edited)}`);
+
+ const geometryBefore = await client.evaluate(`(() => {
+ const node = document.querySelector('#node-layer .node:last-child').getBoundingClientRect();
+ const zoom = document.querySelector('#zoom-display').textContent;
+ return { x: node.x, y: node.y, width: node.width, height: node.height, zoom };
+ })()`);
+ await client.evaluate(`(() => {
+ const svg = document.querySelector('#canvas');
+ const box = document.querySelector('#node-layer .node:last-child').getBoundingClientRect();
+ svg.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaY: -120, clientX: box.x + box.width / 2, clientY: box.y + box.height / 2 }));
+ const movedBox = document.querySelector('#node-layer .node:last-child').getBoundingClientRect();
+ const start = { x: movedBox.x + movedBox.width / 2, y: movedBox.y + movedBox.height / 2 };
+ const node = document.querySelector('#node-layer .node:last-child');
+ node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: start.x, clientY: start.y, button: 0, pointerId: 2 }));
+ svg.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: start.x + 35, clientY: start.y + 25, button: 0, pointerId: 2 }));
+ svg.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: start.x + 35, clientY: start.y + 25, button: 0, pointerId: 2 }));
+ })()`);
+ const geometryMoved = await client.evaluate(`(() => {
+ const node = document.querySelector('#node-layer .node:last-child').getBoundingClientRect();
+ return { x: node.x, y: node.y, zoom: document.querySelector('#zoom-display').textContent };
+ })()`);
+ assert(geometryMoved.zoom !== geometryBefore.zoom, "Ctrl+wheel zoom failed");
+ assert(geometryMoved.x > geometryBefore.x + 15 && geometryMoved.y > geometryBefore.y + 10, "Dragging after zoom failed");
+
+ const resizeStart = await client.evaluate(`(() => {
+ const svg = document.querySelector('#canvas');
+ const handle = document.querySelector('[data-handle="se"]');
+ const box = handle.getBoundingClientRect();
+ const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+ const width = document.querySelector('#node-layer .node:last-child').getBoundingClientRect().width;
+ handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: start.x, clientY: start.y, button: 0, pointerId: 3 }));
+ svg.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: start.x + 40, clientY: start.y + 30, button: 0, pointerId: 3 }));
+ svg.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: start.x + 40, clientY: start.y + 30, button: 0, pointerId: 3 }));
+ return { ...start, width };
+ })()`);
+ const resizedWidth = await client.evaluate("document.querySelector('#node-layer .node:last-child').getBoundingClientRect().width");
+ assert(resizedWidth > resizeStart.width + 15, "Resize handles failed after zoom");
+
+ const history = await client.evaluate(`(() => {
+ document.querySelector('[data-action="duplicate"]').click();
+ const duplicated = document.querySelectorAll('#node-layer .node').length;
+ document.querySelector('[data-action="undo"]').click();
+ const undone = document.querySelectorAll('#node-layer .node').length;
+ document.querySelector('[data-action="redo"]').click();
+ const redone = document.querySelectorAll('#node-layer .node').length;
+ document.querySelector('[data-action="clear"]').click();
+ const cleared = document.querySelectorAll('#node-layer .node').length;
+ const hint = document.querySelector('.empty-hint')?.textContent;
+ document.querySelector('[data-action="undo"]').click();
+ return { duplicated, undone, redone, cleared, restored: document.querySelectorAll('#node-layer .node').length, hint };
+ })()`);
+ assert(history.duplicated === 10 && history.undone === 9 && history.redone === 10, "Duplicate or history controls failed");
+ assert(history.cleared === 0 && history.restored === 10 && history.hint?.includes("Add a shape"), "Clear or restore behavior failed");
+
+ const persistence = await client.evaluate(`(() => {
+ document.querySelector('[data-action="save"]').click();
+ document.querySelector('[data-action="clear"]').click();
+ const cleared = document.querySelectorAll('#node-layer .node').length;
+ document.querySelector('[data-action="load"]').click();
+ return { cleared, loaded: document.querySelectorAll('#node-layer .node').length };
+ })()`);
+ assert(persistence.cleared === 0 && persistence.loaded === 10, "Local save and load failed");
+
+ const edgeLabel = await client.evaluate(`(() => {
+ const edge = document.querySelector('#edge-layer .edge');
+ edge.querySelector('.edge-hit').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 4 }));
+ const input = document.querySelector('[data-edge-prop="label"]');
+ input.value = 'Approved path';
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ return document.querySelector('#edge-layer .edge .edge-label')?.textContent;
+ })()`);
+ assert(edgeLabel === "Approved path", "Connector label editing failed");
+
+ await client.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: profile });
+ await client.evaluate(`(() => { document.querySelector('[data-action="export-svg"]').click(); document.querySelector('[data-action="export-png"]').click(); })()`);
+ await new Promise((resolve) => setTimeout(resolve, 800));
+ const downloads = readdirSync(profile);
+ const svgDownload = downloads.find((name) => name.endsWith(".svg"));
+ assert(svgDownload, "SVG export did not download");
+ assert(downloads.some((name) => name.endsWith(".png")), "PNG export did not download");
+ const exportedSvg = readFileSync(join(profile, svgDownload), "utf8");
+ assert(exportedSvg.includes("Approved path") && exportedSvg.includes('fill="#ffffff"'), "SVG export did not preserve connector label styling");
+ assert(!exportedSvg.includes("edge-hit"), "SVG export included editor-only hit targets");
+ assert(client.errors.length === 0, `Browser errors: ${client.errors.join("; ")}`);
+
+ console.log("Browser smoke test passed: rendering, connectors, zoom, drag, resize, text, history, persistence, and export.");
+} finally {
+ client?.close();
+ browser.kill();
+ server.kill();
+ setTimeout(() => rmSync(profile, { recursive: true, force: true }), 250).unref();
+}