diff --git a/README.md b/README.md index f410fe0..167ce1f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,10 @@ Everything on the board is a node: https://github.com/user-attachments/assets/ad5de9f4-6f44-43a2-b59a-5279232d7f60 +## Canvas engine + +The board is built on [canvas-harness](https://github.com/winlp4ever/canvas-harness), a canvas-rendered node-graph library we maintain separately. Boards can hold thousands of nodes and still pan, zoom, and edit smoothly — comparable to tldraw and Excalidraw, and on par with hosted tools like Miro or FigJam. + ## Agent layer Built on the OpenAI Agents SDK, with board-aware tools wired in: diff --git a/backend/topix/datatypes/property.py b/backend/topix/datatypes/property.py index 42a4574..5ffbe9e 100644 --- a/backend/topix/datatypes/property.py +++ b/backend/topix/datatypes/property.py @@ -174,6 +174,15 @@ class Position(BaseModel): type: Literal[PropertyType.POSITION] = PropertyType.POSITION position: Position | None = None + # When the position belongs to an edge endpoint AND the link's + # source/target resolves to an attached node, callers may interpret + # `position` as a node-local offset (relative to the node's + # top-left, pre-rotation) instead of an absolute world coordinate. + # Default `False` keeps the legacy world-coord interpretation for + # existing rows so no data migration is needed. Newer clients set + # this to True when saving attached endpoints so edges that move + # with their node don't require cascading updates. + is_local_offset: bool = False class SizeProperty(Property): diff --git a/backend/topix/store/graph.py b/backend/topix/store/graph.py index 07f1008..56a6548 100644 --- a/backend/topix/store/graph.py +++ b/backend/topix/store/graph.py @@ -12,6 +12,7 @@ MatchValue, ) +from topix.datatypes.file.document import Document from topix.datatypes.graph.graph import Graph from topix.datatypes.note.link import Link from topix.datatypes.note.note import Note @@ -122,7 +123,14 @@ def _deep_merge_dict(base: dict, patch: dict) -> dict: return merged async def patch_note(self, node_id: str, data: dict, user_uid: str | None = None) -> Note | None: - """Patch a note by merging the update into the full stored note payload.""" + """Patch a note by merging the update into the full stored note payload. + + Validates the merged payload against the matching pydantic model so + documents (subclass of Note with `type: Literal["document"]`) keep + their type discriminator and document-specific properties intact — + otherwise validating a document row against the bare `Note` model + fails with a literal_error on the `type` field. + """ existing_nodes = await self.get_nodes([node_id]) if not existing_nodes: return None @@ -135,7 +143,8 @@ async def patch_note(self, node_id: str, data: dict, user_uid: str | None = None data, ) merged_payload["id"] = node_id - merged_note = Note.model_validate(merged_payload) + model = Document if isinstance(existing_note, Document) else Note + merged_note = model.model_validate(merged_payload) await self._content_store.update([merged_note.model_dump(exclude_none=False)]) return merged_note diff --git a/webui/package-lock.json b/webui/package-lock.json index 86e0e73..e132b43 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,14 +1,16 @@ { "name": "webui", - "version": "0.3.10", + "version": "0.3.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webui", - "version": "0.3.10", + "version": "0.3.13", "dependencies": { "@base-ui/react": "^1.4.1", + "@canvas-harness/core": "^0.1.2", + "@canvas-harness/react": "^0.1.2", "@dagrejs/dagre": "^1.1.5", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -111,13 +113,15 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^29.1.1", "rollup-plugin-visualizer": "^6.0.5", "tw-animate-css": "^1.3.5", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "unified": "^11.0.5", "vite": "^7.0.6", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^4.1.7" } }, "node_modules/@ant-design/colors": { @@ -236,6 +240,57 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1847,6 +1902,43 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@canvas-harness/core": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@canvas-harness/core/-/core-0.1.2.tgz", + "integrity": "sha512-QpTb8JODKh+z9eAaZQutK2ZSHFGhU/2K8rgtkTLfeSix1ASbaSiHNjoiPDsImWTijgvp86tdNPqWQzBFbvwlVw==", + "license": "MIT", + "dependencies": { + "perfect-freehand": "^1.2.3", + "roughjs": "^4.6.6", + "signia": "^0.1.5" + } + }, + "node_modules/@canvas-harness/react": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@canvas-harness/react/-/react-0.1.2.tgz", + "integrity": "sha512-9eCLUG48LOmALWwi7/J2x2sQT1KEGD9QyXjHI+EAN971ACVdFIQtY9KwBeWSOTSE+o//1r/UJuOpM8ncZLwwzw==", + "license": "MIT", + "dependencies": { + "@canvas-harness/core": "0.1.2" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", @@ -2294,6 +2386,146 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dagrejs/dagre": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", @@ -3123,6 +3355,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -6386,9 +6636,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -8014,6 +8264,17 @@ "yjs": "^13.5.38" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -8276,6 +8537,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -8776,6 +9044,126 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vue/compiler-core": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", @@ -9204,6 +9592,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -9353,6 +9751,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -9522,6 +9930,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -9878,8 +10296,22 @@ "node": ">=8" } }, - "node_modules/csstype": { - "version": "3.2.3", + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" @@ -10392,6 +10824,58 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -10469,6 +10953,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -10840,6 +11331,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -11269,6 +11767,16 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -12280,6 +12788,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-to-image": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", @@ -12769,6 +13290,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13062,6 +13590,121 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14122,6 +14765,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -15182,6 +15832,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-change": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/on-change/-/on-change-4.0.2.tgz", @@ -17646,6 +18307,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -17966,6 +18640,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -17979,6 +18660,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signia": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/signia/-/signia-0.1.5.tgz", + "integrity": "sha512-ViJpywl7H1W6zRfqbu+86xpSCcuq6tpOen7I+gR8axaiyZP8txRNAoeCsL20UkuqzG/Ybtk0u4C2lawkiwPlnw==", + "license": "MIT" + }, "node_modules/simple-icons": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.14.0.tgz", @@ -18138,6 +18825,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -18424,6 +19125,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -18549,6 +19257,13 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -18574,6 +19289,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tiptap-markdown": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", @@ -18630,6 +19355,19 @@ "license": "MIT", "peer": true }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -18878,6 +19616,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -19445,6 +20193,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -19521,6 +20359,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -19538,6 +20389,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -19654,6 +20515,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -20065,6 +20943,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y-protocols": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", diff --git a/webui/package.json b/webui/package.json index 350220f..005bfc4 100644 --- a/webui/package.json +++ b/webui/package.json @@ -11,10 +11,14 @@ "tauri-dev": "npx tauri dev", "tauri-build": "npx tauri build", "type-check": "tsc -p tsconfig.app.json --noEmit", + "test": "vitest", + "test:run": "vitest run", "check-all": "npm run type-check && npm run lint" }, "dependencies": { "@base-ui/react": "^1.4.1", + "@canvas-harness/core": "^0.1.2", + "@canvas-harness/react": "^0.1.2", "@dagrejs/dagre": "^1.1.5", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -117,12 +121,14 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^29.1.1", "rollup-plugin-visualizer": "^6.0.5", "tw-animate-css": "^1.3.5", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "unified": "^11.0.5", "vite": "^7.0.6", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^4.1.7" } } diff --git a/webui/src/components/markdown/expand-page-blocks.ts b/webui/src/components/markdown/expand-page-blocks.ts new file mode 100644 index 0000000..af092e7 --- /dev/null +++ b/webui/src/components/markdown/expand-page-blocks.ts @@ -0,0 +1,50 @@ +/** + * Subpage directive blocks have this shape (see TipTap's Subpage + * extension at `editor/tiptap/page/subpage-extension.ts`): + * + * :::page + * Title snapshot + * ::: + * + * Streamdown / remark doesn't know about `:::name` directives, so on + * its own it'd render the literal `:::page abc` text. This helper + * collapses each block into a regular markdown link with a `page://` + * scheme — `MarkdownLink` then renders it as a PageRefChip, matching + * the inline `@page` reference style. + * + * We render block-level subpages as inline chips for v1. Sufficient + * for read-only previews where the visual distinction is minor; an + * upgrade path (remark-directive + block chip) is documented in the + * harness migration notes. + */ + +// Tolerant pattern: matches both the canonical multi-line form +// :::page +// title (one or more lines) +// ::: +// AND the collapsed single-line form +// :::page title ::: +// (which can happen when markdown gets joined / unwrapped somewhere +// upstream). Whitespace between segments is any combination of +// spaces / tabs / newlines. +const SUBPAGE_BLOCK_RE = /:::page[ \t]+(\S+)\s+([\s\S]*?)\s*:::/g + + +/** + * Escape characters that have meaning inside a markdown link label. + * `]` and `\` close / escape the bracket pair; everything else is + * literal text inside `[…]`. + */ +const escapeLinkText = (s: string): string => + s.replace(/\\/g, "\\\\").replace(/\]/g, "\\]") + + +export const expandPageBlocks = (markdown: string): string => { + if (!markdown.includes(":::page")) return markdown + return markdown.replace(SUBPAGE_BLOCK_RE, (_match, id: string, titleBody: string) => { + // Title may span multiple lines in the block — flatten + trim so + // the inline chip reads cleanly. Fallback matches the TipTap snap. + const title = titleBody.replace(/\s+/g, " ").trim() || "Untitled" + return `[${escapeLinkText(title)}](page://${id})` + }) +} diff --git a/webui/src/components/markdown/markdown-link.tsx b/webui/src/components/markdown/markdown-link.tsx index de4cb99..0aa00d9 100644 --- a/webui/src/components/markdown/markdown-link.tsx +++ b/webui/src/components/markdown/markdown-link.tsx @@ -2,20 +2,49 @@ import React from "react" import { useNavigate } from "@tanstack/react-router" import { LinkIcon } from "@/components/icons" import { cn } from "@/lib/utils" +import { PageRefChip } from "./page-ref-chip" const boardLinkRe = /^\/boards\/([^/]+)\/([^/]+)\/([^/]+)$/ +const PAGE_HREF_PREFIX = "page://" type MarkdownLinkProps = React.AnchorHTMLAttributes & { children?: React.ReactNode } +/** + * Pull a usable string title out of the children React node tree. + * Markdown links can wrap their text in formatting (em, strong); we + * just want the visible label for the page-ref chip. + */ +const textOf = (node: React.ReactNode): string => { + if (typeof node === "string") return node + if (typeof node === "number") return String(node) + if (Array.isArray(node)) return node.map(textOf).join("") + if (React.isValidElement(node)) { + const props = node.props as { children?: React.ReactNode } + return textOf(props.children) + } + return "" +} + /** * Markdown link renderer that routes internal board URLs through the router. - * External links keep default browser behavior. + * `page://` URLs render as a PageRefChip (TipTap interop — see + * `editor/tiptap/page/page-ref-extension.ts`). External links keep + * default browser behavior. */ export function MarkdownLink({ children, href, ...rest }: MarkdownLinkProps) { const navigate = useNavigate() + // Page-ref short-circuit: don't render an at all. The chip + // carries the title from the markdown's link text and behaves as a + // self-contained inline element. v1 has no click target — adding + // navigate() requires a host PageProvider that knows where pages live. + if (href?.startsWith(PAGE_HREF_PREFIX)) { + const title = textOf(children).trim() || "Untitled" + return + } + const content = Array.isArray(children) ? children[0] : children const label = typeof content === "string" ? content.replace(/^[[]|[\]]$/g, "") : "source" diff --git a/webui/src/components/markdown/markdown-view.tsx b/webui/src/components/markdown/markdown-view.tsx index 617525e..75e7d93 100644 --- a/webui/src/components/markdown/markdown-view.tsx +++ b/webui/src/components/markdown/markdown-view.tsx @@ -7,6 +7,7 @@ import { Pre } from "./custom-pre" import { MarkdownLink } from "./markdown-link" import { Streamdown } from "streamdown" import { codePlugin } from "./streamdown-code-plugin" +import { expandPageBlocks } from "./expand-page-blocks" import { sanitizeMathDelimiters } from "./sanitize-math" import { useTheme } from "@/components/theme-provider" import { type ShikiThemePair } from "@/components/theme-constants" @@ -216,7 +217,12 @@ const Renderer: React.FC<{ isStreaming?: boolean shikiThemes: ShikiThemePair }> = ({ content, isStreaming, shikiThemes }) => { - const normalized = normalizeMathDelimiters(sanitizeMathDelimiters(content)) + // Collapse TipTap subpage directive blocks into inline page-ref + // links before the rest of the pipeline runs — remark doesn't know + // about `:::page` blocks, so without this they'd surface as raw + // text. MarkdownLink renders the resulting `page://` URLs as chips. + const expanded = expandPageBlocks(content) + const normalized = normalizeMathDelimiters(sanitizeMathDelimiters(expanded)) return (
diff --git a/webui/src/components/markdown/page-ref-chip.tsx b/webui/src/components/markdown/page-ref-chip.tsx new file mode 100644 index 0000000..140e8c0 --- /dev/null +++ b/webui/src/components/markdown/page-ref-chip.tsx @@ -0,0 +1,38 @@ +import { Notepad } from "@phosphor-icons/react" +import { cn } from "@/lib/utils" + + +export type PageRefChipProps = { + /** Title snapshot from the markdown — what the chip displays. */ + title: string + /** Optional click handler. Read-only contexts (no provider) pass nothing. */ + onClick?: () => void +} + + +/** + * Read-only page reference chip used by MarkdownView. Mirrors the + * TipTap PageRefView visual (`@ + notepad + title`) but doesn't + * resolve the live title — the snapshot in the markdown is the + * source of truth. The TipTap editor re-emits the link with a fresh + * title whenever the user edits, so cards stay roughly in sync. + */ +export function PageRefChip({ title, onClick }: PageRefChipProps) { + const interactive = typeof onClick === "function" + return ( + + + + {title} + + ) +} diff --git a/webui/src/components/rough/circ.tsx b/webui/src/components/rough/circ.tsx deleted file mode 100644 index 4042d9c..0000000 --- a/webui/src/components/rough/circ.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import React, { memo, useCallback, useEffect, useRef } from 'react' -import { RoughCanvas } from 'roughjs/bin/canvas' -import clsx from 'clsx' -import type { StrokeStyle } from '@/features/board/types/style' -import { getCachedCanvas, serializeCacheKey } from './cache' -import { FillLayer } from './fill-layer' -import { resolveEdgeRender } from './derived-edge' -import { useTheme } from '@/components/theme-provider' -import { - useEffectiveZoom, - useMotionState, -} from '@/features/board/components/flow/motion-state-context' - -type RoughShapeProps = { - children?: React.ReactNode - roughness?: number - stroke?: string - strokeStyle?: StrokeStyle // 'solid' | 'dashed' | 'dotted' - strokeWidth?: number - fill?: string - className?: string - seed?: number - widthPx?: number - heightPx?: number -} - -type DrawConfig = { - cssW: number - cssH: number - zoom: number - roughness: number - stroke: string - strokeStyle: StrokeStyle - strokeWidth: number - seed: number - dpr: number - renderScale: number -} - -type SimplifiedCircleOverlayProps = { - edgeColor: string - edgeWidth: number - edgeStyle: StrokeStyle - fillInset: number - widthPx?: number - heightPx?: number -} - -const SimplifiedCircleOverlay = memo(function SimplifiedCircleOverlay({ - edgeColor, - edgeWidth, - edgeStyle, - fillInset, - widthPx, - heightPx -}: SimplifiedCircleOverlayProps) { - const hasStroke = edgeWidth > 0 - const useSvgDash = hasStroke && (edgeStyle === 'dashed' || edgeStyle === 'dotted') - const { strokeLineDash, lineCap } = mapStrokeStyle(edgeStyle, edgeWidth) - const dashArray = strokeLineDash ? strokeLineDash.join(' ') : undefined - const viewBoxWidth = Math.max(1, widthPx ?? 1) - const viewBoxHeight = Math.max(1, heightPx ?? 1) - const rx = Math.max(0, viewBoxWidth / 2 - fillInset) - const ry = Math.max(0, viewBoxHeight / 2 - fillInset) - - return ( - - - - ) -}) - -const drawConfigEqual = (a: DrawConfig | null, b: DrawConfig) => { - if (!a) return false - return ( - a.cssW === b.cssW && - a.cssH === b.cssH && - a.zoom === b.zoom && - a.roughness === b.roughness && - a.stroke === b.stroke && - a.strokeStyle === b.strokeStyle && - a.strokeWidth === b.strokeWidth && - a.seed === b.seed && - a.dpr === b.dpr && - a.renderScale === b.renderScale - ) -} - -const oversampleForZoom = (value: number): number => { - if (!Number.isFinite(value)) return 1 - if (value >= 1) { - return Math.min(1.5, 1 + (value - 1) * 0.5) - } - return Math.max(0.1, value) -} -const MAX_RENDER_WIDTH = 1600 -const MAX_RENDER_HEIGHT = 900 -const RENDER_SCALE_FACTOR = 0.75 - -type DetailSettings = { - curveStepCount: number - maxRandomnessOffset: number - hachureGap: number -} - -const detailForSize = (maxSide: number): DetailSettings => { - if (maxSide >= 800) return { curveStepCount: 3, maxRandomnessOffset: 0.9, hachureGap: 9 } - if (maxSide >= 400) return { curveStepCount: 4, maxRandomnessOffset: 1.1, hachureGap: 7 } - return { curveStepCount: 5, maxRandomnessOffset: 1.3, hachureGap: 5 } -} - -/** Same helper you already use elsewhere */ -function mapStrokeStyle( - strokeStyle: StrokeStyle | undefined, - strokeWidth: number | undefined -): { strokeLineDash?: number[], lineCap?: CanvasLineCap } { - const sw = Math.max(0.5, strokeWidth ?? 1) - switch (strokeStyle) { - case 'dashed': - return { strokeLineDash: [5.5 * sw, 4 * sw], lineCap: 'round' } - case 'dotted': - return { strokeLineDash: [0, 3 * sw], lineCap: 'round' } - case 'solid': - default: - return { strokeLineDash: undefined, lineCap: 'butt' } - } -} - -/* ========================= - ELLIPSE — inscribed - ========================= */ -export const RoughCircle: React.FC = ({ - children, - roughness = 1.2, - stroke = 'transparent', - strokeStyle = 'solid', - strokeWidth = 1, - fill, - className, - seed = 1337, - widthPx, - heightPx -}) => { - const wrapperRef = useRef(null) - const canvasRef = useRef(null) - const lastConfigRef = useRef(null) - const rafRef = useRef(null) - const effectiveZoom = useEffectiveZoom() - const { isMoving, isResizingNode: isResizing } = useMotionState() - const isSimplified = isMoving && !isResizing - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const draw = useCallback((wrapper: HTMLDivElement, canvas: HTMLCanvasElement) => { - const rect = wrapper.getBoundingClientRect() - const cssW = Math.max(1, (widthPx ?? wrapper.clientWidth) || Math.floor(rect.width)) - const cssH = Math.max(1, (heightPx ?? wrapper.clientHeight) || Math.floor(rect.height)) - if (cssW === 0 || cssH === 0) return - - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1 - const oversample = oversampleForZoom(effectiveZoom) - - const edge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const effectiveStrokeWidth = edge.width - const effectiveStrokeStyle = edge.style - const effectiveRoughness = edge.roughness - // bleed so jitter/stroke won't clip - // add a tiny extra margin to avoid clipping at seams - const bleed = Math.ceil(effectiveStrokeWidth / 2 + effectiveRoughness * 1.5 + 3) - - const paddedWidth = cssW + bleed * 2 - const paddedHeight = cssH + bleed * 2 - const baseScale = dpr * oversample * RENDER_SCALE_FACTOR - const limiter = Math.min( - 1, - MAX_RENDER_WIDTH / (paddedWidth * baseScale), - MAX_RENDER_HEIGHT / (paddedHeight * baseScale) - ) - const renderScale = baseScale * limiter - - const pixelW = Math.floor(paddedWidth * renderScale) - const pixelH = Math.floor(paddedHeight * renderScale) - if (canvas.width !== pixelW) canvas.width = pixelW - if (canvas.height !== pixelH) canvas.height = pixelH - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const config: DrawConfig = { - cssW, - cssH, - zoom: effectiveZoom, - roughness: effectiveRoughness, - stroke: edge.color, - strokeStyle: effectiveStrokeStyle, - strokeWidth: effectiveStrokeWidth, - seed, - dpr, - renderScale - } - - if (drawConfigEqual(lastConfigRef.current, config)) { - return - } - - const visibleStroke = edge.color - - const innerW = cssW - const innerH = cssH - const cx = innerW / 2 - const cy = innerH / 2 - const ellipseW = innerW - const ellipseH = innerH - - const { strokeLineDash, lineCap } = mapStrokeStyle(effectiveStrokeStyle, effectiveStrokeWidth) - const apparentSize = Math.max(cssW, cssH) * Math.min(1, effectiveZoom) - const { curveStepCount, maxRandomnessOffset } = detailForSize(apparentSize) - - const cacheKey = serializeCacheKey([ - 'ellipse', - effectiveRoughness, - visibleStroke, - effectiveStrokeStyle, - effectiveStrokeWidth, - seed, - effectiveZoom, - renderScale, - cssW, - cssH, - ]) - - const offscreen = getCachedCanvas(cacheKey, pixelW, pixelH, target => { - const offCtx = target.getContext('2d') - if (!offCtx) return - - offCtx.setTransform(1, 0, 0, 1, 0, 0) - offCtx.clearRect(0, 0, target.width, target.height) - offCtx.setTransform(renderScale, 0, 0, renderScale, 0, 0) - const strokeInset = effectiveStrokeWidth / 2 - offCtx.translate(bleed - strokeInset, bleed - strokeInset) - - const rc = new RoughCanvas(target) - const drawable = rc.generator.ellipse(cx, cy, ellipseW, ellipseH, { - roughness: effectiveRoughness, - stroke: visibleStroke, - strokeWidth: effectiveStrokeWidth, - bowing: 2, - curveStepCount, - maxRandomnessOffset, - seed: seed || 1337, - strokeLineDash, - strokeLineDashOffset: 0, - dashOffset: 8, - dashGap: 16, - disableMultiStroke: true, - preserveVertices: true, - }) - - offCtx.save() - if (lineCap) offCtx.lineCap = lineCap - offCtx.lineJoin = 'round' - rc.draw(drawable) - offCtx.restore() - }) - - if (canvas.width !== offscreen.width) canvas.width = offscreen.width - if (canvas.height !== offscreen.height) canvas.height = offscreen.height - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const ctx = canvas.getContext('2d') - if (!ctx) return - - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(offscreen, 0, 0) - - lastConfigRef.current = config - }, [roughness, stroke, strokeWidth, fill, isDark, effectiveZoom, seed, strokeStyle, widthPx, heightPx]) - - const scheduleRedraw = useCallback(() => { - if (isMoving) return - if (rafRef.current !== null) return - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null - const wrapper = wrapperRef.current - const canvas = canvasRef.current - if (wrapper && canvas) { - draw(wrapper, canvas) - } - }) - }, [draw, isMoving]) - - useEffect(() => { - if (isSimplified) { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - return - } - lastConfigRef.current = null - scheduleRedraw() - }, [isSimplified, scheduleRedraw, widthPx, heightPx]) - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - const mainDivClass = clsx('relative', className || '') - const renderEdge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const fillInset = 0.5 + renderEdge.width / 2 - - return ( -
- - - {isSimplified && ( - - )} -
- {children} -
-
- ) -} diff --git a/webui/src/components/rough/diam.tsx b/webui/src/components/rough/diam.tsx deleted file mode 100644 index 4db812b..0000000 --- a/webui/src/components/rough/diam.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import React, { memo, useCallback, useEffect, useRef } from 'react' -import { RoughCanvas } from 'roughjs/bin/canvas' -import clsx from 'clsx' -import type { StrokeStyle } from '@/features/board/types/style' -import { getCachedCanvas, serializeCacheKey } from './cache' -import { roundedDiamondPath, sharpDiamondPath } from './paths' -import { FillLayer } from './fill-layer' -import { resolveEdgeRender } from './derived-edge' -import { useTheme } from '@/components/theme-provider' -import { - useEffectiveZoom, - useMotionState, -} from '@/features/board/components/flow/motion-state-context' - -type RoundedClass = 'none' | 'rounded-2xl' - -type RoughShapeProps = { - children?: React.ReactNode - roughness?: number - stroke?: string - strokeStyle?: StrokeStyle // 'solid' | 'dashed' | 'dotted' - strokeWidth?: number - fill?: string - className?: string - seed?: number - widthPx?: number - heightPx?: number -} - -type RoughDiamondProps = RoughShapeProps & { - rounded?: RoundedClass -} - -type DrawConfig = { - cssW: number - cssH: number - zoom: number - rounded: RoundedClass - roughness: number - stroke: string - strokeStyle: StrokeStyle - strokeWidth: number - seed: number - dpr: number - renderScale: number -} - -type SimplifiedDiamondOverlayProps = { - rounded: RoundedClass - edgeColor: string - edgeWidth: number - edgeStyle: StrokeStyle - fillInset: number - widthPx: number - heightPx: number -} - -const SimplifiedDiamondOverlay = memo(function SimplifiedDiamondOverlay({ - rounded, - edgeColor, - edgeWidth, - edgeStyle, - fillInset, - widthPx, - heightPx, -}: SimplifiedDiamondOverlayProps) { - const { strokeLineDash, lineCap } = mapStrokeStyle(edgeStyle, edgeWidth) - const dashArray = strokeLineDash ? strokeLineDash.join(' ') : undefined - const viewW = Math.max(1, widthPx) - const viewH = Math.max(1, heightPx) - const x0 = fillInset - const y0 = fillInset - const x1 = viewW - fillInset - const y1 = viewH - fillInset - const baseRadius = rounded === 'rounded-2xl' ? 16 : 0 - const pathData = - baseRadius > 0 - ? roundedDiamondPath(x0, y0, x1, y1, baseRadius) - : sharpDiamondPath(x0, y0, x1, y1) - - return ( - - 0 ? edgeColor : 'transparent'} - strokeWidth={edgeWidth} - strokeDasharray={dashArray} - strokeLinecap={lineCap} - strokeLinejoin="round" - vectorEffect="non-scaling-stroke" - /> - - ) -}) - -const drawConfigEqual = (a: DrawConfig | null, b: DrawConfig) => { - if (!a) return false - return ( - a.cssW === b.cssW && - a.cssH === b.cssH && - a.zoom === b.zoom && - a.rounded === b.rounded && - a.roughness === b.roughness && - a.stroke === b.stroke && - a.strokeStyle === b.strokeStyle && - a.strokeWidth === b.strokeWidth && - a.seed === b.seed && - a.dpr === b.dpr && - a.renderScale === b.renderScale - ) -} - -const oversampleForZoom = (value: number): number => { - if (!Number.isFinite(value)) return 1 - if (value >= 1) { - return Math.min(1.5, 1 + (value - 1) * 0.5) - } - return Math.max(0.1, value) -} -const MAX_RENDER_WIDTH = 1600 -const MAX_RENDER_HEIGHT = 900 -const RENDER_SCALE_FACTOR = 0.75 - -type DetailSettings = { - curveStepCount: number - maxRandomnessOffset: number - hachureGap: number -} - -const detailForSize = (maxSide: number): DetailSettings => { - if (maxSide >= 800) return { curveStepCount: 3, maxRandomnessOffset: 0.9, hachureGap: 9 } - if (maxSide >= 400) return { curveStepCount: 4, maxRandomnessOffset: 1.1, hachureGap: 7 } - return { curveStepCount: 5, maxRandomnessOffset: 1.3, hachureGap: 5 } -} - -/** Map logical stroke style to dash pattern + desired canvas lineCap (set on ctx). */ -function mapStrokeStyle( - strokeStyle: StrokeStyle | undefined, - strokeWidth: number | undefined -): { strokeLineDash?: number[], lineCap?: CanvasLineCap } { - const sw = Math.max(0.5, strokeWidth ?? 1) - switch (strokeStyle) { - case 'dashed': - return { strokeLineDash: [5.5 * sw, 4 * sw], lineCap: 'round' } - case 'dotted': - return { strokeLineDash: [0, 3 * sw], lineCap: 'round' } // round caps → dots - case 'solid': - default: - return { strokeLineDash: undefined, lineCap: 'butt' } - } -} - -/* ========================= - DIAMOND — inscribed, with rounded option - ========================= */ -export const RoughDiamond: React.FC = ({ - children, - rounded = 'none', - roughness = 1.2, - stroke = 'transparent', - strokeStyle = 'solid', - strokeWidth = 1, - fill, - className, - seed = 1337, - widthPx, - heightPx -}) => { - const wrapperRef = useRef(null) - const canvasRef = useRef(null) - const lastConfigRef = useRef(null) - const rafRef = useRef(null) - const effectiveZoom = useEffectiveZoom() - const { isMoving, isResizingNode: isResizing } = useMotionState() - const resolvedWidth = Math.max(1, Math.floor(widthPx ?? 1)) - const resolvedHeight = Math.max(1, Math.floor(heightPx ?? 1)) - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const draw = useCallback((wrapper: HTMLDivElement, canvas: HTMLCanvasElement) => { - if (isMoving && !isResizing) return - const rect = wrapper.getBoundingClientRect() - const cssW = Math.max(1, (widthPx ?? wrapper.clientWidth) || Math.floor(rect.width)) - const cssH = Math.max(1, (heightPx ?? wrapper.clientHeight) || Math.floor(rect.height)) - if (cssW === 0 || cssH === 0) return - - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1 - const oversample = oversampleForZoom(effectiveZoom) - - const edge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const effectiveStrokeWidth = edge.width - const effectiveStrokeStyle = edge.style - const effectiveRoughness = edge.roughness - - // bleed for stroke + jitter - const bleed = Math.ceil(effectiveStrokeWidth / 2 + effectiveRoughness * 1.5 + 2) - - const paddedWidth = cssW + bleed * 2 - const paddedHeight = cssH + bleed * 2 - const baseScale = dpr * oversample * RENDER_SCALE_FACTOR - const limiter = Math.min( - 1, - MAX_RENDER_WIDTH / (paddedWidth * baseScale), - MAX_RENDER_HEIGHT / (paddedHeight * baseScale) - ) - const renderScale = baseScale * limiter - - const pixelW = Math.floor(paddedWidth * renderScale) - const pixelH = Math.floor(paddedHeight * renderScale) - if (canvas.width !== pixelW) canvas.width = pixelW - if (canvas.height !== pixelH) canvas.height = pixelH - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const config: DrawConfig = { - cssW, - cssH, - zoom: effectiveZoom, - rounded, - roughness: effectiveRoughness, - stroke: edge.color, - strokeStyle: effectiveStrokeStyle, - strokeWidth: effectiveStrokeWidth, - seed, - dpr, - renderScale - } - - if (drawConfigEqual(lastConfigRef.current, config)) { - return - } - - const visibleStroke = edge.color - - const inset = effectiveStrokeWidth <= 1.5 ? Math.min(0.5, cssW / 4, cssH / 4) : 0 - const x0 = inset - const y0 = inset - const x1 = inset + Math.max(0, cssW - inset * 2) - const y1 = inset + Math.max(0, cssH - inset * 2) - - const baseRadius = rounded === 'rounded-2xl' ? 16 : 0 - const pathData = - baseRadius > 0 - ? roundedDiamondPath(x0, y0, x1, y1, baseRadius) - : sharpDiamondPath(x0, y0, x1, y1) - - const { strokeLineDash, lineCap } = mapStrokeStyle(effectiveStrokeStyle, effectiveStrokeWidth) - const apparentSize = Math.max(cssW, cssH) * Math.min(1, effectiveZoom) - const { curveStepCount, maxRandomnessOffset } = detailForSize(apparentSize) - - const cacheKey = serializeCacheKey([ - 'diamond', - rounded, - effectiveRoughness, - visibleStroke, - effectiveStrokeStyle, - effectiveStrokeWidth, - seed, - effectiveZoom, - renderScale, - cssW, - cssH, - ]) - - const offscreen = getCachedCanvas(cacheKey, pixelW, pixelH, target => { - const offCtx = target.getContext('2d') - if (!offCtx) return - - offCtx.setTransform(1, 0, 0, 1, 0, 0) - offCtx.clearRect(0, 0, target.width, target.height) - offCtx.setTransform(renderScale, 0, 0, renderScale, 0, 0) - offCtx.translate(bleed, bleed) - - const rc = new RoughCanvas(target) - const drawable = rc.generator.path(pathData, { - roughness: effectiveRoughness, - stroke: visibleStroke, - strokeWidth: effectiveStrokeWidth, - bowing: 2, - curveStepCount, - maxRandomnessOffset, - seed: seed || 1337, - strokeLineDash, - strokeLineDashOffset: 0, - dashOffset: 8, - dashGap: 16, - disableMultiStroke: true, - preserveVertices: true, - }) - - offCtx.save() - if (lineCap) offCtx.lineCap = lineCap - offCtx.lineJoin = 'round' - rc.draw(drawable) - offCtx.restore() - }) - - if (canvas.width !== offscreen.width) canvas.width = offscreen.width - if (canvas.height !== offscreen.height) canvas.height = offscreen.height - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const ctx = canvas.getContext('2d') - if (!ctx) return - - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(offscreen, 0, 0) - - lastConfigRef.current = config - }, [rounded, roughness, stroke, strokeWidth, fill, isDark, effectiveZoom, seed, strokeStyle, isMoving, isResizing, widthPx, heightPx]) - - const scheduleRedraw = useCallback(() => { - if (rafRef.current !== null) return - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null - const wrapper = wrapperRef.current - const canvas = canvasRef.current - if (wrapper && canvas) { - draw(wrapper, canvas) - } - }) - }, [draw]) - - const isSimplified = isMoving && !isResizing - const mainDivClass = clsx('relative', className || '') - - useEffect(() => { - if (!isSimplified) { - lastConfigRef.current = null - scheduleRedraw() - } - }, [isSimplified, scheduleRedraw, widthPx, heightPx]) - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - const fillKind = rounded === 'rounded-2xl' ? 'diamond-rounded' : 'diamond-sharp' - const renderEdge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const fillInset = renderEdge.width <= 1.5 ? 0.5 + renderEdge.width / 2 : renderEdge.width / 2 - - return ( -
- - {isSimplified && ( - - )} - -
- {children} -
-
- ) -} diff --git a/webui/src/components/rough/rect.tsx b/webui/src/components/rough/rect.tsx deleted file mode 100644 index 014143d..0000000 --- a/webui/src/components/rough/rect.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import React, { memo, useCallback, useEffect, useRef } from 'react' -import { RoughCanvas } from 'roughjs/bin/canvas' -import clsx from 'clsx' -import type { StrokeStyle } from '@/features/board/types/style' -import { getCachedCanvas, serializeCacheKey } from './cache' -import { excalidrawRoundedRectPath, rectPath } from './paths' -import { FillLayer } from './fill-layer' -import { resolveEdgeRender } from './derived-edge' -import { useTheme } from '@/components/theme-provider' -import { - useEffectiveZoom, - useMotionState, -} from '@/features/board/components/flow/motion-state-context' - -type RoundedClass = 'none' | 'rounded-2xl' - -type RoughRectProps = { - children?: React.ReactNode - rounded?: RoundedClass - roughness?: number - stroke?: string - strokeStyle?: StrokeStyle // 'solid' | 'dashed' | 'dotted' - strokeWidth?: number - fill?: string - className?: string - seed?: number - widthPx?: number - heightPx?: number -} - -type DrawConfig = { - cssW: number - cssH: number - zoom: number - rounded: RoundedClass - roughness: number - stroke: string - strokeStyle: StrokeStyle - strokeWidth: number - seed: number - dpr: number - renderScale: number -} - -type SimplifiedRectOverlayProps = { - rounded: RoundedClass - edgeColor: string - edgeWidth: number - edgeStyle: StrokeStyle - fillInset: number - widthPx?: number - heightPx?: number -} - -const SimplifiedRectOverlay = memo(function SimplifiedRectOverlay({ - rounded, - edgeColor, - edgeWidth, - edgeStyle, - fillInset, - widthPx, - heightPx -}: SimplifiedRectOverlayProps) { - const { strokeLineDash, lineCap } = mapStrokeStyle(edgeStyle, edgeWidth) - const dashArray = strokeLineDash ? strokeLineDash.join(' ') : undefined - const svgWidth = Math.max(1, widthPx ?? 1) - const svgHeight = Math.max(1, heightPx ?? 1) - const rectWidth = Math.max(0, svgWidth - fillInset * 2) - const rectHeight = Math.max(0, svgHeight - fillInset * 2) - const radius = rounded === 'rounded-2xl' ? 16 : 0 - const cornerRadius = Math.max(0, Math.min(radius, rectWidth / 2, rectHeight / 2)) - - return ( - - 0 ? edgeColor : 'transparent'} - strokeWidth={edgeWidth} - strokeDasharray={dashArray} - strokeLinecap={lineCap} - vectorEffect='non-scaling-stroke' - /> - - ) -}) - -const drawConfigEqual = (a: DrawConfig | null, b: DrawConfig) => { - if (!a) return false - return ( - a.cssW === b.cssW && - a.cssH === b.cssH && - a.zoom === b.zoom && - a.rounded === b.rounded && - a.roughness === b.roughness && - a.stroke === b.stroke && - a.strokeStyle === b.strokeStyle && - a.strokeWidth === b.strokeWidth && - a.seed === b.seed && - a.dpr === b.dpr && - a.renderScale === b.renderScale - ) -} - -const oversampleForZoom = (value: number): number => { - if (!Number.isFinite(value)) return 1 - if (value >= 1) { - return Math.min(1.5, 1 + (value - 1) * 0.5) - } - return Math.max(0.1, value) -} -const MAX_RENDER_WIDTH = 1600 -const MAX_RENDER_HEIGHT = 900 -const RENDER_SCALE_FACTOR = 0.75 - -type DetailSettings = { - curveStepCount: number - maxRandomnessOffset: number - hachureGap: number -} - -const detailForSize = (maxSide: number): DetailSettings => { - if (maxSide >= 800) return { curveStepCount: 3, maxRandomnessOffset: 0.9, hachureGap: 9 } - if (maxSide >= 400) return { curveStepCount: 4, maxRandomnessOffset: 1.1, hachureGap: 7 } - return { curveStepCount: 5, maxRandomnessOffset: 1.3, hachureGap: 5 } -} - -/** Map logical stroke style to dash pattern and (optionally) desired canvas lineCap. */ -function mapStrokeStyle( - strokeStyle: StrokeStyle | undefined, - strokeWidth: number | undefined -): { - strokeLineDash?: number[] - lineCap?: CanvasLineCap -} { - const sw = Math.max(0.5, strokeWidth ?? 1) - - switch (strokeStyle) { - case 'dashed': - return { - strokeLineDash: [5.5 * sw, 4 * sw], - lineCap: 'round' - } - case 'dotted': - // Round caps + [0, gap] yields pleasant dots - return { - strokeLineDash: [0, 3 * sw], - lineCap: 'round' - } - case 'solid': - default: - return { - strokeLineDash: undefined, - lineCap: 'butt' - } - } -} - -/** - * RoughCanvas-based rectangle component. - */ -export const RoughRect: React.FC = ({ - children, - rounded = 'none', - roughness = 1.2, - stroke = 'transparent', - strokeStyle = 'solid', - strokeWidth = 1, - fill, - className, - seed = 1337, - widthPx, - heightPx -}) => { - const wrapperRef = useRef(null) - const canvasRef = useRef(null) - const lastConfigRef = useRef(null) - const rafRef = useRef(null) - const effectiveZoom = useEffectiveZoom() - const { isMoving, isResizingNode: isResizing } = useMotionState() - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const draw = useCallback((wrapper: HTMLDivElement, canvas: HTMLCanvasElement) => { - const rect = wrapper.getBoundingClientRect() - const cssW = Math.max(1, (widthPx ?? wrapper.clientWidth) || Math.floor(rect.width)) - const cssH = Math.max(1, (heightPx ?? wrapper.clientHeight) || Math.floor(rect.height)) - if (cssW === 0 || cssH === 0) return - - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1 - - // oversample backing store for zoom-in, clamped to avoid runaway buffers - const oversample = oversampleForZoom(effectiveZoom) - - const edge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const effectiveStrokeWidth = edge.width - const effectiveStrokeStyle = edge.style - const effectiveRoughness = edge.roughness - // add a bleed in CSS units (display px), enough for stroke + jitter - const bleed = Math.ceil(effectiveStrokeWidth / 2 + effectiveRoughness * 1.5 + 2) - - const paddedWidth = cssW + bleed * 2 - const paddedHeight = cssH + bleed * 2 - const baseScale = dpr * oversample * RENDER_SCALE_FACTOR - const limiter = Math.min( - 1, - MAX_RENDER_WIDTH / (paddedWidth * baseScale), - MAX_RENDER_HEIGHT / (paddedHeight * baseScale) - ) - const renderScale = baseScale * limiter - - const pixelW = Math.floor(paddedWidth * renderScale) - const pixelH = Math.floor(paddedHeight * renderScale) - if (canvas.width !== pixelW) canvas.width = pixelW - if (canvas.height !== pixelH) canvas.height = pixelH - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const config: DrawConfig = { - cssW, - cssH, - zoom: effectiveZoom, - rounded, - roughness: effectiveRoughness, - stroke: edge.color, - strokeStyle: effectiveStrokeStyle, - strokeWidth: effectiveStrokeWidth, - seed, - dpr, - renderScale - } - - if (drawConfigEqual(lastConfigRef.current, config)) { - return - } - - const visibleStroke = edge.color - - // hairline crispness without eating tiny boxes; include half stroke so outer edge aligns - const insetBase = Math.min(0.5, cssW / 4, cssH / 4) - const inset = insetBase + effectiveStrokeWidth / 2 - const w = Math.max(0, cssW - inset * 2) - const h = Math.max(0, cssH - inset * 2) - - const baseRadius = rounded === 'rounded-2xl' ? 16 : 0 - const radius = Math.max(0, Math.min(baseRadius, w / 2, h / 2)) - - const pathData = radius > 0 - ? excalidrawRoundedRectPath(inset, inset, w, h, radius) - : rectPath(inset, inset, w, h) - - const { strokeLineDash, lineCap } = mapStrokeStyle(effectiveStrokeStyle, effectiveStrokeWidth) - const apparentSize = Math.max(cssW, cssH) * Math.min(1, effectiveZoom) - const { curveStepCount, maxRandomnessOffset } = detailForSize(apparentSize) - - const cacheKey = serializeCacheKey([ - 'rect', - rounded, - effectiveRoughness, - visibleStroke, - effectiveStrokeStyle, - effectiveStrokeWidth, - seed, - effectiveZoom, - renderScale, - cssW, - cssH, - ]) - - const offscreen = getCachedCanvas(cacheKey, pixelW, pixelH, target => { - const offCtx = target.getContext('2d') - if (!offCtx) return - - offCtx.setTransform(1, 0, 0, 1, 0, 0) - offCtx.clearRect(0, 0, target.width, target.height) - offCtx.setTransform(renderScale, 0, 0, renderScale, 0, 0) - offCtx.translate(bleed, bleed) - - const rc = new RoughCanvas(target) - const drawable = rc.generator.path(pathData, { - roughness: effectiveRoughness, - stroke: visibleStroke, - strokeWidth: effectiveStrokeWidth, - bowing: 2, - curveStepCount, - maxRandomnessOffset, - seed: seed || 1337, - strokeLineDash, - strokeLineDashOffset: 0, - dashOffset: 8, - dashGap: 16, - disableMultiStroke: true, - preserveVertices: true, - }) - - offCtx.save() - if (lineCap) offCtx.lineCap = lineCap - offCtx.lineJoin = 'round' - rc.draw(drawable) - offCtx.restore() - }) - - if (canvas.width !== offscreen.width) canvas.width = offscreen.width - if (canvas.height !== offscreen.height) canvas.height = offscreen.height - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const ctx = canvas.getContext('2d') - if (!ctx) return - - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(offscreen, 0, 0) - - lastConfigRef.current = config - }, [rounded, roughness, stroke, strokeWidth, fill, isDark, effectiveZoom, seed, strokeStyle, widthPx, heightPx]) - - const scheduleRedraw = useCallback(() => { - if (isMoving) return - if (rafRef.current !== null) return - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null - const wrapper = wrapperRef.current - const canvas = canvasRef.current - if (wrapper && canvas) { - draw(wrapper, canvas) - } - }) - }, [draw, isMoving]) - - const isSimplified = isMoving && !isResizing - - useEffect(() => { - if (isSimplified) { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - return - } - lastConfigRef.current = null - scheduleRedraw() - }, [isSimplified, scheduleRedraw, widthPx, heightPx]) - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - const mainDivClass = clsx('relative', className || '') - const fillKind = rounded === 'rounded-2xl' ? 'rect-rounded' : 'rect-sharp' - const renderEdge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const fillInset = 0.5 + renderEdge.width / 2 - - return ( -
- - - {isSimplified && ( - - )} -
- {children} -
-
- ) -} - diff --git a/webui/src/components/rough/tag.tsx b/webui/src/components/rough/tag.tsx deleted file mode 100644 index cbff0d5..0000000 --- a/webui/src/components/rough/tag.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import React, { memo, useCallback, useEffect, useRef } from 'react' -import { RoughCanvas } from 'roughjs/bin/canvas' -import clsx from 'clsx' -import type { StrokeStyle } from '@/features/board/types/style' -import { getCachedCanvas, serializeCacheKey } from './cache' -import { tagPath } from './paths' -import { FillLayer } from './fill-layer' -import { resolveEdgeRender } from './derived-edge' -import { useTheme } from '@/components/theme-provider' -import { - useEffectiveZoom, - useMotionState, -} from '@/features/board/components/flow/motion-state-context' - -type RoughShapeProps = { - children?: React.ReactNode - roughness?: number - stroke?: string - strokeStyle?: StrokeStyle // 'solid' | 'dashed' | 'dotted' - strokeWidth?: number - fill?: string - className?: string - seed?: number - widthPx?: number - heightPx?: number -} - -type DrawConfig = { - cssW: number - cssH: number - zoom: number - roughness: number - stroke: string - strokeStyle: StrokeStyle - strokeWidth: number - seed: number - dpr: number - renderScale: number -} - -type SimplifiedTagOverlayProps = { - edgeColor: string - edgeWidth: number - edgeStyle: StrokeStyle - fillInset: number - widthPx: number - heightPx: number -} - -const SimplifiedTagOverlay = memo(function SimplifiedTagOverlay({ - edgeColor, - edgeWidth, - edgeStyle, - fillInset, - widthPx, - heightPx -}: SimplifiedTagOverlayProps) { - const { strokeLineDash, lineCap } = mapStrokeStyle(edgeStyle, edgeWidth) - const dashArray = strokeLineDash ? strokeLineDash.join(' ') : undefined - const innerW = Math.max(1, widthPx - fillInset * 2) - const innerH = Math.max(1, heightPx - fillInset * 2) - const notch = Math.min(innerH * 0.45, innerW * 0.3) - const radius = Math.min(innerH / 2, innerW / 4, 18) - const pathData = tagPath(innerW, innerH, notch, radius) - - return ( - - 0 ? edgeColor : 'transparent'} - strokeWidth={edgeWidth} - strokeDasharray={dashArray} - strokeLinecap={lineCap} - strokeLinejoin="round" - vectorEffect="non-scaling-stroke" - /> - - ) -}) - -const drawConfigEqual = (a: DrawConfig | null, b: DrawConfig) => { - if (!a) return false - return ( - a.cssW === b.cssW && - a.cssH === b.cssH && - a.zoom === b.zoom && - a.roughness === b.roughness && - a.stroke === b.stroke && - a.strokeStyle === b.strokeStyle && - a.strokeWidth === b.strokeWidth && - a.seed === b.seed && - a.dpr === b.dpr && - a.renderScale === b.renderScale - ) -} - -const oversampleForZoom = (value: number): number => { - if (!Number.isFinite(value)) return 1 - if (value >= 1) { - return Math.min(1.5, 1 + (value - 1) * 0.5) - } - return Math.max(0.1, value) -} - -const MAX_RENDER_WIDTH = 1600 -const MAX_RENDER_HEIGHT = 900 -const RENDER_SCALE_FACTOR = 0.75 - -type DetailSettings = { - curveStepCount: number - maxRandomnessOffset: number - hachureGap: number -} - -const detailForSize = (maxSide: number): DetailSettings => { - if (maxSide >= 800) return { curveStepCount: 3, maxRandomnessOffset: 0.9, hachureGap: 9 } - if (maxSide >= 400) return { curveStepCount: 4, maxRandomnessOffset: 1.1, hachureGap: 7 } - return { curveStepCount: 5, maxRandomnessOffset: 1.3, hachureGap: 5 } -} - -/** Map logical stroke style to dash pattern + desired canvas lineCap (set on ctx). */ -function mapStrokeStyle( - strokeStyle: StrokeStyle | undefined, - strokeWidth: number | undefined -): { strokeLineDash?: number[], lineCap?: CanvasLineCap } { - const sw = Math.max(0.5, strokeWidth ?? 1) - switch (strokeStyle) { - case 'dashed': - return { strokeLineDash: [5.5 * sw, 4 * sw], lineCap: 'round' } - case 'dotted': - return { strokeLineDash: [0, 3 * sw], lineCap: 'round' } // round caps → dots - case 'solid': - default: - return { strokeLineDash: undefined, lineCap: 'butt' } - } -} - -export const RoughTag: React.FC = ({ - children, - roughness = 1.2, - stroke = 'transparent', - strokeStyle = 'solid', - strokeWidth = 1, - fill, - className, - seed = 1337, - widthPx, - heightPx -}) => { - const wrapperRef = useRef(null) - const canvasRef = useRef(null) - const lastConfigRef = useRef(null) - const rafRef = useRef(null) - const effectiveZoom = useEffectiveZoom() - const { isMoving, isResizingNode: isResizing } = useMotionState() - const resolvedWidth = Math.max(1, Math.floor(widthPx ?? 1)) - const resolvedHeight = Math.max(1, Math.floor(heightPx ?? 1)) - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const draw = useCallback((wrapper: HTMLDivElement, canvas: HTMLCanvasElement) => { - if (isMoving && !isResizing) return - const rect = wrapper.getBoundingClientRect() - const cssW = Math.max(1, (widthPx ?? wrapper.clientWidth) || Math.floor(rect.width)) - const cssH = Math.max(1, (heightPx ?? wrapper.clientHeight) || Math.floor(rect.height)) - if (cssW === 0 || cssH === 0) return - - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1 - const oversample = oversampleForZoom(effectiveZoom) - - const edge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - const effectiveStrokeWidth = edge.width - const effectiveStrokeStyle = edge.style - const effectiveRoughness = edge.roughness - - const bleed = Math.ceil(effectiveStrokeWidth / 2 + effectiveRoughness * 1.5 + 2) - const paddedWidth = cssW + bleed * 2 - const paddedHeight = cssH + bleed * 2 - const baseScale = dpr * oversample * RENDER_SCALE_FACTOR - const limiter = Math.min( - 1, - MAX_RENDER_WIDTH / (paddedWidth * baseScale), - MAX_RENDER_HEIGHT / (paddedHeight * baseScale) - ) - const renderScale = baseScale * limiter - - const pixelW = Math.floor(paddedWidth * renderScale) - const pixelH = Math.floor(paddedHeight * renderScale) - if (canvas.width !== pixelW) canvas.width = pixelW - if (canvas.height !== pixelH) canvas.height = pixelH - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const config: DrawConfig = { - cssW, - cssH, - zoom: effectiveZoom, - roughness: effectiveRoughness, - stroke: edge.color, - strokeStyle: effectiveStrokeStyle, - strokeWidth: effectiveStrokeWidth, - seed, - dpr, - renderScale - } - - if (drawConfigEqual(lastConfigRef.current, config)) { - return - } - - const visibleStroke = edge.color - const notch = Math.min(cssH * 0.45, cssW * 0.3) - const radius = Math.min(cssH / 2, cssW / 4, 18) - const pathData = tagPath(cssW, cssH, notch, radius) - - const { strokeLineDash, lineCap } = mapStrokeStyle(effectiveStrokeStyle, effectiveStrokeWidth) - const apparentSize = Math.max(cssW, cssH) * Math.min(1, effectiveZoom) - const { curveStepCount, maxRandomnessOffset } = detailForSize(apparentSize) - - const cacheKey = serializeCacheKey([ - 'tag', - effectiveRoughness, - visibleStroke, - effectiveStrokeStyle, - effectiveStrokeWidth, - seed, - effectiveZoom, - renderScale, - cssW, - cssH, - ]) - - const offscreen = getCachedCanvas(cacheKey, pixelW, pixelH, target => { - const offCtx = target.getContext('2d') - if (!offCtx) return - - offCtx.setTransform(1, 0, 0, 1, 0, 0) - offCtx.clearRect(0, 0, target.width, target.height) - offCtx.setTransform(renderScale, 0, 0, renderScale, 0, 0) - offCtx.translate(bleed, bleed) - - const rc = new RoughCanvas(target) - const drawable = rc.generator.path(pathData, { - roughness: effectiveRoughness, - stroke: visibleStroke, - strokeWidth: effectiveStrokeWidth, - bowing: 2, - curveStepCount, - maxRandomnessOffset, - seed: seed || 1337, - strokeLineDash, - strokeLineDashOffset: 0, - dashOffset: 8, - dashGap: 16, - disableMultiStroke: true, - preserveVertices: true, - }) - - offCtx.save() - if (lineCap) offCtx.lineCap = lineCap - offCtx.lineJoin = 'round' - rc.draw(drawable) - offCtx.restore() - }) - - if (canvas.width !== offscreen.width) canvas.width = offscreen.width - if (canvas.height !== offscreen.height) canvas.height = offscreen.height - - canvas.style.width = paddedWidth + 'px' - canvas.style.height = paddedHeight + 'px' - canvas.style.left = (-bleed) + 'px' - canvas.style.top = (-bleed) + 'px' - - const ctx = canvas.getContext('2d') - if (!ctx) return - - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(offscreen, 0, 0) - - lastConfigRef.current = config - }, [roughness, stroke, strokeWidth, fill, isDark, effectiveZoom, seed, strokeStyle, isMoving, isResizing, widthPx, heightPx]) - - const scheduleRedraw = useCallback(() => { - if (rafRef.current !== null) return - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null - const wrapper = wrapperRef.current - const canvas = canvasRef.current - if (wrapper && canvas) { - draw(wrapper, canvas) - } - }) - }, [draw]) - - const mainDivClass = clsx('relative', className || '') - const isSimplified = isMoving && !isResizing - const renderEdge = resolveEdgeRender(stroke, fill, isDark, strokeStyle, strokeWidth, roughness) - // Tag path runs to wrapper edges; fill stays at wrapper edges, stroke center at wrapper edge. - const fillInset = renderEdge.width / 2 - - useEffect(() => { - if (!isSimplified) { - lastConfigRef.current = null - scheduleRedraw() - } - }, [isSimplified, scheduleRedraw, widthPx, heightPx]) - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, []) - - return ( -
- - {isSimplified && ( - - )} - -
- {children} -
-
- ) -} diff --git a/webui/src/components/sidebar/sidebar-label.tsx b/webui/src/components/sidebar/sidebar-label.tsx index 686e855..4acbac3 100644 --- a/webui/src/components/sidebar/sidebar-label.tsx +++ b/webui/src/components/sidebar/sidebar-label.tsx @@ -9,11 +9,13 @@ import { useListSubscriptions } from "@/features/newsfeed/api/list-subscriptions import { NewChatUrl, SheetUrl } from "@/routes" import { ContextBoard } from "@/features/agent/components/context-board" import { UNTITLED_LABEL } from "@/features/board/const" -import { useGraphStore } from "@/features/board/store/graph-store" +import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" +import { useHarnessNodeExternal } from "@/features/board/harness/store/use-harness-node-external" import { useGetNote } from "@/features/board/api/get-note" import { useUpdateNote } from "@/features/board/api/update-note" -import { FolderBreadcrumb } from "@/features/board/components/flow/folder-breadcrumb" +import { FolderBreadcrumb } from "@/features/board/components/folder-breadcrumb" import { useAppStore } from "@/store" +import type { NoteNodeData } from "@/features/board/harness/convert/note-to-node" export const SidebarLabel = ({ mobileContextOnly = false }: { mobileContextOnly?: boolean }) => { const navigate = useNavigate() @@ -72,12 +74,18 @@ export const SidebarLabel = ({ mobileContextOnly = false }: { mobileContextOnly? const { data: subscriptionList } = useListSubscriptions() const { updateBoard } = useUpdateBoard() const { updateChat } = useUpdateChat() - const boardCanEdit = useGraphStore(state => state.boardCanEdit) - const boardLabel = useGraphStore(state => state.boardLabel) - const setBoardLabel = useGraphStore(state => state.setBoardLabel) - const sheetNode = useGraphStore(state => - sheetNoteId ? state.nodes.find(n => n.id === sheetNoteId) : undefined - ) + const boardCanEdit = useBoardAppStore(state => state.canEdit) + const boardLabel = useBoardAppStore(state => state.boardLabel) + const setBoardLabel = useBoardAppStore(state => state.setBoardLabel) + // Sheet title is read from the live harness store via a module-level + // store-ref bridge so the sidebar (rendered above the board tree) + // sees title edits in real time. Falls back to a one-shot GET when + // the node isn't on the current canvas (e.g. user landed directly on + // a sheet URL without opening the board first). + const sheetNode = useHarnessNodeExternal(sheetNoteId) + const sheetNodeLabel = sheetNode + ? (sheetNode.data as Partial | undefined)?.label?.markdown + : undefined const { data: fetchedSheet } = useGetNote({ boardId: sheetBoardId, noteId: sheetNoteId, @@ -108,7 +116,7 @@ export const SidebarLabel = ({ mobileContextOnly = false }: { mobileContextOnly? return } if (active.view === "sheet") { - const sheetLabel = sheetNode?.data?.label?.markdown ?? fetchedSheet?.label?.markdown + const sheetLabel = sheetNodeLabel ?? fetchedSheet?.label?.markdown setLabel(sheetLabel ?? "Sheet") return } @@ -121,7 +129,7 @@ export const SidebarLabel = ({ mobileContextOnly = false }: { mobileContextOnly? return } setLabel("") - }, [active.view, active.id, boardList, boardLabel, chatList, subscriptionList, sheetNode, fetchedSheet]) + }, [active.view, active.id, boardList, boardLabel, chatList, subscriptionList, sheetNodeLabel, fetchedSheet]) const handleSaveEdit = (newLabel: string) => { setLabel(newLabel) diff --git a/webui/src/features/agent/api/send-message.ts b/webui/src/features/agent/api/send-message.ts index ce20bd3..2637c89 100644 --- a/webui/src/features/agent/api/send-message.ts +++ b/webui/src/features/agent/api/send-message.ts @@ -11,10 +11,8 @@ import { buildResponse } from "../utils/stream/build" import { fetchWithAuthRaw } from "@/api" import type { ToolOutput } from "../types/tool-outputs" import { trimResponseAnnotations } from "../utils/annotations" -import { useGraphStore } from "@/features/board/store/graph-store" -import { getBoardLink, getBoardNote } from "@/features/board/api/get-board" -import { convertLinkToEdgeWithPoints, convertNoteToNode } from "@/features/board/utils/graph" -import type { LinkEdge, NoteNode } from "@/features/board/types/flow" +import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" +import { getAgentBridge } from "@/features/board/harness/agent/agent-bridge" import type { CreateNoteOutput, EditNoteOutput, @@ -254,81 +252,37 @@ export const useSendMessage = () => { const linkToolOutputs = collectLinkToolOutputs(reasoningSteps) if (noteToolOutputs.length > 0 || linkToolOutputs.length > 0) { - const { - boardId: activeBoardId, - setNodes, - setNodesPersist, - setEdges, - } = useGraphStore.getState() - - if (activeBoardId) { + const activeBoardId = useBoardAppStore.getState().boardId + // Apply outputs through the canvas-harness bridge: re-fetches + // each note/link from the server (canonical state), updates + // the React Query cache so any open sub-page panels see the + // edit, and writes a `remote`-origin batch into the harness + // store so the canvas reflects the change without triggering + // the debounced save loop. + const harnessBridge = getAgentBridge() + + if (activeBoardId && harnessBridge) { const createdNoteIds: string[] = [] for (const output of noteToolOutputs) { - if (output.graphUid !== activeBoardId || !output.noteId) continue - - const isNewlyCreated = output.type === "create_note" - || (output.type === "write_note" && output.action === "created") - - try { - const note = await getBoardNote(activeBoardId, output.noteId) - - // Only mutate the canvas when the note actually belongs - // to the current scope. A sub-page (parent is another - // note) or a note in a different folder shouldn't appear - // as a phantom canvas node on edit — the panel still - // sees the update via the React Query cache write below. - const currentRootId = useGraphStore.getState().rootId - const noteParentId = note.parentId - const belongsToCanvas = - (noteParentId ?? undefined) === (currentRootId ?? undefined) - - if (belongsToCanvas) { - const fetchedNode = convertNoteToNode(note) - setNodesPersist((prevNodes) => - applyRemoteNoteNode(prevNodes, fetchedNode, isNewlyCreated), - { persist: false }) - - if (isNewlyCreated) { - createdNoteIds.push(output.noteId) - } - } - - // Push the fresh note into the React Query cache so the - // sub-page panel (which reads via `useGetNote`) reflects - // the AI's edit immediately. We intentionally don't call - // `invalidateQueries` here: we already have the canonical - // server response in hand, and a same-key refetch can - // return stale data and clobber what we just set if the - // backend commit lands a moment late. - queryClient.setQueryData(["note", activeBoardId, output.noteId], note) - } catch (error) { - console.error("Failed to apply remote note update locally:", error) + const result = await harnessBridge.applyNoteOutput(output) + if (result?.created && result.onCanvas) { + createdNoteIds.push(result.noteId) } } - for (const output of linkToolOutputs) { - if (output.graphUid !== activeBoardId || !output.linkId) continue - - try { - const link = await getBoardLink(activeBoardId, output.linkId) - const nodesById = useGraphStore.getState().nodesById - const { edge, points } = convertLinkToEdgeWithPoints(link, nodesById) - if (points.length) { - setNodes((prevNodes) => [...prevNodes, ...points]) - } - setEdges((prevEdges) => applyRemoteEdge(prevEdges, edge)) - } catch (error) { - console.error("Failed to apply remote link update locally:", error) - } + await harnessBridge.applyLinkOutput(output) } - if (createdNoteIds.length > 0 && router.state.location.pathname.startsWith(`/boards/${activeBoardId}`)) { - const centerAround = createdNoteIds.join(",") + if ( + createdNoteIds.length > 0 + && router.state.location.pathname.startsWith(`/boards/${activeBoardId}`) + ) { + const centerIds = createdNoteIds.join(",") navigate({ to: "/boards/$id", params: { id: activeBoardId }, replace: true, - search: (prev: Record) => ({ ...prev, center_around: centerAround }), + search: (prev: Record) => ({ ...prev, center: centerIds }), }) } } @@ -421,61 +375,3 @@ const collectLinkToolOutputs = (steps: ReasoningStep[]): LinkNotesOutput[] => return [] }) -const applyRemoteEdge = (prevEdges: LinkEdge[], nextEdge: LinkEdge): LinkEdge[] => { - const existing = prevEdges.find((edge) => edge.id === nextEdge.id) - if (existing) { - return prevEdges.map((edge) => - edge.id === nextEdge.id - ? { ...nextEdge, selected: existing.selected, animated: existing.animated } - : edge, - ) - } - return [...prevEdges, nextEdge] -} - - -const applyRemoteNoteNode = ( - prevNodes: NoteNode[], - nextNode: NoteNode, - selectNode: boolean, -): NoteNode[] => { - const existingNode = prevNodes.find((node) => node.id === nextNode.id) - const baseNode = existingNode - ? { - ...nextNode, - selected: existingNode.selected, - dragging: existingNode.dragging, - measured: existingNode.measured ?? nextNode.measured, - } - : nextNode - - if (existingNode) { - return prevNodes.map((node) => - node.id === nextNode.id - ? { - ...baseNode, - selected: selectNode ? true : baseNode.selected, - } - : (selectNode ? { ...node, selected: false } : node) - ) - } - - const maxZ = prevNodes.reduce((acc, node) => { - const kind = (node.data as { kind?: string }).kind - const nodeType = (node.data as { style?: { type?: string } }).style?.type - if (kind === "point" || nodeType === "slide") return acc - return Math.max(acc, node.zIndex ?? 0) - }, 0) - - const appendedNode = { - ...baseNode, - selected: selectNode, - zIndex: baseNode.data.style?.type === "slide" ? -1000 : maxZ + 1, - } - - const clearedNodes = selectNode - ? prevNodes.map((node) => ({ ...node, selected: false })) - : prevNodes - - return [...clearedNodes, appendedNode] -} diff --git a/webui/src/features/agent/components/chat/actions/save-as-note.tsx b/webui/src/features/agent/components/chat/actions/save-as-note.tsx index 692da6c..4f92c0a 100644 --- a/webui/src/features/agent/components/chat/actions/save-as-note.tsx +++ b/webui/src/features/agent/components/chat/actions/save-as-note.tsx @@ -10,7 +10,7 @@ import { useConvertToMindMap } from "@/features/board/api/convert-to-mindmap" import { useCreateBoard } from "@/features/board/api/create-board" import { useListBoards } from "@/features/board/api/list-boards" import { UNTITLED_LABEL } from "@/features/board/const" -import { useGraphStore } from "@/features/board/store/graph-store" +import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" import { CancelStatusIcon, CheckCircleStatusIcon, @@ -72,7 +72,7 @@ export const SaveAsNote = ({ }: SaveAsNoteProps) => { const [processing, setProcessing] = useState(false) const userId = useAppStore(s => s.userId) - const rootId = useGraphStore(state => state.rootId) + const rootId = useBoardAppStore(state => state.rootId) ?? undefined const { convertToMindMapAsync } = useConvertToMindMap() const { data: boardList } = useListBoards(userId) diff --git a/webui/src/features/agent/components/chat/input.tsx b/webui/src/features/agent/components/chat/input.tsx index 3d8e780..7e613e7 100644 --- a/webui/src/features/agent/components/chat/input.tsx +++ b/webui/src/features/agent/components/chat/input.tsx @@ -8,7 +8,7 @@ import { useAppStore } from '@/store' import { SendButton } from './send-button' import TextareaAutosize from 'react-textarea-autosize' import { useChat } from '../../hooks/chat-context' -import { useGraphStore } from '@/features/board/store/graph-store' +import { useBoardAppStore } from '@/features/board/harness/store/board-app-store' import { useNavigate, useParams } from '@tanstack/react-router' import { SettingsBillingUrl } from '@/routes' import { WelcomeMessage } from './welcome-message' @@ -112,7 +112,7 @@ export const InputBar = ({ const showBoardChip = autoCreateBoard || Boolean(attachedBoardId) // Single boolean: re-renders only when the dialog opens or closes, // not on title/content changes. Cheap. - const hasActiveSurface = useGraphStore((s) => Boolean(s.activeNodeSurface)) + const hasActiveSurface = useBoardAppStore((s) => Boolean(s.activeNodeSurface)) const placeholder = showBoardLimitGate ? `You've used ${FREE_PLAN_BOARD_LIMIT}/${FREE_PLAN_BOARD_LIMIT} boards on the free plan` : autoCreateBoard diff --git a/webui/src/features/agent/hooks/use-message-context.ts b/webui/src/features/agent/hooks/use-message-context.ts index d849aec..178a106 100644 --- a/webui/src/features/agent/hooks/use-message-context.ts +++ b/webui/src/features/agent/hooks/use-message-context.ts @@ -1,5 +1,9 @@ +import { useSyncExternalStore } from "react" +import type { NodeId } from "@canvas-harness/core" import { useChatStore } from "../store/chat-store" -import { useGraphStore } from "@/features/board/store/graph-store" +import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" +import { getCanvasStoreRef } from "@/features/board/harness/canvas-store-ref" +import { nodeToNote } from "@/features/board/harness/convert/node-to-note" import { buildContextTextFromNodes } from "@/features/board/utils/context-text" import { queryClient } from "@/query-client" import type { Note } from "@/features/board/types/note" @@ -10,28 +14,54 @@ const MAX_MESSAGE_CONTEXT_CHARS = 12000 /** - * Read the active surface's note synchronously from wherever it lives — - * the canvas store for on-canvas surfaces, the React Query cache for - * sub-pages that never appear on the canvas. + * Subscribe to the harness selection without depending on + * `` being an ancestor. We use the module-level + * `getCanvasStoreRef` bridge because the floating-island composer + * lives as a sibling of `` (and thus its + * `CanvasProvider`), so `useSelection()` from `@canvas-harness/react` + * isn't reachable here. + * + * Returns `0` when no store is mounted (login screen / dashboard). + * Re-fires only when the lib's `'selection'` event channel emits — + * which excludes pan/zoom/drag/hover. */ -function readActiveSurfaceNote(noteId: string): Note | undefined { - const localData = useGraphStore.getState().nodesById.get(noteId)?.data as Note | undefined - if (localData) return localData - const boardId = useGraphStore.getState().boardId +const useHarnessSelectionLength = (): number => + useSyncExternalStore( + (cb) => { + const store = getCanvasStoreRef() + if (!store) return () => undefined + return store.subscribe("selection", cb) + }, + () => getCanvasStoreRef()?.getSelection().length ?? 0, + () => 0, + ) + + +/** + * Synchronously read a note from wherever it lives: + * - the active canvas-harness scene (covers on-canvas notes) + * - the React Query `["note", boardId, noteId]` cache (sub-pages + * reached via the editor's `/subpage` flow that aren't on the + * current canvas scope) + */ +const readNote = (noteId: string): Note | undefined => { + const store = getCanvasStoreRef() + if (store) { + const node = store.getNode(noteId as NodeId) + if (node) return nodeToNote(node) + } + const boardId = useBoardAppStore.getState().boardId if (!boardId) return undefined return queryClient.getQueryData(["note", boardId, noteId]) } /** - * Cheap reactive subscription that returns whether *any* selected non-point - * node currently exists. Used by composer UIs to render a "selection - * attached" indicator without iterating or allocating per-render. - * - * Selector returns a primitive boolean — zustand uses Object.is, no - * useShallow allocation. some() short-circuits on the first match, so the - * common case (something is selected) is effectively O(1) even on boards - * with hundreds or thousands of nodes. + * Reactive boolean — `true` when there's anything to attach to the + * next message. Composer UIs use this to render the "selection + * attached" chip. Re-renders only on selection / active-surface + * changes; pan / zoom / drag don't trigger it (canvas-harness's + * `'selection'` channel fires only on selection set changes). */ export const useHasMessageContext = ( { enabled = true }: { enabled?: boolean } = {}, @@ -39,41 +69,45 @@ export const useHasMessageContext = ( const enableMessageBoardContextSelection = useChatStore( (state) => state.enableMessageBoardContextSelection, ) - const hasSelection = useGraphStore((state) => - enabled - && state.nodes.some( - (node) => - node.selected - && (node.data as { kind?: string } | undefined)?.kind !== "point", - ), + // Subscribe to canvas-harness's `'selection'` channel via the + // module-level bridge (no `` ancestor required — + // the floating-island composer lives as a sibling of HarnessCanvas). + const selectionLength = useHarnessSelectionLength() + const hasSelection = enabled && selectionLength > 0 + // Active page is *always* sent as context when a surface is open, + // regardless of the selection toggle — the indicator reflects that. + const hasActiveSurface = useBoardAppStore( + (state) => Boolean(state.activeNodeSurface), + ) + return ( + enabled && + (hasActiveSurface || (enableMessageBoardContextSelection && hasSelection)) ) - // The active page is *always* sent as context when a surface is open, - // regardless of the selection toggle, so the indicator reflects that too. - const hasActiveSurface = useGraphStore((state) => Boolean(state.activeNodeSurface)) - return enabled && (hasActiveSurface || (enableMessageBoardContextSelection && hasSelection)) } /** - * One-shot lazy builder for the per-message board-selection context. Reads - * the current store state once, filters selected non-point nodes, renders - * the context text, and truncates to a hard cap. Call from a submit handler - * — never inside a render — so the heavy filter + text build only runs when - * the user actually presses send. + * One-shot lazy builder for the per-message board context. Resolves + * the active surface (if any) or otherwise the current canvas + * selection, converts canvas-harness Nodes back to Note shapes, and + * renders the structured `` blocks via + * `buildContextTextFromNodes`. Truncated to `MAX_MESSAGE_CONTEXT_CHARS`. + * + * Call from a submit handler — never inside a render — so the + * conversion + text build only happens when the user actually presses + * send. */ export const buildMessageContext = ( { enabled = true }: { enabled?: boolean } = {}, ): string | undefined => { if (!enabled) return undefined - // When a surface is open, the active page replaces the canvas-selection - // context entirely. The same note would otherwise also appear via its - // root in nodesById, which would duplicate it (and confusingly mix two - // formats). The active page is rendered through the same - // `` block as canvas nodes for a single coherent format. - const activeSurface = useGraphStore.getState().activeNodeSurface + // Active surface wins. The same note would otherwise appear via the + // canvas selection too, which would duplicate it; sending it once + // through the `` block keeps the format coherent. + const activeSurface = useBoardAppStore.getState().activeNodeSurface if (activeSurface) { - const note = readActiveSurfaceNote(activeSurface.nodeId) + const note = readNote(activeSurface.nodeId) if (!note) return undefined const synthetic = { id: note.id, data: note } as unknown as NoteNode const block = buildContextTextFromNodes([synthetic]).trim() @@ -85,13 +119,21 @@ export const buildMessageContext = ( useChatStore.getState().enableMessageBoardContextSelection if (!enableMessageBoardContextSelection) return undefined - const selectedNodes = useGraphStore.getState().nodes.filter( - (node) => - node.selected - && (node.data as { kind?: string } | undefined)?.kind !== "point", - ) - if (selectedNodes.length === 0) return undefined - const text = buildContextTextFromNodes(selectedNodes).trim() + const store = getCanvasStoreRef() + if (!store) return undefined + const selectionIds = store.getSelection() + if (selectionIds.length === 0) return undefined + + const syntheticNodes: NoteNode[] = [] + for (const id of selectionIds) { + const node = store.getNode(id as NodeId) + if (!node) continue + const note = nodeToNote(node) + syntheticNodes.push({ id: note.id, data: note } as unknown as NoteNode) + } + if (syntheticNodes.length === 0) return undefined + + const text = buildContextTextFromNodes(syntheticNodes).trim() if (!text) return undefined return text.slice(0, MAX_MESSAGE_CONTEXT_CHARS) } diff --git a/webui/src/features/agent/hooks/use-submit-prompt.ts b/webui/src/features/agent/hooks/use-submit-prompt.ts index 55297f1..f381936 100644 --- a/webui/src/features/agent/hooks/use-submit-prompt.ts +++ b/webui/src/features/agent/hooks/use-submit-prompt.ts @@ -2,7 +2,7 @@ import { useCallback } from "react" import { useNavigate, useParams, useRouterState } from "@tanstack/react-router" import { useAppStore } from "@/store" import { useChatStore } from "../store/chat-store" -import { useGraphStore } from "@/features/board/store/graph-store" +import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" import { useChat } from "./chat-context" import { useCreateChat } from "../api/create-chat" import { useUpdateChat } from "../api/update-chat" @@ -41,7 +41,7 @@ export const useSubmitPrompt = () => { const enabledTools = useChatStore((s) => s.enabledTools) const useDeepResearch = useChatStore((s) => s.useDeepResearch) const setUseDeepResearch = useChatStore((s) => s.setUseDeepResearch) - const rootId = useGraphStore((s) => s.rootId) + const rootId = useBoardAppStore((s) => s.rootId) ?? undefined const { createChatAsync } = useCreateChat() const { updateChatAsync } = useUpdateChat() diff --git a/webui/src/features/board/api/add-mindmap-to-board.ts b/webui/src/features/board/api/add-mindmap-to-board.ts deleted file mode 100644 index 4cd8c3d..0000000 --- a/webui/src/features/board/api/add-mindmap-to-board.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { useMindMapStore } from '@/features/agent/store/mindmap-store' -import { useMutation } from '@tanstack/react-query' -import { useGraphStore } from '../store/graph-store' -import { displaceNodes } from '../utils/flow-view' -import { nodeCenter } from '../utils/point-attach' -import { POINT_NODE_SIZE } from '../components/flow/point-node' -import { generateUuid } from '@/lib/common' -import type { LinkEdge, NoteNode } from '../types/flow' -import { useFitNodes } from '../hooks/use-fit-nodes' -import { useShallow } from 'zustand/react/shallow' -import { estimateNoteContentHeight } from '../utils/markdown-height-estimate' -import { contentWidthFromNode, nodeHeightFromContent } from '../utils/note-box' - -const isAutoSizedTextNode = (node: NoteNode) => { - const kind = (node.data as { kind?: string }).kind - if (kind === 'point') return false - const nodeType = node.data.style.type - if (nodeType === 'image' || nodeType === 'icon' || nodeType === 'slide') return false - return true -} - -const applyAutoHeightForMindMapNodes = (nodes: NoteNode[]) => - nodes.map(node => { - if (!isAutoSizedTextNode(node)) return node - - const markdown = node.data.content?.markdown ?? node.data.label?.markdown ?? '' - if (!markdown.trim()) return node - - const nodeType = node.data.style.type - const persistedSize = node.data.properties.nodeSize.size - const width = typeof node.width === 'number' && Number.isFinite(node.width) - ? node.width - : persistedSize?.width ?? 280 - const currentHeight = typeof node.height === 'number' && Number.isFinite(node.height) - ? node.height - : persistedSize?.height ?? 20 - - const contentWidth = contentWidthFromNode({ nodeType, nodeWidth: width }) - const estimatedContentH = estimateNoteContentHeight({ - text: markdown, - width: contentWidth, - fontFamily: node.data.style.fontFamily, - fontSize: node.data.style.fontSize, - textStyle: node.data.style.textStyle, - }) - const estimatedNodeH = Math.max(20, nodeHeightFromContent({ nodeType, contentHeight: estimatedContentH })) - if (estimatedNodeH <= currentHeight) return node - - node.height = estimatedNodeH - node.measured = { - ...node.measured, - width, - height: estimatedNodeH, - } - node.data.properties.nodeSize.size = { - ...(persistedSize ?? { width, height: currentHeight }), - height: estimatedNodeH, - } - - return node - }) - -/** - * A hook to add all mind map nodes/edges from the mind map store - * into the current board, displacing to avoid overlap, - * marking nodes as new (frontend only), fitting the view, - * and persisting to the backend. - */ -export const useAddMindMapToBoard = () => { - const boardId = useGraphStore(state => state.boardId) - const rootId = useGraphStore(state => state.rootId) - const nodes = useGraphStore(useShallow(state => state.nodes)) - const edges = useGraphStore(useShallow(state => state.edges)) - const setNodesPersist = useGraphStore(state => state.setNodesPersist) - const setEdgesPersist = useGraphStore(state => state.setEdgesPersist) - const { mindmaps, clearMindMap } = useMindMapStore() - const fitNodes = useFitNodes() - - const mutation = useMutation({ - mutationFn: async (): Promise => { - if (!boardId) { - return false - } - - const boardMindmaps = mindmaps.get(boardId) || [] - let nodes_ = [...nodes] - let edges_ = [...edges] - - const contextNodes = nodes.filter( - n => n.selected && (n.data as { kind?: string } | undefined)?.kind !== 'point' - ) - let stackBase = contextNodes - - const newIds: string[] = [] - - for (const mindMap of boardMindmaps) { - const { nodes: mindMapNodes, edges: mindMapEdges, useAnchors } = mindMap - mindMapNodes.forEach(node => { - node.data.parentId = rootId - }) - mindMapEdges.forEach(edge => { - if (!edge.data) return - edge.data.graphUid = boardId - edge.data.parentId = rootId - }) - - // avoid overlap and merge into the board - const displacedMindMapNodes = contextNodes.length > 0 && useAnchors !== false - ? displaceNodes(stackBase, mindMapNodes, contextNodes) - : displaceNodes(nodes_, mindMapNodes) - if (contextNodes.length > 0 && useAnchors !== false) { - stackBase = displacedMindMapNodes - } - const preparedMindMapNodes = applyAutoHeightForMindMapNodes(displacedMindMapNodes) - const displacedById = new Map(preparedMindMapNodes.map(node => [node.id, node])) - const newPointNodes: NoteNode[] = [] - const convertedEdges: LinkEdge[] = [] - - const attachPointPair = (edge: LinkEdge) => { - const sourceNode = displacedById.get(edge.source) - const targetNode = displacedById.get(edge.target) - if (!sourceNode || !targetNode) { - convertedEdges.push(edge) - return - } - - const edgeId = edge.id || generateUuid() - const startId = `${edgeId}-start` - const endId = `${edgeId}-end` - const offset = POINT_NODE_SIZE / 2 - const sourceCenter = nodeCenter(sourceNode) - const targetCenter = nodeCenter(targetNode) - - const startPoint: NoteNode = { - id: startId, - type: 'point', - zIndex: 1001, - position: { - x: sourceCenter.x - offset, - y: sourceCenter.y - offset, - }, - data: { - kind: 'point', - attachedToNodeId: sourceNode.id, - attachedDirection: { x: 0, y: 0 }, - } as NoteNode['data'], - draggable: true, - selectable: true, - } - - const endPoint: NoteNode = { - id: endId, - type: 'point', - zIndex: 1001, - position: { - x: targetCenter.x - offset, - y: targetCenter.y - offset, - }, - data: { - kind: 'point', - attachedToNodeId: targetNode.id, - attachedDirection: { x: 0, y: 0 }, - } as NoteNode['data'], - draggable: true, - selectable: true, - } - - newPointNodes.push(startPoint, endPoint) - - convertedEdges.push({ - ...edge, - id: edgeId, - source: startId, - target: endId, - sourceHandle: 'point', - targetHandle: 'point', - data: edge.data - ? { - ...edge.data, - id: edgeId, - graphUid: boardId, - parentId: rootId, - } - : edge.data, - }) - } - mindMapEdges.forEach(attachPointPair) - nodes_ = [...preparedMindMapNodes, ...newPointNodes, ...nodes_] - edges_ = [...convertedEdges, ...edges_] - - // update stores (drives React Flow) - setNodesPersist(nodes_) - setEdgesPersist(edges_) - - // fit the camera to just-added nodes (waits until measured) - preparedMindMapNodes.forEach(n => newIds.push(n.id)) - } - - // fit view to new nodes - if (newIds.length > 0) { - await fitNodes(newIds, { padding: 0.25, duration: 100 }) - } - - clearMindMap(boardId) - return true - } - }) - - return { - addMindMapToBoard: mutation.mutate, - addMindMapToBoardAsync: mutation.mutateAsync, - ...mutation - } -} diff --git a/webui/src/features/board/api/create-board.ts b/webui/src/features/board/api/create-board.ts index 6a5b80a..777264f 100644 --- a/webui/src/features/board/api/create-board.ts +++ b/webui/src/features/board/api/create-board.ts @@ -1,8 +1,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useGraphStore } from "../store/graph-store" import type { Graph } from "../types/board" import { apiFetch } from "@/api" import { useAppStore } from "@/store" +import { useBoardAppStore } from "../harness/store/board-app-store" import { listBoards } from "./list-boards" import { FREE_PLAN_BOARD_LIMIT, isBoardCreationLimited } from "../lib/board-limit" import { toast } from "sonner" @@ -30,7 +30,7 @@ export const useCreateBoard = () => { const userId = useAppStore(s => s.userId) const userPlan = useAppStore(s => s.userPlan) - const { setGraphScope, setNodes, setEdges } = useGraphStore() + const setBoardScope = useBoardAppStore((s) => s.setBoardScope) const mutation = useMutation({ mutationFn: async () => { @@ -47,9 +47,9 @@ export const useCreateBoard = () => { const newBoard = { uid: boardId } as Graph // Temporary ID until the server responds return [newBoard, ...(oldBoards || [])] // Prepend the new board to the list }) - setGraphScope({ boardId, rootId: undefined }) // Set the current board scope to the newly created board - setNodes([]) - setEdges([]) + // Pre-set the harness scope so subsequent navigation onto /boards/:id + // hydrates the new (empty) board without a flash. + setBoardScope({ boardId, rootId: null }) return boardId } }) diff --git a/webui/src/features/board/api/get-board.ts b/webui/src/features/board/api/get-board.ts index 31b3dbf..3197df0 100644 --- a/webui/src/features/board/api/get-board.ts +++ b/webui/src/features/board/api/get-board.ts @@ -1,19 +1,14 @@ import type { Graph } from "../types/board" import camelcaseKeys from "camelcase-keys" -import { useMutation } from "@tanstack/react-query" -import { useGraphStore } from "../store/graph-store" -import { convertLinkToEdgeWithPoints, convertNoteToNode } from "../utils/graph" -import type { LinkEdge, NoteNode } from "../types/flow" import { apiFetch } from "@/api" import type { Link } from "../types/link" import type { Note } from "../types/note" /** - * Fetch a board by its ID for the user. - * - * @param boardId - The ID of the board to be fetched. - * @returns A promise that resolves to the board object. + * Fetch a board by its ID. Returns the graph + canEdit flag. The + * canvas-harness store calls this from `hydrateBoardStore` on scope + * change. */ export async function getBoard( boardId: string, @@ -32,75 +27,6 @@ export async function getBoard( } -/** - * Custom hook to fetch a board by its ID for the user. - */ -export const useGetBoard = () => { - // no selectors here → no subscription - const mutation = useMutation({ - mutationFn: async (): Promise => { - const { - boardId, - rootId, - isLoading, - setIsLoading, - } = useGraphStore.getState() - if (!boardId) return false - if (isLoading) return false - setIsLoading(true) - try { - const { - setNodes, - setEdges, - setBoardVisibility, - setBoardCanEdit, - setBoardLabel, - } = useGraphStore.getState() - - const { graph, canEdit } = await getBoard(boardId, rootId) - const { nodes: notes, edges: links, visibility } = graph - const nodes = (notes ?? []).map(convertNoteToNode) - const nodesById = new Map(nodes.map(node => [node.id, node])) - const edges: LinkEdge[] = [] - const pointNodes: NoteNode[] = [] - - for (const link of links ?? []) { - const { edge, points } = convertLinkToEdgeWithPoints(link, nodesById) - edges.push(edge) - if (points.length) { - pointNodes.push(...points) - } - } - - const loadedNodes = [...nodes, ...pointNodes] - const readonlyNodes = canEdit - ? loadedNodes - : loadedNodes.map(node => ({ - ...node, - draggable: false, - selectable: false, - })) - - setNodes(readonlyNodes) - setEdges(edges) - setBoardVisibility(visibility ?? "private") - setBoardCanEdit(canEdit) - setBoardLabel(graph.label ?? "") - return true - } finally { - setIsLoading(false) - } - }, - }) - - return { - getBoard: mutation.mutate, - getBoardAsync: mutation.mutateAsync, - ...mutation, - } -} - - /** * Fetch a single note from a board. */ diff --git a/webui/src/features/board/api/parse-document.ts b/webui/src/features/board/api/parse-document.ts index 56874a8..ba6e1f7 100644 --- a/webui/src/features/board/api/parse-document.ts +++ b/webui/src/features/board/api/parse-document.ts @@ -1,19 +1,19 @@ import camelcaseKeys from "camelcase-keys" -import { useMutation } from "@tanstack/react-query" import { apiFetch } from "@/api" import type { Note } from "../types/note" import type { Link } from "../types/link" -import { convertLinkToEdgeWithPoints, convertNoteToNode } from "../utils/graph" -import type { LinkEdge, NoteNode } from "../types/flow" -import { useGraphStore } from "../store/graph-store" + type ParseDocumentResponse = { notes: Note[] links: Link[] } + /** - * Parse a document and return notes + links. + * Parse a document and return notes + links. The canvas-harness side + * calls this from `useHarnessParseDocument`, which then writes the + * parsed result into the harness store as a `remote`-origin batch. */ export async function parseDocument( boardId: string, @@ -36,55 +36,3 @@ export async function parseDocument( links: (data.links ?? []) as Link[], } } - -/** - * Hook to parse a document and merge nodes/edges into the graph store. - */ -export const useParseDocument = () => { - const setNodes = useGraphStore(state => state.setNodes) - const setEdges = useGraphStore(state => state.setEdges) - - const mutation = useMutation({ - mutationFn: async ({ - boardId, - file, - rootId, - }: { - boardId: string - file: File - rootId?: string - }): Promise => parseDocument(boardId, file, rootId), - onSuccess: ({ notes, links }, { boardId, rootId }) => { - const firstNote = notes[0] - if (firstNote) { - const expectedParentId = rootId ?? undefined - const firstParentId = firstNote.parentId ?? undefined - if (firstNote.graphUid !== boardId || firstParentId !== expectedParentId) { - return - } - } - - const nodes = notes.map(convertNoteToNode) - const nodesById = new Map(nodes.map(node => [node.id, node])) - const edges: LinkEdge[] = [] - const pointNodes: NoteNode[] = [] - - for (const link of links) { - const { edge, points } = convertLinkToEdgeWithPoints(link, nodesById) - edges.push(edge) - if (points.length) { - pointNodes.push(...points) - } - } - - setNodes(prev => [...nodes, ...pointNodes, ...prev]) - setEdges(prev => [...edges, ...prev]) - }, - }) - - return { - parseDocument: mutation.mutate, - parseDocumentAsync: mutation.mutateAsync, - ...mutation, - } -} diff --git a/webui/src/features/board/components/board-view.tsx b/webui/src/features/board/components/board-view.tsx index 9bdc7f8..13fc1d0 100644 --- a/webui/src/features/board/components/board-view.tsx +++ b/webui/src/features/board/components/board-view.tsx @@ -1,46 +1,62 @@ -import { ReactFlowProvider } from "@xyflow/react" -import { useEffect, useMemo } from "react" -import GraphEditor from "./flow/graph-editor" -import { useGraphStore } from "../store/graph-store" -import { LoadingWindow } from "@/components/loading-view" -import { useGetBoard } from "../api/get-board" +import { useNavigate, useSearch } from "@tanstack/react-router" +import { HarnessCanvas } from "../harness/canvas" +import { useBoardAppStore } from "../harness/store/board-app-store" +import { FloatingAssistant } from "./flow/floating-assistant/floating-assistant" +import { CopilotSheet } from "./flow/copilot-sheet" + /** - * GraphView - * - * Board-aware shell for the graph editor that triggers a fetch on board changes - * and renders purely from derived loading state. It resets the mutation state - * when the boardId changes, fires a single fetch, and relies on React Query's - * status combined with the graph store's isLoading to avoid local race conditions. + * Board entry-point. Mounts the canvas-harness board surface, the + * floating-island AI composer at the bottom of the canvas, and the + * full chat sheet drawer. Scope (boardId / rootId) is set on the + * board-app-store by `BoardScreen`; this component reads from there. */ export const BoardView: React.FC = () => { - const { boardId, rootId, isLoading: storeLoading } = useGraphStore() - const { getBoardAsync, isPending, isSuccess, reset } = useGetBoard() - - useEffect(() => { - if (!boardId) return - reset() - void getBoardAsync() - }, [boardId, rootId, getBoardAsync, reset]) + const navigate = useNavigate() + const boardId = useBoardAppStore((s) => s.boardId) + const chatSheetOpen = useBoardAppStore((s) => s.chatSheetOpen) + const setChatSheetOpen = useBoardAppStore((s) => s.setChatSheetOpen) + const presentationMode = useBoardAppStore((s) => s.presentationMode) - const loading = useMemo( - () => !isSuccess || isPending || storeLoading, - [isSuccess, isPending, storeLoading] - ) + // current_chat_id from the URL — shared between the floating island + // and the full chat sheet so opening one continues the same chat. + const boardSearch = useSearch({ + strict: false, + select: (s: { current_chat_id?: string }) => ({ currentChatId: s.current_chat_id }), + }) + const currentChatId = boardSearch?.currentChatId return (
- -
- {loading ? ( -
- -
- ) : ( - - )} -
-
+
+ + {!chatSheetOpen && !presentationMode && boardId && ( + setChatSheetOpen(true)} + /> + )} + { + setChatSheetOpen(false) + if (chatId) { + navigate({ + to: "/chats/$id", + params: { id: chatId }, + search: (prev: Record) => ({ + ...prev, + board_id: boardId || undefined, + }), + }) + } + }} + /> +
) } diff --git a/webui/src/features/board/components/default-view.tsx b/webui/src/features/board/components/default-view.tsx deleted file mode 100644 index a22b3b9..0000000 --- a/webui/src/features/board/components/default-view.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { LinearView } from './flow/linear-view' - - -export const DefaultBoardView = () => { - return ( -
-
- -
-
- ) -} diff --git a/webui/src/features/board/components/flow/action-panel.tsx b/webui/src/features/board/components/flow/action-panel.tsx deleted file mode 100644 index 919fb54..0000000 --- a/webui/src/features/board/components/flow/action-panel.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { memo, useEffect, useState } from 'react' -import type { AddNoteNodeOptions } from '../../hooks/use-add-node' -import { ImageSearchDialog } from './utils/image-search' -import { IconSearchDialog } from './utils/icon-search' -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' -import { useGraphStore } from '../../store/graph-store' -import { useNavigate, useSearch } from '@tanstack/react-router' -import { useBoardShortcuts } from '../../hooks/use-board-shortcuts' -import { DocumentUploadDialog } from './utils/document-upload' -import { TopBar } from './top-bar' -import { SlidePanel } from './slide-panel' -import { CopilotSheet } from './copilot-sheet' -import { FloatingAssistant } from './floating-assistant/floating-assistant' -import { updateBoard } from '../../api/update-board' - - -type ViewMode = 'graph' | 'linear' | 'list' - -interface ActionPanelProps { - onAddNode: (options: AddNoteNodeOptions) => void - onAddLine: () => void - enableSelection: boolean - setEnableSelection: (mode: boolean) => void - - // React Flow controls - onZoomIn: () => void - onZoomOut: () => void - onResetZoom: () => void - toggleLock: () => void - - viewMode: ViewMode - setViewMode: (mode: ViewMode) => void -} - -/** - * Action panel orchestrator for tools + navigation controls. - */ -export const ActionPanel = memo(function ActionPanel({ - onAddNode, - onAddLine, - enableSelection, - setEnableSelection, - onZoomIn, - onZoomOut, - onResetZoom, - toggleLock, - viewMode, - setViewMode -}: ActionPanelProps) { - const [openImageSearch, setOpenImageSearch] = useState(false) - const [openIconSearch, setOpenIconSearch] = useState(false) - // Lifted to graph-store so the surface panels (sheet/code/widget) can - // open the chat sideview from their mobile-only sparkles button. Also - // resets automatically on scope change in `setGraphScope`. - const openChatDialog = useGraphStore(state => state.chatSheetOpen) - const setOpenChatDialog = useGraphStore(state => state.setChatSheetOpen) - const [openShapeMenu, setOpenShapeMenu] = useState(false) - const [openDocumentUpload, setOpenDocumentUpload] = useState(false) - const [openSlidesPanel, setOpenSlidesPanel] = useState(false) - const boardId = useGraphStore(state => state.boardId) - const boardVisibility = useGraphStore(state => state.boardVisibility) - const setNodes = useGraphStore(state => state.setNodes) - const setBoardVisibility = useGraphStore(state => state.setBoardVisibility) - const setViewSlides = useGraphStore(state => state.setViewSlides) - const presentationMode = useGraphStore(state => state.presentationMode) - const navigate = useNavigate() - // `strict: false` so this also reads the search on the surface routes - // (/boards/$id/sheets/$noteId, /code-sandbox/$noteId, /widgets/$noteId); - // a `from: "/boards/$id"` would only match the bare board route and - // silently drop `current_chat_id` on those nested routes. - const boardSearch = useSearch({ - strict: false, - select: (s: { current_chat_id?: string }) => ({ currentChatId: s.current_chat_id }), - }) - const currentChatId = boardSearch?.currentChatId - - useEffect(() => { - setViewSlides(openSlidesPanel) - }, [openSlidesPanel, setViewSlides]) - - useEffect(() => { - setNodes(ns => - ns.map(n => { - if (n.data?.style?.type !== 'slide') return n - return { - ...n, - style: { ...(n.style ?? {}), pointerEvents: openSlidesPanel ? 'auto' : 'none' }, - dragHandle: '.slide-handle', - } - }) - ) - }, [openSlidesPanel, setNodes]) - - useBoardShortcuts({ - enabled: viewMode === 'graph', - shortcuts: [ - { key: 'a', handler: onAddLine }, - { key: 'n', handler: () => onAddNode({ nodeType: 'sheet' }) }, - { key: 's', handler: () => setOpenShapeMenu(true) }, - { key: 'r', handler: () => onAddNode({ nodeType: 'rectangle' }) }, - { key: 'o', handler: () => onAddNode({ nodeType: 'ellipse' }) }, - { key: 'd', handler: () => onAddNode({ nodeType: 'diamond' }) }, - { key: 't', handler: () => onAddNode({ nodeType: 'text' }) }, - { key: 'y', handler: () => onAddNode({ nodeType: 'code-sandbox' }) }, - { key: 'g', handler: () => setOpenIconSearch(true) }, - { key: 'i', handler: () => setOpenImageSearch(true) }, - { key: 'c', handler: () => boardId && setOpenChatDialog(true) }, - { key: 'm', handler: () => boardId && setOpenSlidesPanel(true) }, - { key: 'p', handler: () => setEnableSelection(false) }, - { key: 'v', handler: () => setEnableSelection(!enableSelection) }, - { key: 'l', handler: toggleLock }, - { key: '=', handler: onZoomIn }, - { key: '+', handler: onZoomIn }, - { key: '-', handler: onZoomOut }, - { key: '_', handler: onZoomOut }, - { key: '0', handler: onResetZoom }, - ], - }) - - const handleUpdateVisibility = async (visibility: 'private' | 'public') => { - if (!boardId) return - await updateBoard(boardId, { visibility }) - setBoardVisibility(visibility) - } - - return ( - <> - {!presentationMode && ( - setOpenSlidesPanel(v => !v)} - slidesPanelOpen={openSlidesPanel} - boardId={boardId} - boardVisibility={boardVisibility} - onUpdateVisibility={handleUpdateVisibility} - /> - )} - - - - - - - - Slides - - setOpenSlidesPanel(false)} /> - - - {!openChatDialog && !presentationMode && viewMode === 'graph' && boardId && ( - setOpenChatDialog(true)} - /> - )} - { - setOpenChatDialog(false) - if (chatId) { - navigate({ - to: "/chats/$id", - params: { id: chatId }, - search: (prev: Record) => ({ - ...prev, - board_id: boardId || undefined, - }), - }) - } else { - navigate({ - to: "/chats", - search: (prev: Record) => ({ - ...prev, - board_id: boardId || undefined, - }), - }) - } - }} - /> - - ) -}) diff --git a/webui/src/features/board/components/flow/code-sandbox-node.tsx b/webui/src/features/board/components/flow/code-sandbox-node.tsx deleted file mode 100644 index 8568c39..0000000 --- a/webui/src/features/board/components/flow/code-sandbox-node.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { memo, useMemo } from "react" - -import { useTheme } from "@/components/theme-provider" - -import { useGraphStore } from "../../store/graph-store" -import { useMotionState } from "./motion-state-context" -import type { Note } from "../../types/note" -import { - highlightPython, - ROSE_PINE_DARK, - ROSE_PINE_LIGHT, -} from "./code-sandbox-utils" -import { NodeTitleCaption } from "./node-title-caption" -import "./code-sandbox-node.css" - - -type CodeSandboxNodeProps = { - note: Note - dragging?: boolean -} - - -/** - * Read-only board preview for Python sandbox notes. - */ -export const CodeSandboxNode = memo(function CodeSandboxNode({ - note, - dragging, -}: CodeSandboxNodeProps) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === "dark" - const palette = isDark ? ROSE_PINE_DARK : ROSE_PINE_LIGHT - const { isMoving } = useMotionState() - const boardCanEdit = useGraphStore((state) => state.boardCanEdit) - - const codePreview = note.content?.markdown || "# Write Python here" - const previewHtml = useMemo(() => highlightPython(codePreview), [codePreview]) - const suspendPreview = Boolean(isMoving || dragging) - - return ( -
-
- -
- - -
- ) -}) diff --git a/webui/src/features/board/components/flow/connection.tsx b/webui/src/features/board/components/flow/connection.tsx deleted file mode 100644 index c3be365..0000000 --- a/webui/src/features/board/components/flow/connection.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { getStraightPath, type ConnectionLineComponentProps } from '@xyflow/react' -import { useMemo } from 'react' -import type { NoteNode } from '../../types/flow' - - -/** - * Custom connection line component for React Flow. - */ -export function CustomConnectionLine({ - fromX, - fromY, - toX, - toY, - connectionLineStyle -}: ConnectionLineComponentProps) { - const [edgePath] = getStraightPath({ - sourceX: fromX, - sourceY: fromY, - targetX: toX, - targetY: toY, - }) - - const markerId = useMemo( - () => `connection-arrow-${Math.random().toString(36).slice(2, 8)}`, - [] - ) - - const strokeColor = 'var(--secondary-foreground)' - const style = { - stroke: strokeColor, - strokeWidth: 2, - strokeDasharray: '8 6', - strokeLinecap: 'round' as const, - fill: 'none', - ...(connectionLineStyle || {}), - } - - return ( - - - - - - - - - ) -} diff --git a/webui/src/features/board/components/flow/document-card-view.tsx b/webui/src/features/board/components/flow/document-card-view.tsx deleted file mode 100644 index 87cd5d9..0000000 --- a/webui/src/features/board/components/flow/document-card-view.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { memo, useCallback, useMemo } from "react" -import type { MouseEvent } from "react" -import clsx from "clsx" - -import { LiteMarkdown } from "@/components/markdown/lite-markdown" -import { DeleteIcon, NotepadIcon, PinIcon, PinOffIcon } from "@/components/icons" - -import { useMotionState } from "./motion-state-context" -import type { NoteWithPin } from "./note-card" - - -const TITLE_ROW_PX = 32 -const TITLE_FONT_SIZE = 20 -const BADGE_GAP = 4 -const CARD_PADDING_X = 12 -const CARD_PADDING_Y = 10 -const BODY_TOP_GAP = 6 -const BODY_FONT_SIZE = 14 -const BODY_LINE_HEIGHT = 1.35 - - -/** - * Strips editor-only fence syntax (`:::toggle ...`, `:::`) and collapses blank - * line runs so the compact preview reads like prose, not raw markup. - */ -function cleanSheetPreview(markdown: string): string { - if (!markdown) return "" - const stripped: string[] = [] - for (const line of markdown.split("\n")) { - const trimmed = line.trim() - const toggleOpen = trimmed.match(/^:::toggle\s*(.*)$/) - if (toggleOpen) { - const summary = toggleOpen[1].trim() - stripped.push(summary ? `▸ ${summary}` : "▸") - continue - } - if (trimmed.startsWith(":::")) continue - stripped.push(line) - } - const collapsed: string[] = [] - let lastBlank = false - for (const line of stripped) { - const isBlank = !line.trim() - if (isBlank && lastBlank) continue - collapsed.push(line) - lastBlank = isBlank - } - return collapsed.join("\n").trim() -} - - -type DocumentCardContentProps = { - note: NoteWithPin - isDark: boolean - textColor?: string - suspendContent?: boolean - hideBadge?: boolean -} - - -/** - * Title + handwriting body preview for a sheet note. Uses DOM-based lite - * markdown so the text flows naturally at any container size. - */ -export const DocumentCardContent = memo(function DocumentCardContent({ - note, - textColor, - hideBadge = false, -}: DocumentCardContentProps) { - const title = note.label?.markdown?.trim() || "" - const body = useMemo( - () => cleanSheetPreview(note.content?.markdown || ""), - [note.content?.markdown], - ) - - const paddingTop = hideBadge ? 30 : CARD_PADDING_Y - - return ( -
- {!hideBadge && ( -
- - Sheet -
- )} - -
- {title || New note} -
- -
- {body && ( - <> -
- -
-
- - )} -
-
- ) -}) - - -type DocumentCardViewProps = { - note: NoteWithPin - selected: boolean - dragging?: boolean - isDark: boolean - isPinned: boolean - textColor?: string - hideBadge?: boolean - onTogglePin: (event: MouseEvent) => void - onDelete: (event: MouseEvent) => void - onOpen: () => void -} - - -/** - * Paper-styled card used for sheet nodes on the canvas. Wraps DocumentCardContent - * with the card chrome (texture, shadow, selection ring) and floating hover chips. - */ -export const DocumentCardView = memo(function DocumentCardView({ - note, - selected, - dragging, - isDark, - isPinned, - textColor, - hideBadge, - onTogglePin, - onDelete, - onOpen, -}: DocumentCardViewProps) { - const { isMoving } = useMotionState() - const suspendContent = Boolean(isMoving || dragging) - - const handleCardClick = useCallback((event: React.MouseEvent) => { - if ((event.target as HTMLElement).closest("button")) return - onOpen() - }, [onOpen]) - - return ( -
-
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > - - -
- - -
- ) -}) diff --git a/webui/src/features/board/components/flow/document-node.tsx b/webui/src/features/board/components/flow/document-node.tsx deleted file mode 100644 index f017137..0000000 --- a/webui/src/features/board/components/flow/document-node.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { memo, useMemo } from "react" -import { NodeResizeControl, type ControlPosition, type NodeProps } from "@xyflow/react" -import { PdfIcon } from "@/components/icons" -import type { NoteNode } from "../../types/flow" -import type { DocumentProperties } from "../../types/document" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { fontFamilyToTwClass, fontSizeToTwClass, textStyleToTwClass, type Style } from "../../types/style" -import { cn } from "@/lib/utils" -import { useTheme } from "@/components/theme-provider" -import { darkModeDisplayHex } from "../../lib/colors/dark-variants" -import { NodeTitleCaption } from "./node-title-caption" - - -type ResizeHandle = { - pos: ControlPosition - className: string -} - - -const RESIZE_HANDLES: ResizeHandle[] = [ - { pos: "top-left", className: "top-0 left-0 cursor-nwse-resize" }, - { pos: "top-right", className: "top-0 right-0 cursor-nesw-resize" }, - { pos: "bottom-left", className: "bottom-0 left-0 cursor-nesw-resize" }, - { pos: "bottom-right", className: "bottom-0 right-0 cursor-nwse-resize" }, -] - - -const getHandleTransform = (pos: ControlPosition) => { - const x = pos.includes("right") ? "50%" : "-50%" - const y = pos.includes("bottom") ? "50%" : "-50%" - return `translate(${x}, ${y})` -} - - -/** - * A React component that renders a document node within a flow board. - */ -export const DocumentNode = memo(function DocumentNode({ id, data, selected }: NodeProps) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === "dark" - const summary = (data.properties as DocumentProperties)?.summary?.text?.trim() - const style = data.style as Style | undefined - const rounded = (style?.roundness ?? 1) > 0 ? "rounded-xl" : "rounded-none" - const textAlignClass = data.style.textAlign === "left" ? "text-left" : data.style.textAlign === "right" ? "text-right" : "text-center" - const fontClass = fontFamilyToTwClass(data.style.fontFamily) - const sizeClass = fontSizeToTwClass(data.style.fontSize) - const textStyleClass = textStyleToTwClass(data.style.textStyle) - - const displayTextColor = isDark ? darkModeDisplayHex(data.style.textColor) || "#000000" : data.style.textColor - const captionTextStyle = useMemo(() => ({ color: displayTextColor }), [displayTextColor]) - - const className = cn( - "w-full h-full p-3 text-card-foreground border-2 border-dashed flex flex-col items-center text-center", - rounded, - selected ? "border-secondary-foreground" : "border-transparent", - ) - - const content = ( -
-
-
- -
-
- -
- ) - - const contentWithResizeHandles = ( -
- {content} - {selected && RESIZE_HANDLES.map(({ pos, className }) => ( - -
- - ))} -
- ) - - if (!summary) { - return contentWithResizeHandles - } - - return ( - - - {contentWithResizeHandles} - - -

{summary}

-
-
- ) -}) diff --git a/webui/src/features/board/components/flow/edge/edge-geometry.ts b/webui/src/features/board/components/flow/edge/edge-geometry.ts deleted file mode 100644 index 0b3314c..0000000 --- a/webui/src/features/board/components/flow/edge/edge-geometry.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { Node, InternalNode } from '@xyflow/react' -import type { LinkStyle } from '../../../types/style' -import { pointInNode, type NodeGeometry } from '../../../utils/flow' - -export type Point = { x: number; y: number } - -export function trianglePath(size: number, tipFactor: number, baseFactor: number): { d: string; baseX: number; tipX: number } { - const tipX = size * tipFactor - const baseX = size * baseFactor - const topY = size * 0.1 - const bottomY = size * 0.9 - - const d = [ - `M ${baseX} ${topY}`, - `L ${tipX} ${size / 2}`, - `L ${baseX} ${bottomY}`, - `Z` - ].join(' ') - - return { d, baseX, tipX } -} - -/** - * Open two-edge path for outlined arrowheads with a concave hand-drawn curve. - * Caller pairs this with a separate straight base stroke. - */ -export function curvedTrianglePath( - size: number, - tipFactor: number, - baseFactor: number, - curveInset = 0.08, -): { d: string; baseX: number; tipX: number } { - const tipX = size * tipFactor - const baseX = size * baseFactor - const topY = size * 0.1 - const bottomY = size * 0.9 - const midY = size / 2 - const inset = size * curveInset - - const midX = (baseX + tipX) / 2 - const upperCtrlY = (topY + midY) / 2 + inset - const lowerCtrlY = (bottomY + midY) / 2 - inset - - const d = [ - `M ${baseX} ${topY}`, - `Q ${midX} ${upperCtrlY} ${tipX} ${midY}`, - `Q ${midX} ${lowerCtrlY} ${baseX} ${bottomY}`, - ].join(' ') - - return { d, baseX, tipX } -} - - -/** - * Closed filled-arrow path with concave back edge. Gives a hand-drawn "swoosh" - * look — the two outer edges bow inward and the rear base bows toward the tip. - */ -export function filledCurvedTrianglePath( - size: number, - tipFactor: number, - baseFactor: number, - curveInset = 0.08, - backBow = 0.12, -): { d: string; baseX: number; tipX: number } { - const tipX = size * tipFactor - const baseX = size * baseFactor - const topY = size * 0.1 - const bottomY = size * 0.9 - const midY = size / 2 - const inset = size * curveInset - - const midX = (baseX + tipX) / 2 - const upperCtrlY = (topY + midY) / 2 + inset - const lowerCtrlY = (bottomY + midY) / 2 - inset - const backCtrlX = baseX + size * backBow - - const d = [ - `M ${baseX} ${topY}`, - `Q ${midX} ${upperCtrlY} ${tipX} ${midY}`, - `Q ${midX} ${lowerCtrlY} ${baseX} ${bottomY}`, - `Q ${backCtrlX} ${midY} ${baseX} ${topY}`, - `Z`, - ].join(' ') - - return { d, baseX, tipX } -} - - -export function barbPaths(size: number, tipFactor: number, baseFactor: number): { p1: string; p2: string } { - const tipX = size * tipFactor - const baseX = size * baseFactor - const topY = size * 0.05 - const bottomY = size * 0.95 - const midY = size / 2 - - const p1 = `M ${baseX} ${midY} L ${tipX} ${midY} L ${baseX} ${topY}` - const p2 = `M ${tipX} ${midY} L ${baseX} ${bottomY}` - - return { p1, p2 } -} - - -/** - * Hand-drawn barb variant: keeps the short shaft stub but replaces the two - * splayed strokes with concave quadratic curves bowing toward the arrow axis. - */ -export function curvedBarbPaths( - size: number, - tipFactor: number, - baseFactor: number, - curveInset = 0.08, -): { p1: string; p2: string } { - const tipX = size * tipFactor - const baseX = size * baseFactor - const topY = size * 0.05 - const bottomY = size * 0.95 - const midY = size / 2 - const inset = size * curveInset - const midX = (baseX + tipX) / 2 - - const upperCtrlY = (midY + topY) / 2 + inset - const lowerCtrlY = (midY + bottomY) / 2 - inset - - const p1 = `M ${tipX} ${midY} Q ${midX} ${upperCtrlY} ${baseX} ${topY}` - const p2 = `M ${tipX} ${midY} Q ${midX} ${lowerCtrlY} ${baseX} ${bottomY}` - - return { p1, p2 } -} - -export function cssDashArray(style: LinkStyle | undefined, strokeWidth: number): string | undefined { - if (!style) return undefined - const sw = Math.max(0.5, strokeWidth) - if (style.strokeStyle === 'dashed') return `${5.5 * sw} ${4 * sw}` - if (style.strokeStyle === 'dotted') return `0 ${3 * sw}` - return undefined -} - -export function quadraticPath(p0: Point, cp: Point, p1: Point): { path: string; labelX: number; labelY: number } { - const path = `M ${p0.x} ${p0.y} Q ${cp.x} ${cp.y} ${p1.x} ${p1.y}` - const midX = (p0.x + 2 * cp.x + p1.x) / 4 - const midY = (p0.y + 2 * cp.y + p1.y) / 4 - return { path, labelX: midX, labelY: midY } -} - -export function bendToControlPoint(bend: Point, start: Point, end: Point): Point { - return { - x: 2 * bend.x - 0.5 * (start.x + end.x), - y: 2 * bend.y - 0.5 * (start.y + end.y), - } -} - -function lerpPoint(a: Point, b: Point, t: number): Point { - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - } -} - -export function pointOnQuadratic(p0: Point, p1: Point, p2: Point, t: number): Point { - const u = 1 - t - return { - x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x, - y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y, - } -} - -export function subdivideQuadratic(p0: Point, p1: Point, p2: Point, t: number): { - first: [Point, Point, Point] - second: [Point, Point, Point] -} { - const p01 = lerpPoint(p0, p1, t) - const p12 = lerpPoint(p1, p2, t) - const p012 = lerpPoint(p01, p12, t) - return { - first: [p0, p01, p012], - second: [p012, p12, p2], - } -} - -export function extractQuadraticSegment(p0: Point, p1: Point, p2: Point, t0: number, t1: number): { - p0: Point - p1: Point - p2: Point -} { - if (t0 <= 0 && t1 >= 1) { - return { p0, p1, p2 } - } - - const clampedT0 = Math.max(0, Math.min(1, t0)) - const clampedT1 = Math.max(clampedT0 + 1e-4, Math.min(1, t1)) - - const { second } = subdivideQuadratic(p0, p1, p2, clampedT0) - const localT = (clampedT1 - clampedT0) / (1 - clampedT0) - const { first } = subdivideQuadratic(second[0], second[1], second[2], localT) - return { p0: first[0], p1: first[1], p2: first[2] } -} - -type FlowInternalNode = InternalNode | NodeGeometry | null | undefined - -export function findExitParam( - node: FlowInternalNode, - getter: (t: number) => Point, - iterations = 20 -): number { - if (!node) return 0 - let low = 0 - let high = 1 - for (let i = 0; i < iterations; i++) { - const mid = (low + high) / 2 - const point = getter(mid) - if (pointInNode(point, node)) { - low = mid - } else { - high = mid - } - } - return high -} - -export function shiftPointAlong(a: Point, b: Point, distance: number): Point { - const dx = b.x - a.x - const dy = b.y - a.y - const len = Math.hypot(dx, dy) || 1 - const scale = distance / len - return { - x: a.x + dx * scale, - y: a.y + dy * scale, - } -} diff --git a/webui/src/features/board/components/flow/edge/edge-label.tsx b/webui/src/features/board/components/flow/edge/edge-label.tsx deleted file mode 100644 index 3ab10c1..0000000 --- a/webui/src/features/board/components/flow/edge/edge-label.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { memo, useRef } from 'react' -import TextareaAutosize from 'react-textarea-autosize' -import { EdgeLabelRenderer } from '@xyflow/react' -import { LiteMarkdown } from '@/components/markdown/lite-markdown' -import { fontFamilyToTwClass, type FontFamily } from '../../../types/style' - - -/** - * Props for the EdgeLabel component. - */ -type EdgeLabelProps = { - labelText: string - labelColor?: string - labelDraft: string - isEditing: boolean - fontFamily?: FontFamily - onChange?: (value: string) => void - labelInputRef: React.RefObject - transformStyle: { transform: string } - handleLabelBlur: () => void - handleLabelKeyDown: (event: React.KeyboardEvent) => void -} - - -/** - * Edge label component for displaying and editing edge labels in a flowchart. - */ -export const EdgeLabel = memo(function EdgeLabel({ - labelText, - labelColor, - labelDraft, - isEditing, - fontFamily, - onChange, - labelInputRef, - transformStyle, - handleLabelBlur, - handleLabelKeyDown -}: EdgeLabelProps) { - const wrapperRef = useRef(null) - const fontFamilyClass = fontFamilyToTwClass(fontFamily) - - return ( - -
event.stopPropagation()} - > - {isEditing ? ( - onChange?.(event.target.value)} - onBlur={handleLabelBlur} - onKeyDown={handleLabelKeyDown} - placeholder='Add label...' - className={`z-[1002] text-center text-base px-0 py-0.5 bg-card rounded-md border-2 border-secondary-foreground focus:outline-none min-w-[60px] resize-none max-w-[200px] overflow-y-hidden ${fontFamilyClass}`} - minRows={1} - maxRows={4} - style={{ color: labelColor ?? 'inherit' }} - /> - ) : ( -
- -
- )} -
-
- ) -}) diff --git a/webui/src/features/board/components/flow/edge/edge-markers.tsx b/webui/src/features/board/components/flow/edge/edge-markers.tsx deleted file mode 100644 index b495960..0000000 --- a/webui/src/features/board/components/flow/edge/edge-markers.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useMemo } from 'react' - -import { useTheme } from '@/components/theme-provider' -import { darkModeDisplayHex } from '../../../lib/colors/dark-variants' -import type { LinkEdge } from '../../../types/flow' -import type { Link } from '../../../types/link' -import type { ArrowheadType, LinkStyle } from '../../../types/style' -import { - curvedTrianglePath, - filledCurvedTrianglePath, - curvedBarbPaths, -} from './edge-geometry' - -export const BASE_HEAD_SIZE = 10 -export const HEAD_SCALE = 1.4 -export const TIP_FACTOR = 0.95 -export const BASE_X_FACTOR = 0.25 -export const BASE_THICKNESS_BOOST = 1.0 - -type MarkerOrient = 'start' | 'end' - -type MarkerDef = { - kind: ArrowheadType - stroke: string - strokeWidth: number -} - -const normalizeIdPart = (value: string | number): string => - String(value).replace(/[^a-zA-Z0-9_-]/g, '_') - -// eslint-disable-next-line react-refresh/only-export-components -export const getMarkerId = ( - kind: ArrowheadType, - stroke: string, - strokeWidth: number, - orient: MarkerOrient, -): string => - `edge-marker-${kind}-${normalizeIdPart(stroke)}-${normalizeIdPart(strokeWidth)}-${orient}` - -function markerOrient(which: MarkerOrient): 'auto-start-reverse' | 'auto' { - return which === 'start' ? 'auto-start-reverse' : 'auto' -} - -function renderMarker( - { kind, stroke, strokeWidth }: MarkerDef, - orient: MarkerOrient, -) { - if (kind === 'none') return null - - const headScale = kind === 'barb' ? HEAD_SCALE * 1.15 : HEAD_SCALE - const headSize = BASE_HEAD_SIZE * headScale - const headStrokeWidth = Math.max(1, strokeWidth) - const markerPadding = Math.max(2, headStrokeWidth * 0.9) - const markerBoxSize = headSize + markerPadding * 2 - const viewBox = `${-markerPadding} ${-markerPadding} ${markerBoxSize} ${markerBoxSize}` - const refX = kind === 'barb' - ? headSize * TIP_FACTOR - : headSize * BASE_X_FACTOR - const refProps = { - refX: `${refX}`, - refY: `${headSize / 2}`, - markerWidth: markerBoxSize, - markerHeight: markerBoxSize, - markerUnits: 'userSpaceOnUse' as const, - orient: markerOrient(orient), - } - - const commonGroupProps = { - fill: 'none' as const, - stroke, - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - } - - const markerId = getMarkerId(kind, stroke, strokeWidth, orient) - - if (kind === 'arrow-filled') { - const { d } = filledCurvedTrianglePath(headSize * 0.95, TIP_FACTOR, BASE_X_FACTOR) - return ( - - - - ) - } - - if (kind === 'arrow') { - const { d, baseX } = curvedTrianglePath(headSize, TIP_FACTOR, BASE_X_FACTOR) - const topY = headSize * 0.1 - const bottomY = headSize * 0.9 - const midY = headSize / 2 - const baseCtrlX = baseX + headSize * 0.1 - return ( - - - - - - - ) - } - - const { p1, p2 } = curvedBarbPaths(headSize, TIP_FACTOR, BASE_X_FACTOR) - return ( - - - - - - - ) -} - -function computeStroke(linkStyle: LinkStyle | undefined, isDark: boolean): string { - const baseStroke = linkStyle?.strokeColor ?? '#333333' - if (!isDark) return baseStroke - return darkModeDisplayHex(baseStroke) ?? '#a5c9ff' -} - -export function EdgeMarkerDefs({ edges }: { edges: LinkEdge[] }) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const markers = useMemo(() => { - const map = new Map() - for (const edge of edges) { - const link = edge.data as Link | undefined - const style = link?.style - const strokeWidth = style?.strokeWidth ?? 1.5 - const stroke = computeStroke(style, isDark) - const startKind = (style?.sourceArrowhead ?? 'none') as ArrowheadType - const endKind = (style?.targetArrowhead ?? 'none') as ArrowheadType - - if (startKind !== 'none') { - const key = `${startKind}|${stroke}|${strokeWidth}` - if (!map.has(key)) { - map.set(key, { kind: startKind, stroke, strokeWidth }) - } - } - if (endKind !== 'none') { - const key = `${endKind}|${stroke}|${strokeWidth}` - if (!map.has(key)) { - map.set(key, { kind: endKind, stroke, strokeWidth }) - } - } - } - return Array.from(map.values()) - }, [edges, isDark]) - - if (markers.length === 0) return null - - return ( - - - {markers.map((marker) => ( - - {renderMarker(marker, 'start')} - {renderMarker(marker, 'end')} - - ))} - - - ) -} diff --git a/webui/src/features/board/components/flow/edge/edge-view.tsx b/webui/src/features/board/components/flow/edge/edge-view.tsx deleted file mode 100644 index 5ac7799..0000000 --- a/webui/src/features/board/components/flow/edge/edge-view.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import { - BaseEdge, - useReactFlow, - type EdgeProps -} from '@xyflow/react' -import type { CSSProperties, ReactElement } from 'react' -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { useShallow } from 'zustand/shallow' -import type { LinkEdge } from '../../../types/flow' -import type { Link } from '../../../types/link' -import type { ArrowheadType, LinkStyle } from '../../../types/style' -import { useTheme } from '@/components/theme-provider' -import { useGraphStore } from '../../../store/graph-store' -import { darkModeDisplayHex } from '../../../lib/colors/dark-variants' -import { EdgeLabel } from './edge-label' -import { - type Point, - cssDashArray, - extractQuadraticSegment, - pointOnQuadratic, - quadraticPath, -} from './edge-geometry' -import { useEdgeGeometry } from './use-edge-geometry' -import { useControlPointDrag } from './use-control-point-drag' -import { useRoughPath, hashSeed } from './use-rough-path' -import { useFreehandPath } from './use-freehand-path' -import { - BASE_HEAD_SIZE, - HEAD_SCALE, - TIP_FACTOR, - BASE_X_FACTOR, - getMarkerId, -} from './edge-markers' -import { selectEdgeAllSlices, type EdgeNodeSlice } from '../../../utils/edge-node-geometry' -import { estimateEdgeLabelSize } from '../../../utils/edge-label-estimate' - -const ARROW_CLEARANCE_FACTOR = 0.5 // pull heads farther from node surface - -type EdgeControlPointHandlers = { - onControlPointChange?: (point: Point) => void -} - -type EdgeLabelEditingData = { - labelEditing?: boolean - labelDraft?: string - onLabelChange?: (value: string) => void - onLabelSave?: () => void - onLabelCancel?: () => void -} - -type EdgeRenderData = Link & EdgeLabelEditingData & EdgeControlPointHandlers - - -function isFinitePoint(point: Partial | null | undefined): point is Point { - return Boolean( - point && - Number.isFinite(point.x) && - Number.isFinite(point.y) - ) -} - - -/** - * Renders an edge between two nodes, with optional arrowheads, label, and control point. - */ -export const EdgeView = memo(function EdgeView({ - id, - source, - target, - style = {}, - data, - selected -}: EdgeProps): ReactElement | null { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - const { screenToFlowPosition } = useReactFlow() - const isMoving = useGraphStore(state => state.isMoving) - - const flat = useGraphStore(useShallow(selectEdgeAllSlices(source, target))) - - const sourceNodeSlice = useMemo( - () => flat.sExists - ? { x: flat.sx, y: flat.sy, w: flat.sw, h: flat.sh, shape: flat.sShape, attachedToNodeId: flat.sAttachedToId } - : null, - [flat.sExists, flat.sx, flat.sy, flat.sw, flat.sh, flat.sShape, flat.sAttachedToId], - ) - const targetNodeSlice = useMemo( - () => flat.tExists - ? { x: flat.tx, y: flat.ty, w: flat.tw, h: flat.th, shape: flat.tShape, attachedToNodeId: flat.tAttachedToId } - : null, - [flat.tExists, flat.tx, flat.ty, flat.tw, flat.th, flat.tShape, flat.tAttachedToId], - ) - const attachedSourceNodeSlice = useMemo( - () => flat.asExists - ? { x: flat.asx, y: flat.asy, w: flat.asw, h: flat.ash, shape: flat.asShape } - : null, - [flat.asExists, flat.asx, flat.asy, flat.asw, flat.ash, flat.asShape], - ) - const attachedTargetNodeSlice = useMemo( - () => flat.atExists - ? { x: flat.atx, y: flat.aty, w: flat.atw, h: flat.ath, shape: flat.atShape } - : null, - [flat.atExists, flat.atx, flat.aty, flat.atw, flat.ath, flat.atShape], - ) - const [bendPointDrag, setBendPointDrag] = useState(null) - - const edgeExtras = (data ?? {}) as EdgeRenderData - - const edgeData = useMemo(() => { - const controlPoint = edgeExtras.properties?.edgeControlPoint?.position - return { - linkStyle: edgeExtras.style ?? undefined, - label: edgeExtras.label, - labelEditing: edgeExtras.labelEditing, - labelDraft: edgeExtras.labelDraft, - onControlPointChange: edgeExtras.onControlPointChange, - onLabelChange: edgeExtras.onLabelChange, - onLabelSave: edgeExtras.onLabelSave, - onLabelCancel: edgeExtras.onLabelCancel, - controlPoint: isFinitePoint(controlPoint) ? controlPoint : null, - } - }, [edgeExtras.properties?.edgeControlPoint?.position, edgeExtras.style, edgeExtras.label, edgeExtras.labelEditing, edgeExtras.labelDraft, edgeExtras.onControlPointChange, edgeExtras.onLabelChange, edgeExtras.onLabelSave, edgeExtras.onLabelCancel]) - - const linkStyle = edgeData.linkStyle as LinkStyle | undefined - - const baseStroke = linkStyle?.strokeColor ?? '#333333' - const baseLabelColor = linkStyle?.textColor ?? '#000000' - - const { displayStroke, displayLabelColor } = useMemo(() => { - if (!isDark) return { displayStroke: baseStroke, displayLabelColor: baseLabelColor } - return { - displayStroke: darkModeDisplayHex(baseStroke) ?? '#a5c9ff', - displayLabelColor: darkModeDisplayHex(baseLabelColor) ?? '#a5c9ff' - } - }, [isDark, baseStroke, baseLabelColor]) - - const strokeWidth = linkStyle?.strokeWidth ?? 1.5 - - const startKind = (linkStyle?.sourceArrowhead ?? 'none') as ArrowheadType - const endKind = (linkStyle?.targetArrowhead ?? 'none') as ArrowheadType - const startMarkerId = startKind !== 'none' - ? getMarkerId(startKind, displayStroke, strokeWidth, 'start') - : undefined - const endMarkerId = endKind !== 'none' - ? getMarkerId(endKind, displayStroke, strokeWidth, 'end') - : undefined - - // visual arrow length in px (tip to base) - const headSize = BASE_HEAD_SIZE * HEAD_SCALE - const arrowLength = headSize * (TIP_FACTOR - BASE_X_FACTOR) - // pull endpoints back so head sits off the node (scaled with head length) - const arrowOffset = arrowLength * ARROW_CLEARANCE_FACTOR + 6 - - const pathStyle = linkStyle?.pathStyle ?? 'bezier' - const isBezierPath = pathStyle === 'bezier' - - const storedBendPoint = edgeData.controlPoint - - const { - geom, - pathData, - renderedStart, - renderedEnd, - insideSegments, - bezierPoints, - displayBendPoint, - isInvalid - } = useEdgeGeometry({ - sourceGeom: sourceNodeSlice, - targetGeom: targetNodeSlice, - sourceClipGeom: attachedSourceNodeSlice, - targetClipGeom: attachedTargetNodeSlice, - linkStyle, - startKind, - endKind, - arrowOffset, - isBezierPath, - bendPointDrag, - storedBendPoint - }) - - const roughSeed = useMemo(() => hashSeed(id ?? `${source}->${target}`), [id, source, target]) - const roughDisabled = isMoving || Boolean(edgeData.labelEditing) - const isSolidStroke = (linkStyle?.strokeStyle ?? 'solid') === 'solid' - const freehandDisabled = roughDisabled || !isSolidStroke - const roughMainPath = useRoughPath(pathData?.path ?? null, { - seed: roughSeed, - strokeWidth, - disabled: roughDisabled || isSolidStroke, - }) - const freehandMainPath = useFreehandPath(pathData?.path ?? null, { - strokeWidth, - disabled: freehandDisabled, - }) - - const dashArray = useMemo(() => cssDashArray(linkStyle, strokeWidth), [linkStyle, strokeWidth]) - const hiddenDashArray = useMemo(() => { - const sw = Math.max(0.5, strokeWidth) - return `0 ${3 * sw}` - }, [strokeWidth]) - - const edgeStrokeStyle: CSSProperties = useMemo( - (): CSSProperties => ({ - ...(style as CSSProperties), - stroke: displayStroke, - strokeWidth, - strokeLinecap: 'round', - strokeLinejoin: 'round', - fill: 'none', - strokeDasharray: dashArray - }), - [style, displayStroke, strokeWidth, dashArray] - ) - - const labelText = edgeData.label?.markdown ?? '' - const hasLabel = Boolean(labelText) - const isLabelEditing = Boolean(edgeData.labelEditing) - const labelDraft = isLabelEditing ? edgeData.labelDraft ?? '' : labelText - const effectiveLabelText = isLabelEditing ? labelDraft : labelText - const estimatedLabelSize = useMemo( - () => estimateEdgeLabelSize({ - text: effectiveLabelText, - fontFamily: linkStyle?.fontFamily, - maxWidth: 200, - }), - [effectiveLabelText, linkStyle?.fontFamily], - ) - const labelInputRef = useRef(null) - const skipSaveRef = useRef(false) - - useEffect(() => { - if (!isLabelEditing) { - skipSaveRef.current = false - return - } - const raf = requestAnimationFrame(() => { - labelInputRef.current?.focus() - labelInputRef.current?.select() - }) - return () => cancelAnimationFrame(raf) - }, [isLabelEditing]) - - const handleLabelBlur = () => { - if (skipSaveRef.current) { - skipSaveRef.current = false - return - } - edgeData.onLabelSave?.() - } - - const labelTransformStyle = pathData - ? { transform: `translate(-50%, -50%) translate(${pathData.labelX}px, ${pathData.labelY}px)` } - : null - - const handleLabelKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault() - edgeData.onLabelSave?.() - } else if (event.key === 'Escape') { - event.preventDefault() - skipSaveRef.current = true - edgeData.onLabelCancel?.() - } - } - - const { dragPoint: controlPointDrag, handlePointerDown: handleControlPointPointerDown } = - useControlPointDrag({ - screenToFlowPosition, - onCommit: edgeData.onControlPointChange, - }) - - useEffect(() => { - if (controlPointDrag) { - setBendPointDrag(controlPointDrag) - return - } - setBendPointDrag(null) - }, [controlPointDrag]) - - const labelGapPaths = useMemo(() => { - if (!pathData || !bezierPoints) return null - if (!hasLabel && !isLabelEditing) return null - if (isLabelEditing) return null - - const padding = 0 - const rectX = pathData.labelX - estimatedLabelSize.width / 2 - padding - const rectY = pathData.labelY - estimatedLabelSize.height / 2 - padding - const rectW = estimatedLabelSize.width + padding * 2 - const rectH = estimatedLabelSize.height + padding * 2 - - const inside = (p: Point) => - p.x >= rectX && p.x <= rectX + rectW && p.y >= rectY && p.y <= rectY + rectH - - const samples = 60 - let t0: number | null = null - let t1: number | null = null - for (let i = 0; i <= samples; i += 1) { - const t = i / samples - const p = pointOnQuadratic(bezierPoints.p0, bezierPoints.p1, bezierPoints.p2, t) - if (inside(p)) { - if (t0 === null) t0 = t - t1 = t - } - } - - if (t0 === null || t1 === null || t1 - t0 < 1e-3) return null - - const first = t0 > 1e-3 - ? (() => { - const seg = extractQuadraticSegment(bezierPoints.p0, bezierPoints.p1, bezierPoints.p2, 0, t0) - return quadraticPath(seg.p0, seg.p1, seg.p2) - })() - : null - const second = t1 < 1 - 1e-3 - ? (() => { - const seg = extractQuadraticSegment(bezierPoints.p0, bezierPoints.p1, bezierPoints.p2, t1, 1) - return quadraticPath(seg.p0, seg.p1, seg.p2) - })() - : null - - return { - first: first?.path ?? null, - second: second?.path ?? null - } - }, [pathData, bezierPoints, estimatedLabelSize, hasLabel, isLabelEditing]) - - const roughGapFirst = useRoughPath(labelGapPaths?.first ?? null, { - seed: roughSeed + 1, - strokeWidth, - disabled: roughDisabled || isSolidStroke, - }) - const roughGapSecond = useRoughPath(labelGapPaths?.second ?? null, { - seed: roughSeed + 2, - strokeWidth, - disabled: roughDisabled || isSolidStroke, - }) - const freehandGapFirst = useFreehandPath(labelGapPaths?.first ?? null, { - strokeWidth, - disabled: freehandDisabled, - }) - const freehandGapSecond = useFreehandPath(labelGapPaths?.second ?? null, { - strokeWidth, - disabled: freehandDisabled, - }) - - if (!geom || !pathData || !renderedStart || !renderedEnd || !labelTransformStyle || isInvalid) { - return null - } - - const showControlPoint = - isBezierPath && - !!displayBendPoint && - !!edgeData.onControlPointChange && - selected && - !isLabelEditing - - const freehandFillStyle: CSSProperties = { - fill: displayStroke, - stroke: 'none', - } - const markerCarrierStyle: CSSProperties = { - fill: 'none', - stroke: 'transparent', - strokeWidth: Math.max(strokeWidth, 1), - } - - const renderHalf = ( - smoothD: string, - roughD: string | null, - freehandD: string | null, - markerStart?: string, - markerEnd?: string, - ) => { - if (freehandD) { - return ( - <> - - - - ) - } - return ( - - ) - } - - return ( - <> - {labelGapPaths ? ( - <> - - {labelGapPaths.first && renderHalf( - labelGapPaths.first, - roughGapFirst, - freehandGapFirst, - startMarkerId, - undefined, - )} - {labelGapPaths.second && renderHalf( - labelGapPaths.second, - roughGapSecond, - freehandGapSecond, - undefined, - endMarkerId, - )} - {!labelGapPaths.first && labelGapPaths.second && startMarkerId && renderHalf( - labelGapPaths.second, - roughGapSecond, - freehandGapSecond, - startMarkerId, - undefined, - )} - {!labelGapPaths.second && labelGapPaths.first && endMarkerId && renderHalf( - labelGapPaths.first, - roughGapFirst, - freehandGapFirst, - undefined, - endMarkerId, - )} - - ) : freehandMainPath ? ( - <> - - - - - ) : ( - - )} - - {selected && insideSegments.length > 0 && insideSegments.map((segment, index) => ( - - ))} - - {(isLabelEditing || hasLabel) && ( - - )} - - {showControlPoint && ( - <> - - - - )} - - ) -}) diff --git a/webui/src/features/board/components/flow/edge/use-control-point-drag.ts b/webui/src/features/board/components/flow/edge/use-control-point-drag.ts deleted file mode 100644 index a85f13e..0000000 --- a/webui/src/features/board/components/flow/edge/use-control-point-drag.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import type { Point } from './edge-geometry' - -type UseControlPointDragArgs = { - screenToFlowPosition: (point: { x: number; y: number }) => Point - onCommit?: (point: Point) => void -} - -type UseControlPointDragResult = { - dragPoint: Point | null - handlePointerDown: (event: React.PointerEvent) => void -} - -/** - * Handles control-point dragging for edge bend points. - */ -export function useControlPointDrag({ - screenToFlowPosition, - onCommit -}: UseControlPointDragArgs): UseControlPointDragResult { - const [dragPoint, setDragPoint] = useState(null) - const dragPointRef = useRef(null) - const moveRef = useRef<((event: PointerEvent) => void) | null>(null) - const upRef = useRef<((event: PointerEvent) => void) | null>(null) - - useEffect(() => { - return () => { - if (moveRef.current) { - window.removeEventListener('pointermove', moveRef.current) - } - if (upRef.current) { - window.removeEventListener('pointerup', upRef.current) - window.removeEventListener('pointercancel', upRef.current) - } - } - }, []) - - const updatePoint = (clientX: number, clientY: number) => { - const projected = screenToFlowPosition({ x: clientX, y: clientY }) - setDragPoint(projected) - dragPointRef.current = projected - } - - const handlePointerDown = (event: React.PointerEvent) => { - event.stopPropagation() - event.preventDefault() - - if (moveRef.current) { - window.removeEventListener('pointermove', moveRef.current) - moveRef.current = null - } - if (upRef.current) { - window.removeEventListener('pointerup', upRef.current) - window.removeEventListener('pointercancel', upRef.current) - upRef.current = null - } - - const handleMove = (moveEvent: PointerEvent) => { - updatePoint(moveEvent.clientX, moveEvent.clientY) - } - - const handleUp = () => { - if (moveRef.current) { - window.removeEventListener('pointermove', moveRef.current) - moveRef.current = null - } - if (upRef.current) { - window.removeEventListener('pointerup', upRef.current) - window.removeEventListener('pointercancel', upRef.current) - upRef.current = null - } - const finalPoint = dragPointRef.current - if (finalPoint) { - onCommit?.(finalPoint) - } - setDragPoint(null) - dragPointRef.current = null - } - - updatePoint(event.clientX, event.clientY) - moveRef.current = handleMove - upRef.current = handleUp - window.addEventListener('pointermove', handleMove) - window.addEventListener('pointerup', handleUp) - window.addEventListener('pointercancel', handleUp) - } - - return { dragPoint, handlePointerDown } -} diff --git a/webui/src/features/board/components/flow/edge/use-edge-geometry.ts b/webui/src/features/board/components/flow/edge/use-edge-geometry.ts deleted file mode 100644 index 3a8da63..0000000 --- a/webui/src/features/board/components/flow/edge/use-edge-geometry.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { useMemo } from 'react' -import { - getSimpleBezierPath, - getStraightPath, - getSmoothStepPath -} from '@xyflow/react' -import type { ArrowheadType, LinkStyle } from '../../../types/style' -import type { Point } from './edge-geometry' -import { - bendToControlPoint, - extractQuadraticSegment, - findExitParam, - pointOnQuadratic, - quadraticPath, - shiftPointAlong -} from './edge-geometry' -import { - getEdgeParamsFromGeometry, - nodeCenterFromGeometry, - type NodeGeometry -} from '../../../utils/flow' - -type GeometryResult = { - geom: { - sx: number - sy: number - tx: number - ty: number - edgePath: string - labelX: number - labelY: number - } | null - pathData: { path: string; labelX: number; labelY: number } | null - renderedStart: Point | null - renderedEnd: Point | null - insideSegments: string[] - bezierPoints: { p0: Point; p1: Point; p2: Point } | null - displayBendPoint: Point | null - isInvalid: boolean -} - -type Params = { - sourceGeom: NodeGeometry | null - targetGeom: NodeGeometry | null - sourceClipGeom?: NodeGeometry | null - targetClipGeom?: NodeGeometry | null - linkStyle: LinkStyle | undefined - startKind: ArrowheadType - endKind: ArrowheadType - arrowOffset: number - isBezierPath: boolean - bendPointDrag: Point | null - storedBendPoint: Point | null -} - - -/** - * Picks a default bend point that feels more like a concept-map branch than a neutral spline. - */ -function getDefaultMindMapBendPoint(sourceCenter: Point, targetCenter: Point): Point { - const dx = targetCenter.x - sourceCenter.x - const dy = targetCenter.y - sourceCenter.y - const len = Math.hypot(dx, dy) || 1 - - const midX = (sourceCenter.x + targetCenter.x) / 2 - const midY = (sourceCenter.y + targetCenter.y) / 2 - - const horizontalDirection = dx >= 0 ? 1 : -1 - const verticalRelation = dy >= 0 ? 1 : -1 - const verticalBendDirection = horizontalDirection === verticalRelation ? 1 : -1 - - const verticalOffset = Math.min(120, Math.max(20, len * 0.08)) - - return { - x: midX, - y: midY + verticalBendDirection * verticalOffset, - } -} - - -export function useEdgeGeometry({ - sourceGeom, - targetGeom, - sourceClipGeom, - targetClipGeom, - linkStyle, - startKind, - endKind, - arrowOffset, - isBezierPath, - bendPointDrag, - storedBendPoint -}: Params): GeometryResult { - const geom = useMemo(() => { - if (!sourceGeom || !targetGeom) return null - const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParamsFromGeometry(sourceGeom, targetGeom) - - let sxAdj = sx - let syAdj = sy - let txAdj = tx - let tyAdj = ty - - const dx = tx - sx - const dy = ty - sy - const len = Math.hypot(dx, dy) || 1 - - const ux = dx / len - const uy = dy / len - - if (startKind !== 'none') { - sxAdj += ux * arrowOffset - syAdj += uy * arrowOffset - } - - if (endKind !== 'none') { - txAdj -= ux * arrowOffset - tyAdj -= uy * arrowOffset - } - - const common = { - sourceX: sxAdj, - sourceY: syAdj, - sourcePosition: sourcePos, - targetX: txAdj, - targetY: tyAdj, - targetPosition: targetPos - } - - const pathKind = linkStyle?.pathStyle ?? 'bezier' - let edgePath: string - let labelX: number - let labelY: number - - if (pathKind === 'straight') { - ;[edgePath, labelX, labelY] = getStraightPath(common) - } else if (pathKind === 'polyline') { - ;[edgePath, labelX, labelY] = getSmoothStepPath(common) - } else { - ;[edgePath, labelX, labelY] = getSimpleBezierPath(common) - } - - return { sx: sxAdj, sy: syAdj, tx: txAdj, ty: tyAdj, edgePath, labelX, labelY } - }, [sourceGeom, targetGeom, linkStyle?.pathStyle, startKind, endKind, arrowOffset]) - - const sourceCenter = useMemo(() => { - if (sourceGeom) return nodeCenterFromGeometry(sourceGeom) - if (!geom) return null - return { x: geom.sx, y: geom.sy } - }, [sourceGeom, geom]) - - const targetCenter = useMemo(() => { - if (targetGeom) return nodeCenterFromGeometry(targetGeom) - if (!geom) return null - return { x: geom.tx, y: geom.ty } - }, [targetGeom, geom]) - - const fallbackBendPoint: Point | null = useMemo(() => { - if (!isBezierPath) return null - if (!sourceCenter || !targetCenter) return null - return getDefaultMindMapBendPoint(sourceCenter, targetCenter) - }, [isBezierPath, sourceCenter, targetCenter]) - - const geometryResult = useMemo(() => { - if (!geom) { - return { - pathData: null, - renderedStart: null, - renderedEnd: null, - insideSegments: [], - bezierPoints: null, - displayBendPoint: null, - isInvalid: true - } - } - - let pathData: { path: string, labelX: number, labelY: number } | null = null - let renderedStart: Point | null = null - let renderedEnd: Point | null = null - const insideSegments: string[] = [] - let bezierPoints: { p0: Point; p1: Point; p2: Point } | null = null - let displayBendPoint: Point | null = null - let isInvalid = false - - if (isBezierPath) { - if (!sourceCenter || !targetCenter || !fallbackBendPoint) { - isInvalid = true - } else { - displayBendPoint = bendPointDrag ?? storedBendPoint ?? fallbackBendPoint - const shouldUseControlPoint = Boolean(bendPointDrag || storedBendPoint) - const activeBend = shouldUseControlPoint ? displayBendPoint ?? fallbackBendPoint : fallbackBendPoint - const centerControl = bendToControlPoint(activeBend, sourceCenter, targetCenter) - const pointGetter = (t: number) => pointOnQuadratic(sourceCenter, centerControl, targetCenter, t) - const clipSource = sourceClipGeom ?? sourceGeom ?? null - const clipTarget = targetClipGeom ?? targetGeom ?? null - const startExit = findExitParam(clipSource, pointGetter) - const endExit = 1 - findExitParam(clipTarget, (t: number) => pointGetter(1 - t)) - const trimmed = extractQuadraticSegment(sourceCenter, centerControl, targetCenter, startExit, endExit) - const hasSourceClip = Boolean(clipSource) - const hasTargetClip = Boolean(clipTarget) - const minGap = 1e-3 - - if (hasSourceClip && startExit > minGap && startExit < endExit) { - const hiddenStart = extractQuadraticSegment(sourceCenter, centerControl, targetCenter, 0, startExit) - insideSegments.push(quadraticPath(hiddenStart.p0, hiddenStart.p1, hiddenStart.p2).path) - } - - if (hasTargetClip && endExit < 1 - minGap && endExit > startExit) { - const hiddenEnd = extractQuadraticSegment(sourceCenter, centerControl, targetCenter, endExit, 1) - insideSegments.push(quadraticPath(hiddenEnd.p0, hiddenEnd.p1, hiddenEnd.p2).path) - } - - const startPoint = startKind !== 'none' - ? shiftPointAlong(trimmed.p0, trimmed.p1, arrowOffset) - : trimmed.p0 - - const endPoint = endKind !== 'none' - ? shiftPointAlong(trimmed.p2, trimmed.p1, arrowOffset) - : trimmed.p2 - - renderedStart = startPoint - renderedEnd = endPoint - bezierPoints = { p0: startPoint, p1: trimmed.p1, p2: endPoint } - - pathData = quadraticPath(startPoint, trimmed.p1, endPoint) - } - } else { - renderedStart = { x: geom.sx, y: geom.sy } - renderedEnd = { x: geom.tx, y: geom.ty } - pathData = { path: geom.edgePath, labelX: geom.labelX, labelY: geom.labelY } - } - - return { pathData, renderedStart, renderedEnd, insideSegments, bezierPoints, displayBendPoint, isInvalid } - }, [ - geom, - isBezierPath, - sourceCenter, - targetCenter, - fallbackBendPoint, - bendPointDrag, - storedBendPoint, - startKind, - endKind, - arrowOffset, - sourceGeom, - targetGeom, - sourceClipGeom, - targetClipGeom - ]) - - return { - geom, - ...geometryResult, - } -} diff --git a/webui/src/features/board/components/flow/edge/use-freehand-path.ts b/webui/src/features/board/components/flow/edge/use-freehand-path.ts deleted file mode 100644 index 068d3a5..0000000 --- a/webui/src/features/board/components/flow/edge/use-freehand-path.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { useMemo } from 'react' -import { getStroke } from 'perfect-freehand' - - -type Point = [number, number] - - -/** - * Sample an SVG `d` (M/L/Q only) into a dense point list along the path. - * Quadratic segments are subdivided into `samples` pieces per command. - */ -function samplePathToPoints(d: string, samples = 24): Point[] { - const tokens = d.match(/[MLQZmlqz]|-?\d*\.?\d+(?:e[+-]?\d+)?/gi) - if (!tokens) return [] - const points: Point[] = [] - let cx = 0 - let cy = 0 - let i = 0 - while (i < tokens.length) { - const cmd = tokens[i] - i += 1 - if (cmd === 'M' || cmd === 'm' || cmd === 'L' || cmd === 'l') { - const x = parseFloat(tokens[i]); i += 1 - const y = parseFloat(tokens[i]); i += 1 - cx = x; cy = y - points.push([x, y]) - } else if (cmd === 'Q' || cmd === 'q') { - const x1 = parseFloat(tokens[i]); i += 1 - const y1 = parseFloat(tokens[i]); i += 1 - const x2 = parseFloat(tokens[i]); i += 1 - const y2 = parseFloat(tokens[i]); i += 1 - for (let s = 1; s <= samples; s += 1) { - const t = s / samples - const u = 1 - t - const sx = u * u * cx + 2 * u * t * x1 + t * t * x2 - const sy = u * u * cy + 2 * u * t * y1 + t * t * y2 - points.push([sx, sy]) - } - cx = x2; cy = y2 - } else if (cmd === 'Z' || cmd === 'z') { - // ignore - } else { - return [] - } - } - return points -} - - -/** - * Convert a ring of polygon vertices from perfect-freehand into a closed SVG - * path `d`. Uses quadratic midpoints for soft corners (same trick tldraw uses). - */ -function outlineToSvgPath(stroke: number[][]): string { - if (stroke.length === 0) return '' - if (stroke.length < 3) { - const [first] = stroke - return `M ${first[0].toFixed(2)} ${first[1].toFixed(2)} Z` - } - const parts: string[] = [] - const [x0, y0] = stroke[0] - parts.push(`M ${x0.toFixed(2)} ${y0.toFixed(2)}`) - for (let i = 0; i < stroke.length; i += 1) { - const [x1, y1] = stroke[i] - const [x2, y2] = stroke[(i + 1) % stroke.length] - const mx = (x1 + x2) / 2 - const my = (y1 + y2) / 2 - parts.push(`Q ${x1.toFixed(2)} ${y1.toFixed(2)} ${mx.toFixed(2)} ${my.toFixed(2)}`) - } - parts.push('Z') - return parts.join(' ') -} - - -type UseFreehandPathOptions = { - strokeWidth: number - disabled?: boolean -} - - -/** - * Produce a tapered, hand-drawn SVG `d` (a filled polygon outline) from an - * input path `d`. Samples the path, runs perfect-freehand with a pressure - * profile that's thin at the endpoints and fatter in the middle, then closes - * the outline. Returns `null` when disabled or on failure — caller should fall - * back to the normal stroked render. - */ -export function useFreehandPath( - d: string | null | undefined, - { strokeWidth, disabled }: UseFreehandPathOptions, -): string | null { - return useMemo(() => { - if (!d || disabled) return null - try { - const rawPoints = samplePathToPoints(d) - if (rawPoints.length < 2) return null - - // pressure profile: slight ramp-in, steady middle, slight ramp-out - const withPressure = rawPoints.map(([x, y], i) => { - const t = i / (rawPoints.length - 1 || 1) - const bell = Math.sin(Math.PI * t) - const pressure = 0.35 + 0.55 * bell - return [x, y, pressure] as [number, number, number] - }) - - const size = Math.max(1.2, strokeWidth) * 1.3 - const stroke = getStroke(withPressure, { - size, - thinning: 0.55, - smoothing: 0.6, - streamline: 0.55, - simulatePressure: false, - last: true, - start: { taper: Math.min(24, size * 3), cap: true }, - end: { taper: Math.min(24, size * 3), cap: true }, - }) - if (!stroke || stroke.length === 0) return null - return outlineToSvgPath(stroke) - } catch { - return null - } - }, [d, strokeWidth, disabled]) -} diff --git a/webui/src/features/board/components/flow/edge/use-rough-path.ts b/webui/src/features/board/components/flow/edge/use-rough-path.ts deleted file mode 100644 index ff0c6b4..0000000 --- a/webui/src/features/board/components/flow/edge/use-rough-path.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { useMemo } from 'react' -import { RoughGenerator } from 'roughjs/bin/generator' -import type { Point } from 'roughjs/bin/geometry' - - -let generatorInstance: RoughGenerator | null = null - - -function getGenerator(): RoughGenerator { - if (!generatorInstance) generatorInstance = new RoughGenerator() - return generatorInstance -} - - -/** - * Deterministic hash → positive integer seed for rough.js. - */ -export function hashSeed(input: string): number { - let h = 5381 - for (let i = 0; i < input.length; i += 1) { - h = ((h << 5) + h) ^ input.charCodeAt(i) - } - return Math.abs(h) % 2_000_000_000 || 1 -} - - -/** - * Sample an SVG `d` (M/L/Q only) into a point list along the path. Quadratic - * segments are subdivided into `samples` pieces. Points feed rough.js's - * `curve`/`linearPath`, which produce a single continuous sketchy stroke — - * unlike `generator.path`, which jitters bezier control points and looks - * smooth, or a polyline-`d` route, which renders as disconnected scribbles. - */ -function samplePathToPoints(d: string, samples = 3): Point[] { - const tokens = d.match(/[MLQZmlqz]|-?\d*\.?\d+(?:e[+-]?\d+)?/gi) - if (!tokens) return [] - const points: Point[] = [] - let cx = 0 - let cy = 0 - let i = 0 - while (i < tokens.length) { - const cmd = tokens[i] - i += 1 - if (cmd === 'M' || cmd === 'm' || cmd === 'L' || cmd === 'l') { - const x = parseFloat(tokens[i]); i += 1 - const y = parseFloat(tokens[i]); i += 1 - cx = x; cy = y - points.push([x, y]) - } else if (cmd === 'Q' || cmd === 'q') { - const x1 = parseFloat(tokens[i]); i += 1 - const y1 = parseFloat(tokens[i]); i += 1 - const x2 = parseFloat(tokens[i]); i += 1 - const y2 = parseFloat(tokens[i]); i += 1 - for (let s = 1; s <= samples; s += 1) { - const t = s / samples - const u = 1 - t - const sx = u * u * cx + 2 * u * t * x1 + t * t * x2 - const sy = u * u * cy + 2 * u * t * y1 + t * t * y2 - points.push([sx, sy]) - } - cx = x2; cy = y2 - } else if (cmd === 'Z' || cmd === 'z') { - // ignore - } else { - return [] - } - } - return points -} - - -type UseRoughPathOptions = { - seed: number - roughness?: number - bowing?: number - strokeWidth: number - disabled?: boolean -} - - -/** - * Produce a sketchy SVG `d` variant of the input path. Samples the path into - * points, then uses rough.js's curve/linearPath to emit a single continuous - * hand-drawn stroke. Returns the input `d` when disabled or on failure. - */ -export function useRoughPath( - d: string | null | undefined, - { seed, roughness = 1.2, bowing = 1, strokeWidth, disabled }: UseRoughPathOptions, -): string | null { - return useMemo(() => { - if (!d) return null - if (disabled) return d - try { - const generator = getGenerator() - const points = samplePathToPoints(d) - if (points.length < 2) return d - const options = { - roughness, - bowing, - stroke: '#000', - strokeWidth, - seed: seed || 1, - disableMultiStroke: true, - preserveVertices: false, - } - const drawable = points.length >= 3 - ? generator.curve(points, options) - : generator.linearPath(points, options) - const strokeSet = drawable.sets.find(set => set.type === 'path') - if (!strokeSet) return d - return generator.opsToPath(strokeSet, 2) - } catch { - return d - } - }, [d, seed, roughness, bowing, strokeWidth, disabled]) -} diff --git a/webui/src/features/board/components/flow/floating-assistant/floating-island.tsx b/webui/src/features/board/components/flow/floating-assistant/floating-island.tsx index 94642f2..c0008f9 100644 --- a/webui/src/features/board/components/flow/floating-assistant/floating-island.tsx +++ b/webui/src/features/board/components/flow/floating-assistant/floating-island.tsx @@ -9,7 +9,7 @@ import { useSubmitPrompt } from "@/features/agent/hooks/use-submit-prompt" import { buildMessageContext, useHasMessageContext } from "@/features/agent/hooks/use-message-context" import { ProgressLine } from "./progress-line" import { useCurrentAssistantMessage } from "./use-current-assistant-message" -import { useGraphStore } from "../../../store/graph-store" +import { useBoardAppStore } from "../../../harness/store/board-app-store" export interface FloatingIslandProps { @@ -30,7 +30,7 @@ export const FloatingIsland = ({ boardId, onOpenFullSheet }: FloatingIslandProps const hasMessageContext = useHasMessageContext() // Single boolean derivation — only re-renders when a surface opens or // closes, never when its content/title changes. Cheap. - const hasActiveSurface = useGraphStore((s) => Boolean(s.activeNodeSurface)) + const hasActiveSurface = useBoardAppStore((s) => Boolean(s.activeNodeSurface)) const handleSubmit = async () => { if (isStreaming) return diff --git a/webui/src/features/board/components/flow/folder-node.tsx b/webui/src/features/board/components/flow/folder-node.tsx deleted file mode 100644 index 581c7fe..0000000 --- a/webui/src/features/board/components/flow/folder-node.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { memo, useMemo } from "react" -import { FolderIcon } from "@/components/icons" -import type { NoteNode } from "../../types/flow" -import { useTheme } from "@/components/theme-provider" -import { darkModeDisplayHex } from "../../lib/colors/dark-variants" -import { isTransparent } from "../../lib/colors/tailwind" -import { fontFamilyToTwClass, fontSizeToTwClass, textStyleToTwClass } from "../../types/style" -import { NodeTitleCaption } from "./node-title-caption" - - -type FolderNodeProps = { - id: string - data: NoteNode["data"] -} - - -export const FolderNode = memo(function FolderNode({ id, data }: FolderNodeProps) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - const textAlignClass = data.style.textAlign === 'left' ? 'text-left' : data.style.textAlign === 'right' ? 'text-right' : 'text-center' - const fontClass = fontFamilyToTwClass(data.style.fontFamily) - const sizeClass = fontSizeToTwClass(data.style.fontSize) - const textStyleClass = textStyleToTwClass(data.style.textStyle) - - const displayTextColor = useMemo(() => ( - isDark ? darkModeDisplayHex(data.style.textColor) ?? '#e4e4e7' : data.style.textColor - ), [data.style.textColor, isDark]) - - const displayStrokeColor = useMemo(() => { - if (!isTransparent(data.style.strokeColor)) { - return isDark ? darkModeDisplayHex(data.style.strokeColor) ?? '#1e1e1e' : data.style.strokeColor - } - - return displayTextColor - }, [data.style.strokeColor, displayTextColor, isDark]) - - const captionTextStyle = useMemo(() => ({ color: displayTextColor }), [displayTextColor]) - - return ( -
-
- -
- -
- -
-
- ) -}) diff --git a/webui/src/features/board/components/flow/graph-canvas.tsx b/webui/src/features/board/components/flow/graph-canvas.tsx deleted file mode 100644 index ba5db58..0000000 --- a/webui/src/features/board/components/flow/graph-canvas.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { - ReactFlow, - MarkerType, - Background, - BackgroundVariant, - SelectionMode, - type ReactFlowInstance, - type ReactFlowProps, -} from '@xyflow/react' -import { memo, useCallback, useMemo } from 'react' -import { useShallow } from 'zustand/shallow' - -import { NodeView } from './node-view' -import { PointNode } from './point-node' -import { DocumentNode } from './document-node' -import { EdgeView } from './edge/edge-view' -import { EdgeMarkerDefs } from './edge/edge-markers' -import { GraphContextMenu } from './graph-context-menu' -import { MotionProvider, ZoomProvider } from './motion-state-context' -import { useGraphStore } from '../../store/graph-store' -import { useEdgeLabelEdit } from '../../hooks/use-edge-label-edit' -import { useNodeViewportCull } from '../../hooks/use-node-viewport-cull' -import type { LinkEdge, NoteNode } from '../../types/flow' -import { StackedCardsIllustration } from '@/components/illustrations/stacked-cards-illustration' -import type { BoardBackgroundTexture } from '../../utils/board-background' - - -const proOptions = { hideAttribution: true } - -const nodeTypes = { default: NodeView, point: PointNode, document: DocumentNode } -const edgeTypes = { default: EdgeView } - -const defaultEdgeOptions = { - type: 'default', - zIndex: 1000, - style: { - stroke: 'var(--secondary-foreground)', - strokeWidth: 2, - strokeDasharray: '8 6', - strokeLinecap: 'round' as const, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: 'var(--secondary-foreground)', - width: 22, - height: 22, - }, -} - - -type EmptyGraphHintProps = { - isMobile: boolean -} - - -/** - * Centered empty-state illustration with onboarding hint. - */ -const EmptyGraphHint = memo(function EmptyGraphHint({ isMobile }: EmptyGraphHintProps) { - return ( -
-
-
-
- ) -}) - - -export type GraphCanvasProps = { - isLocked: boolean - isMobile: boolean - presentationMode: boolean - onInit: (instance: ReactFlowInstance) => void - onNodeDoubleClick: ReactFlowProps['onNodeDoubleClick'] - onSelectionStart: () => void - onSelectionEnd: () => void - onSelectionDragStart: () => void - onSelectionDragStop: () => void - isGraphView: boolean - /** - * Optional content rendered inside the ReactFlow children slot - * (e.g. floating viewport controls). Stays inside the graph viewport. - */ - children?: React.ReactNode -} - - -/** - * Owns the React Flow surface and its directly-driven subscriptions - * (nodes, edges, change handlers, edge label state, background variant). - * Re-renders only when one of those changes; motion flags and chrome - * state live in the parent and floating chrome respectively. - */ -export const GraphCanvas = memo(function GraphCanvas({ - isLocked, - isMobile, - presentationMode, - onInit, - onNodeDoubleClick, - onSelectionStart, - onSelectionEnd, - onSelectionDragStart, - onSelectionDragStop, - isGraphView, - children, -}: GraphCanvasProps) { - const nodes = useGraphStore(useShallow(state => state.nodes)) - const edges = useGraphStore(useShallow(state => state.edges)) - - const onNodesChange = useGraphStore(state => state.onNodesChange) - const onEdgesChange = useGraphStore(state => state.onEdgesChange) - const onNodesDelete = useGraphStore(state => state.onNodesDelete) - const onEdgesDelete = useGraphStore(state => state.onEdgesDelete) - const setNodesPersist = useGraphStore(state => state.setNodesPersist) - const enableSelection = useGraphStore(state => state.isSelectMode) - const boardBackgroundTexture = useGraphStore(state => state.boardBackgroundTexture) - - const backgroundVariant = useMemo(() => { - if (!boardBackgroundTexture) return null - const variants: Record = { - dots: BackgroundVariant.Dots, - lines: BackgroundVariant.Lines, - } - return variants[boardBackgroundTexture] - }, [boardBackgroundTexture]) - - const backgroundColor = useMemo(() => { - if (!boardBackgroundTexture) return undefined - return boardBackgroundTexture === 'lines' - ? 'var(--muted)' - : 'var(--muted-foreground)' - }, [boardBackgroundTexture]) - - useNodeViewportCull() - - const { edgesForRender, handleEdgeDoubleClick } = useEdgeLabelEdit({ - edges, - isGraphView, - }) - - const handleDragStart = useCallback(() => { - useGraphStore.getState().setIsDragging(true) - }, []) - const handleDragStop = useCallback(() => { - useGraphStore.getState().setIsDragging(false) - }, []) - - const isEmptyGraph = nodes.length === 0 && edges.length === 0 - - return ( - - {({ onPaneContextMenu, onNodeContextMenu }) => ( - - - {isEmptyGraph && !presentationMode && ( - - )} - - - {backgroundVariant && ( - - )} - - {children} - - - - )} - - ) -}) diff --git a/webui/src/features/board/components/flow/graph-context-menu.tsx b/webui/src/features/board/components/flow/graph-context-menu.tsx deleted file mode 100644 index 49c4383..0000000 --- a/webui/src/features/board/components/flow/graph-context-menu.tsx +++ /dev/null @@ -1,454 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { ReactFlowProps } from '@xyflow/react' -import { Clipboard, StackMinus, StackPlus } from '@phosphor-icons/react' - -import type { LinkEdge, NoteNode } from '../../types/flow' -import { - ArticleSummaryIcon, - ChatTranslateIcon as ChatTranslateSharedIcon, - ChevronDownIcon, - ChevronRightIcon, - DrawIcon, - SchemaMapIcon, - SparklesIcon, - TreeMapIcon, -} from '@/components/icons' -import { useGraphStore } from '../../store/graph-store' -import { buildContextTextFromNodes } from '../../utils/context-text' -import { toast } from 'sonner' -import { useAiSparkActions } from '../../hooks/use-ai-spark-actions' -import { useExportSelectionPng } from '../../hooks/use-export-selection-png' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' - -/** - * Props for the GraphContextMenu component. - */ -type GraphContextMenuProps = { - nodes: NoteNode[] - setNodesPersist: (updater: (prev: NoteNode[]) => NoteNode[]) => void - children: (handlers: { - onPaneContextMenu: NonNullable['onPaneContextMenu']> - onNodeContextMenu: NonNullable['onNodeContextMenu']> - }) => React.ReactNode -} - -/** - * A context menu for the graph that appears on right-clicking - * selected nodes or the pane when nodes are selected. - * Allows changing z-index and performing AI actions on selected nodes. - */ -export function GraphContextMenu({ nodes, setNodesPersist, children }: GraphContextMenuProps) { - const [menuPosition, setMenuPosition] = useState<{ x: number, y: number } | null>(null) - const [customLanguage, setCustomLanguage] = useState('') - const [translateOpen, setTranslateOpen] = useState(false) - const [exportTransparentBackground, setExportTransparentBackground] = useState(false) - - const stats = useMemo(() => { - let globalMin = Number.POSITIVE_INFINITY - let globalMax = Number.NEGATIVE_INFINITY - let selectedMin = Number.POSITIVE_INFINITY - let selectedMax = Number.NEGATIVE_INFINITY - const selectedSet = new Set() - - for (const node of nodes) { - const kind = (node.data as { kind?: string } | undefined)?.kind - const nodeType = (node.data as { style?: { type?: string } } | undefined)?.style?.type - if (kind === 'point' || nodeType === 'slide') continue - const z = node.zIndex ?? 0 - if (z < globalMin) globalMin = z - if (z > globalMax) globalMax = z - - if (node.selected) { - selectedSet.add(node.id) - if (z < selectedMin) selectedMin = z - if (z > selectedMax) selectedMax = z - } - } - - const hasSelection = selectedSet.size > 0 - - return { - selectedSet, - hasSelection, - globalMin: globalMin === Number.POSITIVE_INFINITY ? 0 : globalMin, - globalMax: globalMax === Number.NEGATIVE_INFINITY ? 0 : globalMax, - selectedMin: selectedMin === Number.POSITIVE_INFINITY ? 0 : selectedMin, - selectedMax: selectedMax === Number.NEGATIVE_INFINITY ? 0 : selectedMax, - } - }, [nodes]) - - const { selectedSet, hasSelection, globalMin, globalMax, selectedMin, selectedMax } = stats - const canSendBackward = hasSelection && selectedMin > globalMin - const canSendForward = hasSelection && selectedMax < globalMax - const boardId = useGraphStore(state => state.boardId) - const edges = useGraphStore(state => state.edges) - const setNodes = useGraphStore(state => state.setNodes) - const setEdges = useGraphStore(state => state.setEdges) - const { actions: aiActions, processingKey, runAction } = useAiSparkActions() - const aiMenuActions = useMemo( - () => aiActions.filter(action => action.key !== 'translate'), - [aiActions], - ) - const positionTooltips = useMemo(() => ({ - sendBackward: "Move the selection one layer down.", - sendForward: "Move the selection one layer up.", - sendToBack: "Move the selection to the very back.", - sendToFront: "Move the selection to the very front.", - }), []) - const aiTooltips = useMemo(() => ({ - summarize: "Write a concise summary of the selected content.", - mapify: "Generate a mindmap of key ideas and relationships.", - schemify: "Generate a structured schema of entities and links.", - quizify: "Generate grouped MCQ exercises from the content.", - drawify: "Draw a simple architecture/schema.", - explain: "Explain the content in more detail, step by step.", - }), []) - - const selectedNodes = useMemo( - () => nodes.filter((node) => node.selected && (node.data as { kind?: string } | undefined)?.kind !== 'point'), - [nodes], - ) - - const openMenuAt = useCallback((x: number, y: number) => { - setMenuPosition({ x, y }) - }, []) - - const openMenu = useCallback((event: React.MouseEvent) => { - event.preventDefault() - openMenuAt(event.clientX, event.clientY) - }, [openMenuAt]) - - const handlePaneContextMenu = useCallback['onPaneContextMenu']>>((event) => { - if (!hasSelection) return - openMenu(event as React.MouseEvent) - }, [hasSelection, openMenu]) - - const handleNodeContextMenu = useCallback['onNodeContextMenu']>>((event, node) => { - if (!node?.selected) return - openMenu(event as React.MouseEvent) - }, [openMenu]) - - useEffect(() => { - if (!menuPosition) return - const close = (event: MouseEvent) => { - const target = event.target as HTMLElement | null - if (target?.closest('[data-graph-context-menu="true"]')) return - setMenuPosition(null) - setTranslateOpen(false) - setExportTransparentBackground(false) - } - const listenerOptions: AddEventListenerOptions = { capture: true } - window.addEventListener('mousedown', close, listenerOptions) - return () => window.removeEventListener('mousedown', close, listenerOptions) - }, [menuPosition]) - - useEffect(() => { - const onSelectionContextMenu = (event: MouseEvent) => { - if (!hasSelection) return - const target = event.target as HTMLElement | null - const selectionTarget = target?.closest( - '.react-flow__selection, .react-flow__selectionpane, .react-flow__nodesselection, .react-flow__nodesselection-rect, .react-flow__pane.selection', - ) - if (!selectionTarget) return - event.preventDefault() - event.stopPropagation() - openMenuAt(event.clientX, event.clientY) - } - const listenerOptions: AddEventListenerOptions = { capture: true } - window.addEventListener('contextmenu', onSelectionContextMenu, listenerOptions) - return () => window.removeEventListener('contextmenu', onSelectionContextMenu, listenerOptions) - }, [hasSelection, openMenuAt]) - - const applyToSelected = useCallback((updater: (z: number) => number) => { - if (selectedSet.size === 0) return - setNodesPersist(prev => - prev.map(node => { - if (!selectedSet.has(node.id)) return node - const kind = (node.data as { kind?: string } | undefined)?.kind - const nodeType = (node.data as { style?: { type?: string } } | undefined)?.style?.type - if (kind === 'point' || nodeType === 'slide') return node - const currentZ = node.zIndex ?? 0 - return { ...node, zIndex: updater(currentZ) } - }), - ) - setMenuPosition(null) - }, [selectedSet, setNodesPersist]) - - const handleSendBackward = useCallback(() => { - if (!canSendBackward) return - applyToSelected(z => z - 1) - }, [applyToSelected, canSendBackward]) - - const handleSendForward = useCallback(() => { - if (!canSendForward) return - applyToSelected(z => z + 1) - }, [applyToSelected, canSendForward]) - - const handleSendToBack = useCallback(() => { - if (selectedSet.size === 0) return - const target = globalMin - 1 - applyToSelected(() => target) - }, [applyToSelected, globalMin, selectedSet.size]) - - const handleSendToFront = useCallback(() => { - if (selectedSet.size === 0) return - const target = globalMax + 1 - applyToSelected(() => target) - }, [applyToSelected, globalMax, selectedSet.size]) - - const handleAiAction = useCallback(async (actionKey: string) => { - if (!boardId) { - toast.error("Select a board first.") - return - } - const contextText = buildContextTextFromNodes(selectedNodes) - if (!contextText) { - toast.error("Select at least one node with content.") - return - } - setMenuPosition(null) - await runAction({ boardId, contextText, actionKey }) - }, [boardId, runAction, selectedNodes]) - - const commonLanguages = [ - 'English', - 'French', - 'Spanish', - 'Chinese', - 'Japanese', - 'Korean', - 'German', - 'Portuguese', - ] - - const handleTranslate = useCallback(async (language: string) => { - if (!boardId) { - toast.error("Select a board first.") - return - } - const contextText = buildContextTextFromNodes(selectedNodes, { skipPrefix: true }) - if (!contextText) { - toast.error("Select at least one node with content.") - return - } - setMenuPosition(null) - setTranslateOpen(false) - await runAction({ - boardId, - contextText, - actionKey: 'translate', - targetLanguage: language, - }) - }, [boardId, runAction, selectedNodes]) - - const { exportSelectionPng } = useExportSelectionPng({ - nodes, - edges, - selectedNodes, - setNodes, - setEdges, - onSuccess: () => { - setMenuPosition(null) - setExportTransparentBackground(false) - }, - }) - - return ( - <> - {children({ - onPaneContextMenu: handlePaneContextMenu, - onNodeContextMenu: handleNodeContextMenu, - })} - - {menuPosition && ( -
event.stopPropagation()} - onContextMenu={event => event.preventDefault()} - role='menu' - data-graph-context-menu="true" - > -
- Position -
- - - - - -
{positionTooltips.sendBackward}
-
-
- - - - - -
{positionTooltips.sendForward}
-
-
- - - - - -
{positionTooltips.sendToBack}
-
-
- - - - - -
{positionTooltips.sendToFront}
-
-
-
-
- Export -
- -
- Transparent background -
- - -
-
- - {hasSelection && ( - <> -
-
- - AI Spark -
- {aiMenuActions.map((action) => ( - - - - - -
{aiTooltips[action.key as keyof typeof aiTooltips] || action.request}
-
-
- ))} -
- - {translateOpen && ( -
-
- setCustomLanguage(event.target.value)} - onMouseDown={(event) => event.stopPropagation()} - /> - -
-
- {commonLanguages.map((language) => ( - - ))} -
-
- )} - - )} -
- )} - - ) -} diff --git a/webui/src/features/board/components/flow/graph-editor.tsx b/webui/src/features/board/components/flow/graph-editor.tsx deleted file mode 100644 index bbf1ec5..0000000 --- a/webui/src/features/board/components/flow/graph-editor.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { - useReactFlow, - useOnViewportChange, - type ReactFlowInstance, - type ReactFlowProps, -} from '@xyflow/react' -import '@xyflow/react/dist/base.css' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { useShallow } from 'zustand/shallow' - -import { ActionPanel } from './action-panel' -import { DefaultBoardView } from '../default-view' -import { NodePlacementOverlay } from './node-placement-overlay' -import { LinePlacementOverlay } from './line-placement-overlay' -import { NodeSurfaceHost } from './node-surface-host' -import { GraphCanvas } from './graph-canvas' -import { GraphFloatingChrome } from './graph-floating-chrome' - -import { useGraphStore } from '../../store/graph-store' -import { setLastCursorPosition } from '../../store/cursor-ref' -import type { LinkEdge, NoteNode } from '../../types/flow' -import type { NodeType } from '../../types/style' - -import { useAddNoteNode, type AddNoteNodeOptions } from '../../hooks/use-add-node' -import { usePlaceLine } from '../../hooks/use-place-line' -import { useMindMapStore } from '@/features/agent/store/mindmap-store' -import { useAddMindMapToBoard } from '../../api/add-mindmap-to-board' -import { useCopyPasteNodes } from '../../hooks/use-copy-paste' -import { useCenterAroundParam } from '../../hooks/use-center-around' -import { useBoardShortcuts } from '../../hooks/use-board-shortcuts' -import { useDropImageUpload } from '../../hooks/use-drop-image-upload' -import { PresentationControls } from './presentation-controls' -import { useTheme } from '@/components/theme-provider' -import { useIsMobile } from '@/hooks/use-mobile' -import { darkModeDisplayHex } from '../../lib/colors/dark-variants' -import { applyBackgroundAlpha } from '../../utils/board-background' - -import './graph-styles.css' -import { useThumbnailCapture } from '../../hooks/use-thumbnail-capture' -import { ListView } from './list-view' - -const drawableNodeTypes: NodeType[] = [ - 'rectangle', - 'ellipse', - 'diamond', - 'soft-diamond', - 'layered-diamond', - 'layered-circle', - 'tag', - 'layered-rectangle', - 'thought-cloud', - 'capsule', - 'slide', -] - -const isDrawableNodeType = (nodeType: NodeType) => drawableNodeTypes.includes(nodeType) - -type ViewMode = 'graph' | 'linear' | 'list' - - -/** - * Files view (card-based board list) - */ -function LinearView() { - return -} - -/** - * Main editor: always shows ActionPanel and switches between GraphView / LinearView - */ -export default function GraphEditor() { - const [viewMode, setViewMode] = useState('graph') - const isMobile = useIsMobile() - - const enableSelection = useGraphStore(state => state.isSelectMode) - const [shouldRecenter, setShouldRecenter] = useState(false) - const [isLocked, setIsLocked] = useState(false) - const [isSelecting, setIsSelecting] = useState(false) - const [pendingPlacement, setPendingPlacement] = useState(null) - const { - pending: pendingLinePlacement, - begin: beginLinePlacement, - cancel: cancelLinePlacement, - place: handlePlaceLine, - } = usePlaceLine() - - const { - zoomIn, - zoomOut, - fitView, - viewportInitialized, - zoomTo, - screenToFlowPosition, - setCenter, - } = useReactFlow() - - const boardId = useGraphStore(state => state.boardId) - const boardCanEdit = useGraphStore(state => state.boardCanEdit) - const rootId = useGraphStore(state => state.rootId) - const scopeViewportKey = boardId ? `${boardId}:${rootId ?? 'root'}` : undefined - const navigate = useNavigate() - - // Parent-only reactive subs. Heavy data lives in ; motion - // flags + chrome state live in . - const slides = useGraphStore(useShallow(state => - (state.nodes as NoteNode[]) - .filter(n => n.data?.style?.type === 'slide') - .sort((a, b) => - (a.data.properties.slideNumber?.number ?? 0) - - (b.data.properties.slideNumber?.number ?? 0), - ), - )) - const graphViewports = useGraphStore(useShallow(state => state.graphViewports)) - const presentationMode = useGraphStore(state => state.presentationMode) - const activeSlideId = useGraphStore(state => state.activeSlideId) - const boardBackground = useGraphStore(state => state.boardBackground) - const effectiveIsLocked = isLocked || !boardCanEdit - - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - const displayBoardBackground = applyBackgroundAlpha( - isDark ? darkModeDisplayHex(boardBackground) || boardBackground : boardBackground, - 0.5 - ) || undefined - - useEffect(() => { - const renderer = document.querySelector('.react-flow__renderer') as HTMLElement | null - if (!renderer) return - - const setSize = useGraphStore.getState().setRendererSize - const updateSize = () => { - const rect = renderer.getBoundingClientRect() - setSize({ width: rect.width, height: rect.height }) - } - - updateSize() - const observer = new ResizeObserver(updateSize) - observer.observe(renderer) - - return () => { - observer.disconnect() - setSize(null) - } - }, []) - - const mindmaps = useMindMapStore(state => state.mindmaps) - const { addMindMapToBoardAsync } = useAddMindMapToBoard() - - useCopyPasteNodes({ - jitterMax: 40, - shortcuts: true, - }) - - useCenterAroundParam({ setCenter }) - - useBoardShortcuts({ - enabled: viewMode === 'graph', - shortcuts: [ - { key: 'z', withMod: true, withShift: false, handler: () => useGraphStore.getState().undo() }, - { key: 'z', withMod: true, withShift: true, handler: () => useGraphStore.getState().redo() }, - { key: 'y', withMod: true, handler: () => useGraphStore.getState().redo() }, - ], - }) - - const addNoteNode = useAddNoteNode() - - const beginPlacement = useCallback((options: AddNoteNodeOptions) => { - if (!options.nodeType) return - setPendingPlacement({ - ...options, - position: undefined, - size: undefined, - }) - }, []) - - const cancelPlacement = useCallback(() => { - setPendingPlacement(null) - }, []) - - const handlePlacementComplete = useCallback( - (options: AddNoteNodeOptions) => { - addNoteNode(options) - setPendingPlacement(null) - }, - [addNoteNode], - ) - - useEffect(() => { - if (viewMode !== 'graph' && pendingPlacement) { - cancelPlacement() - } - if (viewMode !== 'graph' && pendingLinePlacement) { - cancelLinePlacement() - } - }, [viewMode, pendingPlacement, pendingLinePlacement, cancelPlacement, cancelLinePlacement]) - - const handlePaneDoubleClick = useCallback( - (event: React.MouseEvent) => { - if (viewMode !== 'graph') return - // if user can't edit, dont allow adding nodes via double click - if (!boardCanEdit) return - if (!screenToFlowPosition) return - if ((event.target as HTMLElement | null)?.closest('.react-flow__node')) return - if ((event.target as HTMLElement | null)?.closest('.react-flow__edge')) return - const flowPoint = screenToFlowPosition({ x: event.clientX, y: event.clientY }) - addNoteNode({ nodeType: 'text', position: flowPoint }) - }, - [viewMode, boardCanEdit, screenToFlowPosition, addNoteNode], - ) - - const handlePaneMouseMove = useCallback( - (event: React.MouseEvent) => { - if (viewMode !== 'graph') return - if (!screenToFlowPosition) return - const flowPoint = screenToFlowPosition({ x: event.clientX, y: event.clientY }) - setLastCursorPosition(flowPoint) - }, - [viewMode, screenToFlowPosition], - ) - - const { onDragOver: handleImageDragOver, onDrop: handleImageDrop } = useDropImageUpload({ - enabled: viewMode === 'graph' && boardCanEdit && !effectiveIsLocked, - screenToFlowPosition, - }) - - const handleNodeDoubleClick = useCallback['onNodeDoubleClick']>>( - (event, node) => { - if (!boardId) return - const nodeType = node.data?.style?.type - if (nodeType !== 'folder') return - const target = event.target as HTMLElement | null - if (target?.closest('[data-folder-label-edit="true"]')) return - navigate({ - to: "/boards/$id", - params: { id: boardId }, - search: (prev: Record) => ({ - ...prev, - root_id: node.id, - }), - }) - }, - [boardId, navigate], - ) - - const handlePanelAddNode = useCallback( - (options: AddNoteNodeOptions) => { - const nodeType = options.nodeType ?? 'rectangle' - if (isDrawableNodeType(nodeType) && !options.imageUrl && !options.icon) { - beginPlacement({ ...options, nodeType }) - return - } - addNoteNode(options) - }, - [addNoteNode, beginPlacement], - ) - - const handleAddLine = useCallback(() => { - beginLinePlacement() - }, [beginLinePlacement]) - - const rfInstanceRef = useRef | null>(null) - const slideIds = useMemo(() => slides.map(s => s.id), [slides]) - - // Mindmap integration - useEffect(() => { - const integrateMindmap = async () => { - if (boardId && mindmaps.has(boardId)) { - await addMindMapToBoardAsync() - } - } - integrateMindmap() - }, [boardId, mindmaps, addMindMapToBoardAsync]) - - // Recenter when toggling view - useEffect(() => { - if (!shouldRecenter || viewMode !== 'graph') return - fitView({ padding: 0.2, minZoom: 1, maxZoom: 1 }) - setShouldRecenter(false) - }, [shouldRecenter, fitView, viewMode]) - - // Initial viewport / restore saved viewport - useEffect(() => { - if (!viewportInitialized) return - if (!scopeViewportKey || !graphViewports[scopeViewportKey]) { - fitView({ padding: 0.2, maxZoom: 1 }) - } - }, [viewportInitialized, fitView, scopeViewportKey, graphViewports]) - - const handleZoomIn = useCallback(() => zoomIn({ duration: 200 }), [zoomIn]) - const handleZoomOut = useCallback(() => zoomOut({ duration: 200 }), [zoomOut]) - const handleResetZoom = useCallback(() => { - zoomTo(1) - }, [zoomTo]) - - const handleToggleLock = useCallback(() => { - if (!boardCanEdit) return - setIsLocked(value => !value) - }, [boardCanEdit, setIsLocked]) - - useEffect(() => { - if (!boardCanEdit) { - setIsLocked(true) - } - }, [boardCanEdit]) - - const handleSelectionStart = useCallback(() => setIsSelecting(true), []) - const handleSelectionDragStart = useCallback(() => setIsSelecting(true), []) - const handleSelectionEnd = useCallback(() => setIsSelecting(false), []) - const handleSelectionDragStop = useCallback(() => setIsSelecting(false), []) - - const activeSlideIndex = activeSlideId ? slideIds.indexOf(activeSlideId) : -1 - const canPrev = activeSlideIndex > 0 - const canNext = activeSlideIndex >= 0 && activeSlideIndex < slideIds.length - 1 - - const goToSlide = useCallback(async (index: number) => { - const node = slides[index] - if (!node) return - useGraphStore.getState().setActiveSlideId(node.id) - await fitView({ nodes: [node], padding: 0.2, duration: 250 }) - }, [fitView, slides]) - - const restoreViewport = useCallback(() => { - if (!scopeViewportKey) return - const saved = graphViewports[scopeViewportKey] - if (saved && rfInstanceRef.current?.setViewport) { - rfInstanceRef.current.setViewport(saved, { duration: 200 }) - } - }, [scopeViewportKey, graphViewports]) - - useBoardShortcuts({ - enabled: presentationMode, - shortcuts: [ - { key: 'arrowleft', handler: () => canPrev && goToSlide(activeSlideIndex - 1) }, - { key: 'arrowright', handler: () => canNext && goToSlide(activeSlideIndex + 1) }, - { key: 'escape', handler: () => { - const store = useGraphStore.getState() - store.setPresentationMode(false) - store.setActiveSlideId(undefined) - store.setIsSelectMode(false) - restoreViewport() - } }, - ], - }) - - useOnViewportChange({ - onStart: () => { - useGraphStore.getState().setIsMoving(true) - }, - onEnd: vp => { - const store = useGraphStore.getState() - if (scopeViewportKey && !presentationMode) { - store.setGraphViewport(scopeViewportKey, vp) - } - store.setZoom(vp.zoom) - store.setIsMoving(false) - }, - }) - - const captureThumbnail = useThumbnailCapture(boardId || '') - - const handleInit = (instance: ReactFlowInstance) => { - rfInstanceRef.current = instance - if (scopeViewportKey) { - const saved = graphViewports[scopeViewportKey] - if (saved) { - // restore immediately, no animation - instance.setViewport(saved, { duration: 0 }) - } - } - captureThumbnail(instance) - } - - return ( -
- {boardCanEdit ? ( - - ) : null} - -
- - - {presentationMode && ( - canPrev && goToSlide(activeSlideIndex - 1)} - onNext={() => canNext && goToSlide(activeSlideIndex + 1)} - onStop={() => { - const store = useGraphStore.getState() - store.setPresentationMode(false) - store.setActiveSlideId(undefined) - store.setIsSelectMode(false) - restoreViewport() - }} - disablePrev={!canPrev} - disableNext={!canNext} - /> - )} - - -
- ) -} diff --git a/webui/src/features/board/components/flow/graph-floating-chrome.tsx b/webui/src/features/board/components/flow/graph-floating-chrome.tsx deleted file mode 100644 index dc216a8..0000000 --- a/webui/src/features/board/components/flow/graph-floating-chrome.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { useReactFlow, type Viewport } from '@xyflow/react' -import { useShallow } from 'zustand/shallow' - -import { GraphSidebar } from '../style-panel/panel' -import { ViewportControls } from './viewport-controls' -import { useGraphStore } from '../../store/graph-store' -import type { LinkEdge, NoteNode } from '../../types/flow' - - -const FLOATING_UI_REAPPEAR_DELAY = 400 - - -/** - * Persistent zoom hint at the top center of the graph area. - */ -function GraphZoomHint() { - return ( -
-

- Use Ctrl + mouse scroll or Ctrl + / Ctrl - to zoom in and out -

-
- ) -} - - -export type GraphFloatingChromeProps = { - presentationMode: boolean - isSelecting: boolean - effectiveIsLocked: boolean - boardCanEdit: boolean - toggleLock: () => void -} - - -/** - * Floating chrome for the graph view: style sidebar, viewport controls - * (mini-map + zoom + lock + background), and the zoom hint. Owns the - * deferred show/hide logic that suppresses these during motion. Subscribes - * to motion flags and chrome-specific state directly so the parent doesn't - * re-render on every drag/pan tick. - */ -export const GraphFloatingChrome = memo(function GraphFloatingChrome({ - presentationMode, - isSelecting, - effectiveIsLocked, - boardCanEdit, - toggleLock, -}: GraphFloatingChromeProps) { - const { zoomTo, setCenter, getViewport } = useReactFlow() - - const isMoving = useGraphStore(state => state.isMoving) - const isDragging = useGraphStore(state => state.isDragging) - const isResizingNode = useGraphStore(state => state.isResizingNode) - - const nodes = useGraphStore(useShallow(state => state.nodes)) - const zoom = useGraphStore(state => state.zoom ?? 1) - const undo = useGraphStore(state => state.undo) - const redo = useGraphStore(state => state.redo) - const canUndo = useGraphStore(state => state.historyPast.length > 0) - const canRedo = useGraphStore(state => state.historyFuture.length > 0) - const boardBackground = useGraphStore(state => state.boardBackground) - const setBoardBackground = useGraphStore(state => state.setBoardBackground) - const boardBackgroundTexture = useGraphStore(state => state.boardBackgroundTexture) - const setBoardBackgroundTexture = useGraphStore(state => state.setBoardBackgroundTexture) - - const [showMiniMap, setShowMiniMap] = useState(true) - const [showStylePanel, setShowStylePanel] = useState(true) - const miniMapTimeoutRef = useRef(null) - const stylePanelTimeoutRef = useRef(null) - - const shouldHideFloatingUi = - presentationMode || isMoving || isDragging || isResizingNode || isSelecting - - const clearDeferredUiTimeouts = useCallback(() => { - if (miniMapTimeoutRef.current) { - clearTimeout(miniMapTimeoutRef.current) - miniMapTimeoutRef.current = null - } - if (stylePanelTimeoutRef.current) { - clearTimeout(stylePanelTimeoutRef.current) - stylePanelTimeoutRef.current = null - } - }, []) - - useEffect(() => { - if (shouldHideFloatingUi) { - clearDeferredUiTimeouts() - setShowMiniMap(false) - setShowStylePanel(false) - return - } - - miniMapTimeoutRef.current = window.setTimeout(() => { - setShowMiniMap(true) - miniMapTimeoutRef.current = null - }, FLOATING_UI_REAPPEAR_DELAY) - - stylePanelTimeoutRef.current = window.setTimeout(() => { - setShowStylePanel(true) - stylePanelTimeoutRef.current = null - }, FLOATING_UI_REAPPEAR_DELAY) - - return () => { - clearDeferredUiTimeouts() - } - }, [shouldHideFloatingUi, clearDeferredUiTimeouts]) - - const handleResetZoom = useCallback(() => { - zoomTo(1) - }, [zoomTo]) - - const handleMiniMapNavigate = useCallback( - ({ x, y }: { x: number; y: number }, zoomLevel: number) => { - setCenter(x, y, { zoom: zoomLevel, duration: 150 }) - }, - [setCenter], - ) - - const getCurrentViewport = useCallback((): Viewport | null => { - return getViewport() ?? null - }, [getViewport]) - - return ( - <> - {showStylePanel && !shouldHideFloatingUi && boardCanEdit && ( -
- -
- )} - - {showMiniMap && !shouldHideFloatingUi && ( - setBoardBackground(null)} - onBoardBackgroundTextureChange={setBoardBackgroundTexture} - onNavigate={handleMiniMapNavigate} - getCurrentViewport={getCurrentViewport} - /> - )} - - {!presentationMode && } - - ) -}) diff --git a/webui/src/features/board/components/flow/graph-styles.css b/webui/src/features/board/components/flow/graph-styles.css deleted file mode 100644 index b5220b6..0000000 --- a/webui/src/features/board/components/flow/graph-styles.css +++ /dev/null @@ -1,55 +0,0 @@ -.react-flow__node { - background-color: transparent !important; - border: none !important; - padding: 0rem; - width: auto; - box-shadow: none; - &.draggable{ - border: none; - } - &:hover { - box-shadow: none; - } - &.selected { - border: none !important; - } - &:focus { - border: none; - } - &:focus-visible { - border: none; - outline: none; - } - &.target { - border: none; - } -} - -.react-flow__minimap-mask { - fill: var(--muted) !important; - opacity: 0.75 !important; -} - -.react-flow__minimap-node { - fill: var(--primary) !important; -} - -.react-flow__resize-control { - background-color: transparent !important; - border: none !important; -} - -/* - * Applied by useNodeViewportCull to nodes well outside the renderer - * viewport. content-visibility: hidden tells the browser to skip paint - * and layout while keeping the node mounted in the React tree, so - * panning back to it doesn't pay React mount/unmount cost. - * - * Only off-screen nodes ever carry this class — on-screen nodes have - * no containment and render rough strokes, layered offsets, captions, - * and drag handles without clipping. - */ -.react-flow__node.graph-node-culled { - content-visibility: hidden; - contain-intrinsic-size: auto 200px 80px; -} \ No newline at end of file diff --git a/webui/src/features/board/components/flow/line-placement-overlay.tsx b/webui/src/features/board/components/flow/line-placement-overlay.tsx deleted file mode 100644 index 85b8033..0000000 --- a/webui/src/features/board/components/flow/line-placement-overlay.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import type React from 'react' - -import type { CanvasPoint } from '../../hooks/use-add-node' - -const MIN_DRAW_PIXELS = 6 - -type ScreenPoint = { x: number; y: number } -type ScreenToFlowFn = ((position: ScreenPoint) => CanvasPoint) | undefined - -interface LinePlacementOverlayProps { - pending: boolean - screenToFlowPosition?: ScreenToFlowFn - onPlace: (start: CanvasPoint, end: CanvasPoint) => void - onCancel: () => void -} - -export function LinePlacementOverlay({ - pending, - screenToFlowPosition, - onPlace, - onCancel, -}: LinePlacementOverlayProps) { - const [start, setStart] = useState(null) - const [end, setEnd] = useState(null) - const pointerIdRef = useRef(null) - const containerRef = useRef(null) - - const reset = useCallback(() => { - setStart(null) - setEnd(null) - pointerIdRef.current = null - }, []) - - const finalize = useCallback( - (startPoint: ScreenPoint | null, endPoint: ScreenPoint | null) => { - if (!pending || !startPoint || !endPoint || !screenToFlowPosition) { - onCancel() - return - } - const dx = Math.abs(endPoint.x - startPoint.x) - const dy = Math.abs(endPoint.y - startPoint.y) - if (dx < MIN_DRAW_PIXELS && dy < MIN_DRAW_PIXELS) { - onCancel() - return - } - onPlace(screenToFlowPosition(startPoint), screenToFlowPosition(endPoint)) - }, - [pending, screenToFlowPosition, onPlace, onCancel], - ) - - const handlePointerDown = useCallback( - (event: React.PointerEvent) => { - if (!pending || event.button !== 0) return - event.preventDefault() - const startPoint = { x: event.clientX, y: event.clientY } - setStart(startPoint) - setEnd(startPoint) - pointerIdRef.current = event.pointerId - event.currentTarget.setPointerCapture(event.pointerId) - }, - [pending], - ) - - const handlePointerMove = useCallback( - (event: React.PointerEvent) => { - if (!pending || pointerIdRef.current !== event.pointerId || !start) return - event.preventDefault() - setEnd({ x: event.clientX, y: event.clientY }) - }, - [pending, start], - ) - - const handlePointerUp = useCallback( - (event: React.PointerEvent) => { - if (!pending || pointerIdRef.current !== event.pointerId) return - event.preventDefault() - event.currentTarget.releasePointerCapture(event.pointerId) - const endPoint = { x: event.clientX, y: event.clientY } - finalize(start, endPoint) - reset() - }, - [pending, finalize, reset, start], - ) - - const handlePointerCancel = useCallback( - (event: React.PointerEvent) => { - if (!pending) return - if (pointerIdRef.current !== null) { - try { - event.currentTarget.releasePointerCapture(pointerIdRef.current) - } catch { - // ignore if pointer already released - } - } - reset() - onCancel() - }, - [pending, onCancel, reset], - ) - - useEffect(() => { - if (!pending) { - reset() - return - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') onCancel() - } - window.addEventListener('keydown', onKeyDown) - return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, [pending, onCancel, reset]) - - if (!pending) return null - - const bounds = containerRef.current?.getBoundingClientRect() - const line = start && end && bounds ? { - x1: start.x - bounds.left, - y1: start.y - bounds.top, - x2: end.x - bounds.left, - y2: end.y - bounds.top, - } : null - - return ( -
event.preventDefault()} - > - {line && ( - - - - )} -
- Click start then end · Esc to cancel -
-
- ) -} diff --git a/webui/src/features/board/components/flow/linear-code-sandbox-card.tsx b/webui/src/features/board/components/flow/linear-code-sandbox-card.tsx deleted file mode 100644 index 26d290d..0000000 --- a/webui/src/features/board/components/flow/linear-code-sandbox-card.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { ConsoleIcon, DeleteIcon } from '@/components/icons' - -import { useGraphStore } from '../../store/graph-store' -import type { NoteNode } from '../../types/flow' - - -type Props = { - node: NoteNode -} - - -/** - * Files-view card for Python sandbox notes with a lightweight icon preview and title editing. - */ -export const LinearCodeSandboxCard = memo(function LinearCodeSandboxCard({ node }: Props) { - const [titleEditing, setTitleEditing] = useState(false) - const [titleDraft, setTitleDraft] = useState(node.data.label?.markdown || '') - const titleInputRef = useRef(null) - - const boardId = useGraphStore(state => state.boardId) - const boardCanEdit = useGraphStore(state => state.boardCanEdit) - const openNodeSurface = useGraphStore(state => state.openNodeSurface) - const setNodesPersist = useGraphStore(state => state.setNodesPersist) - const setEdgesPersist = useGraphStore(state => state.setEdgesPersist) - const updateNodeByIdPersist = useGraphStore(state => state.updateNodeByIdPersist) - - const displayTitle = node.data.label?.markdown?.trim() || 'Untitled sandbox' - - useEffect(() => { - if (titleEditing) return - setTitleDraft(node.data.label?.markdown || '') - }, [node.data.label?.markdown, titleEditing]) - - useEffect(() => { - if (!titleEditing) return - const frame = requestAnimationFrame(() => { - titleInputRef.current?.focus() - titleInputRef.current?.select() - }) - return () => cancelAnimationFrame(frame) - }, [titleEditing]) - - /** - * Persist the edited title into the sandbox note label. - */ - const onSaveTitle = useCallback((nextTitle: string) => { - const normalizedTitle = nextTitle.trim() - const prev = node.data.label?.markdown?.trim() || '' - if (normalizedTitle === prev) return - - updateNodeByIdPersist(node.id, prevNode => ({ - ...prevNode, - data: { - ...prevNode.data, - label: normalizedTitle ? { markdown: normalizedTitle } : undefined, - updatedAt: new Date().toISOString(), - }, - })) - }, [node.data.label?.markdown, node.id, updateNodeByIdPersist]) - - /** - * End title editing, optionally saving the draft. - */ - const stopTitleEdit = useCallback((save: boolean) => { - if (save) onSaveTitle(titleDraft) - else setTitleDraft(node.data.label?.markdown || '') - setTitleEditing(false) - }, [node.data.label?.markdown, onSaveTitle, titleDraft]) - - const onDelete = useCallback((event: React.MouseEvent) => { - event.stopPropagation() - if (!boardId) return - setNodesPersist(nodes => nodes.filter(n => n.id !== node.id)) - setEdgesPersist(edges => edges.filter(e => e.source !== node.id && e.target !== node.id)) - }, [boardId, node.id, setEdgesPersist, setNodesPersist]) - - const handleOpenSurface = useCallback(() => { - if (!boardCanEdit) return - openNodeSurface(node.id, 'code-sandbox') - }, [boardCanEdit, node.id, openNodeSurface]) - - return ( -
- - - - -
- {titleEditing ? ( - setTitleDraft(event.target.value)} - onBlur={() => stopTitleEdit(true)} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - stopTitleEdit(true) - } - if (event.key === 'Escape') { - event.preventDefault() - stopTitleEdit(false) - } - }} - onMouseDown={event => event.stopPropagation()} - onClick={event => event.stopPropagation()} - className='w-full bg-transparent text-center text-sm font-semibold text-foreground border-0 border-b border-foreground/30 focus:border-secondary-foreground focus:outline-none px-0 py-0.5' - placeholder='Untitled sandbox' - /> - ) : ( - - )} -
-
- ) -}) diff --git a/webui/src/features/board/components/flow/linear-document-card.tsx b/webui/src/features/board/components/flow/linear-document-card.tsx deleted file mode 100644 index 22a7e2e..0000000 --- a/webui/src/features/board/components/flow/linear-document-card.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react' -import clsx from 'clsx' -import { DeleteIcon, PdfIcon } from '@/components/icons' - -import type { NoteNode } from '../../types/flow' -import { useGraphStore } from '../../store/graph-store' - - -type Props = { - node: NoteNode -} - - -/** - * Files-view card for uploaded documents with a lightweight PDF icon and editable title. - */ -export const LinearDocumentCard = memo(function LinearDocumentCard({ node }: Props) { - const boardId = useGraphStore(state => state.boardId) - const setNodesPersist = useGraphStore(state => state.setNodesPersist) - const setEdgesPersist = useGraphStore(state => state.setEdgesPersist) - const updateNodeByIdPersist = useGraphStore(state => state.updateNodeByIdPersist) - const [labelEditing, setLabelEditing] = useState(false) - const [labelDraft, setLabelDraft] = useState(node.data.label?.markdown || '') - const inputRef = useRef(null) - const label = node.data.label?.markdown?.trim() - const displayLabel = label || 'Untitled document' - - useEffect(() => { - if (labelEditing) return - setLabelDraft(node.data.label?.markdown || '') - }, [labelEditing, node.data.label?.markdown]) - - useEffect(() => { - if (!labelEditing) return - const frame = requestAnimationFrame(() => { - inputRef.current?.focus() - inputRef.current?.select() - }) - return () => cancelAnimationFrame(frame) - }, [labelEditing]) - - const commitLabel = useCallback((nextRaw: string) => { - const next = nextRaw.trim() - const prev = node.data.label?.markdown?.trim() || '' - if (next === prev) return - updateNodeByIdPersist(node.id, prevNode => ({ - ...prevNode, - data: { - ...prevNode.data, - label: next ? { markdown: next } : undefined, - }, - })) - }, [node.data.label?.markdown, node.id, updateNodeByIdPersist]) - - const stopLabelEdit = useCallback((save: boolean) => { - if (save) commitLabel(labelDraft) - else setLabelDraft(node.data.label?.markdown || '') - setLabelEditing(false) - }, [commitLabel, labelDraft, node.data.label?.markdown]) - - const onDelete = useCallback((event: React.MouseEvent) => { - event.stopPropagation() - if (!boardId) return - setNodesPersist(nodes => nodes.filter(n => n.id !== node.id)) - setEdgesPersist(edges => edges.filter(e => e.source !== node.id && e.target !== node.id)) - }, [boardId, node.id, setEdgesPersist, setNodesPersist]) - - return ( -
- - -
-
- -
-
- -
- {labelEditing ? ( - setLabelDraft(event.target.value)} - onBlur={() => stopLabelEdit(true)} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - stopLabelEdit(true) - } - if (event.key === 'Escape') { - event.preventDefault() - stopLabelEdit(false) - } - }} - onMouseDown={event => event.stopPropagation()} - onClick={event => event.stopPropagation()} - className='w-full bg-transparent text-center text-sm font-sans font-semibold text-foreground border-0 border-b border-foreground/30 focus:border-secondary-foreground focus:outline-none px-0 py-0.5' - placeholder='Untitled document' - /> - ) : ( - - )} -
-
- ) -}) diff --git a/webui/src/features/board/components/flow/linear-folder-card.tsx b/webui/src/features/board/components/flow/linear-folder-card.tsx deleted file mode 100644 index e53d2c5..0000000 --- a/webui/src/features/board/components/flow/linear-folder-card.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { DeleteIcon, FolderIcon } from '@/components/icons' - -import type { NoteNode } from '../../types/flow' -import { useGraphStore } from '../../store/graph-store' - - -type Props = { - node: NoteNode -} - - -const normalizeLabel = (markdown?: string) => { - const text = (markdown ?? '').replace(/\s+/g, ' ').trim() - return text || 'Untitled folder' -} - - -/** - * Files-view card for sub-boards with a centered folder icon and editable title. - */ -export const LinearFolderCard = memo(function LinearFolderCard({ node }: Props) { - const navigate = useNavigate() - const boardId = useGraphStore(state => state.boardId) - const setNodesPersist = useGraphStore(state => state.setNodesPersist) - const setEdgesPersist = useGraphStore(state => state.setEdgesPersist) - const updateNodeByIdPersist = useGraphStore(state => state.updateNodeByIdPersist) - - const [labelEditing, setLabelEditing] = useState(false) - const [labelDraft, setLabelDraft] = useState(node.data.label?.markdown || '') - const inputRef = useRef(null) - - const displayLabel = normalizeLabel(node.data.label?.markdown) - - useEffect(() => { - if (labelEditing) return - setLabelDraft(node.data.label?.markdown || '') - }, [labelEditing, node.data.label?.markdown]) - - useEffect(() => { - if (!labelEditing) return - const frame = requestAnimationFrame(() => { - inputRef.current?.focus() - inputRef.current?.select() - }) - return () => cancelAnimationFrame(frame) - }, [labelEditing]) - - const commitLabel = useCallback((nextRaw: string) => { - const next = nextRaw.trim() - const prev = node.data.label?.markdown?.trim() || '' - if (next === prev) return - updateNodeByIdPersist(node.id, prevNode => ({ - ...prevNode, - data: { - ...prevNode.data, - label: next ? { markdown: next } : undefined, - }, - })) - }, [node.data.label?.markdown, node.id, updateNodeByIdPersist]) - - const stopLabelEdit = useCallback((save: boolean) => { - if (save) commitLabel(labelDraft) - else setLabelDraft(node.data.label?.markdown || '') - setLabelEditing(false) - }, [commitLabel, labelDraft, node.data.label?.markdown]) - - const onDelete = useCallback((event: React.MouseEvent) => { - event.stopPropagation() - if (!boardId) return - setNodesPersist(nodes => nodes.filter(n => n.id !== node.id)) - setEdgesPersist(edges => edges.filter(e => e.source !== node.id && e.target !== node.id)) - }, [boardId, node.id, setEdgesPersist, setNodesPersist]) - - return ( -
- - - - -
- {labelEditing ? ( - setLabelDraft(event.target.value)} - onBlur={() => stopLabelEdit(true)} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - stopLabelEdit(true) - } - if (event.key === 'Escape') { - event.preventDefault() - stopLabelEdit(false) - } - }} - onMouseDown={event => event.stopPropagation()} - onClick={event => event.stopPropagation()} - className='w-full bg-transparent text-center text-sm font-sans font-semibold text-foreground border-0 border-b border-foreground/30 focus:border-secondary-foreground focus:outline-none px-0 py-0.5' - placeholder='Untitled folder' - /> - ) : ( - - )} -
-
- ) -}) diff --git a/webui/src/features/board/components/flow/linear-note-card.tsx b/webui/src/features/board/components/flow/linear-note-card.tsx deleted file mode 100644 index 94c6928..0000000 --- a/webui/src/features/board/components/flow/linear-note-card.tsx +++ /dev/null @@ -1,317 +0,0 @@ -// components/flow/linear-note-card.tsx -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { NoteNode } from '../../types/flow' -import { MarkdownView } from '@/components/markdown/markdown-view' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { useGraphStore } from '../../store/graph-store' -import { DeleteIcon, PaintBoardIcon, PinIcon, PinOffIcon } from '@/components/icons' -import clsx from 'clsx' -import { TAILWIND_300 } from '../../lib/colors/tailwind' -import { formatDistanceToNow } from '../../utils/date' -import { useTheme } from '@/components/theme-provider' -import { darkModeDisplayHex } from '../../lib/colors/dark-variants' -import { DocumentCardView } from './document-card-view' -import type { NoteWithPin } from './note-card' - - -type Props = { node: NoteNode } - -export function LinearNoteCard({ node }: Props) { - const [titleEditing, setTitleEditing] = useState(false) - const [titleDraft, setTitleDraft] = useState(node.data.label?.markdown || '') - const titleInputRef = useRef(null) - - const boardId = useGraphStore(state => state.boardId) - const boardCanEdit = useGraphStore(state => state.boardCanEdit) - const openNodeSurface = useGraphStore(state => state.openNodeSurface) - - const setNodesPersist = useGraphStore(state => state.setNodesPersist) - const updateNodeByIdPersist = useGraphStore(state => state.updateNodeByIdPersist) - const setEdgesPersist = useGraphStore(state => state.setEdgesPersist) - - // dark mode support - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const color = isDark ? darkModeDisplayHex(node.data.style.backgroundColor) ?? '#a5c9ff' : node.data.style.backgroundColor - const textColor = isDark - ? darkModeDisplayHex(node.data.style.textColor) ?? undefined - : node.data.style.textColor - const isPinned = node.data.properties.pinned.boolean - const isSheet = node.data.style.type === 'sheet' - const isCodeSandbox = node.data.style.type === 'code-sandbox' - const isWidget = node.data.style.type === 'widget' - const usesHostedSurface = isSheet || isCodeSandbox || isWidget - const title = node.data.label?.markdown?.trim() || '' - const displayTitle = title || 'Untitled note' - const { text: timeAgo, tooltip: fullDate } = formatDistanceToNow(node.data.updatedAt) - - useEffect(() => { - if (titleEditing) return - setTitleDraft(node.data.label?.markdown || '') - }, [node.data.label?.markdown, titleEditing]) - - const onSaveTitle = useCallback((nextTitle: string) => { - if (!boardId) return - const normalizedTitle = nextTitle.trim() - updateNodeByIdPersist(node.id, prev => ({ - ...prev, - data: { - ...prev.data, - label: normalizedTitle ? { markdown: normalizedTitle } : undefined, - updatedAt: new Date().toISOString(), - }, - })) - }, [boardId, node.id, updateNodeByIdPersist]) - - const startTitleEdit = useCallback((e?: React.MouseEvent) => { - e?.stopPropagation() - setTitleDraft(node.data.label?.markdown || '') - setTitleEditing(true) - requestAnimationFrame(() => { - titleInputRef.current?.focus() - titleInputRef.current?.select() - }) - }, [node.data.label?.markdown]) - - const stopTitleEdit = useCallback((save: boolean) => { - if (save) onSaveTitle(titleDraft) - else setTitleDraft(node.data.label?.markdown || '') - setTitleEditing(false) - }, [node.data.label?.markdown, onSaveTitle, titleDraft]) - - const onPickColor = useCallback((hex: string) => { - if (!boardId) return - const newNode = { - ...node, - data: { ...node.data, style: { ...node.data.style, backgroundColor: hex } } - } as NoteNode - setNodesPersist(nds => - nds.map(n => n.id === node.id ? newNode : n) - ) - }, [boardId, node, setNodesPersist]) - - const onTogglePin = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (!boardId) return - const noteProperties = { ...node.data.properties, pinned: { type: "boolean", boolean: !isPinned } } - const newNode = { ...node, data: { ...node.data, properties: noteProperties } } as NoteNode - setNodesPersist(nds => - nds.map(n => n.id === node.id ? newNode : n) - ) - }, [boardId, isPinned, node, setNodesPersist]) - - const onDelete = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (!boardId) return - setNodesPersist(nodes => nodes.filter(n => n.id !== node.id)) - setEdgesPersist(edges => edges.filter(e => e.source !== node.id && e.target !== node.id)) - }, [boardId, node.id, setNodesPersist, setEdgesPersist]) - - const handleOpenSurface = useCallback(() => { - if (!boardCanEdit) return - openNodeSurface(node.id, isCodeSandbox ? 'code-sandbox' : isWidget ? 'widget' : 'sheet') - }, [boardCanEdit, isCodeSandbox, isWidget, node.id, openNodeSurface]) - - const cardClass = clsx( - 'transition rounded-lg relative transition-all duration-200 group', - usesHostedSurface && boardCanEdit && 'cursor-pointer', - isSheet - ? null - : clsx( - 'overflow-hidden bg-background sticky-note-shadow paper-note-texture p-0.5', - isPinned - ? 'ring-2 ring-secondary-foreground/60' - : 'hover:ring-2 hover:ring-secondary-foreground/40', - ), - ) - - const cardBackground = isSheet ? undefined : color - const CardBody = useMemo(() => ( -
- {!isSheet && ( -
-
-
- {timeAgo && fullDate && ( -
- - {timeAgo} - -
- )} -
- - - - - -
- {[{ name: 'white', hex: '#ffffff' }, ...TAILWIND_300].map(c => ( -
-
-
- - -
-
- )} - - {/* content area */} -
{ - if (isSheet) return - if (usesHostedSurface) { - handleOpenSurface() - } - }} - > - {isSheet ? ( - - ) : isCodeSandbox ? ( -
-            {node.data.content?.markdown || '# Write Python here'}
-          
- ) : isWidget ? ( -
-