diff --git a/docs/glossary.md b/docs/glossary.md index 5b34b104..b4e6f68b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,6 +20,7 @@ say so. | fold, collapse, unfold, expand | hide, show | | clone, fork | duplicate | | version, revision, snapshot | (none of these exist; see **Undo / Redo**) | +| flag, mark (for a stored value) | attribute | ### Reserved @@ -57,8 +58,8 @@ branch to it. A map holds at least one root node, the main root. ### Main root The one root node per map that the app creates with the map. No one can delete, -copy or cut it. The stored flag `isRoot` marks the main root only, so do -not read `isRoot` to learn whether a node has a parent. +copy or cut it. The stored attribute `isRoot` is true for the main root only, +so do not read `isRoot` to learn whether a node has a parent. ### Orphaned node @@ -104,12 +105,12 @@ insert the symbol as a node image. Not available on every server. ### Protected branch -A node and every node below it, which a user marks as finished to tell others -not to change them. The app stores the flag `protected` on the top node of the -branch only and draws a lock badge on that node. A node counts as protected -when it or an ancestor carries the flag. Any writable client protects a branch -or releases it again from the toolbar's lock button, and the flag syncs to -every client. +A node and every node below it, which a user declares finished to tell others +not to change them. The app sets the attribute `protected` on the top node of +the branch only and draws a lock badge on that node. A node counts as protected +when its own `protected` attribute or an ancestor's is true. Any writable +client protects a branch or releases it again from the toolbar's lock button, +and the attribute syncs to every client. A protected branch refuses local edits: rename, style, image, link, drag, add child, paste, remove and cut. Peer writes, undo, redo, redistribute and import @@ -121,11 +122,27 @@ Write "protect" and "release". Avoid "lock" for the concept, and "freeze" or ### Hidden node -A node the app does not draw because an ancestor's children are hidden. The map -does not store hiding: hiding is how one person is looking at the map right now. +A node the app does not draw because the view state hides the child nodes of +one of its ancestors. The app also hides a node a peer adds below such an +ancestor. A hidden node keeps its room in the layout. Write "hide" and "show". Avoid "collapse" and "fold". +### View state + +The set of nodes whose child nodes one person has hidden. mmp keeps the view +state apart from the map data, so the undo stack never records it. The +frontend sends no record of which child nodes are hidden to the server or to a +peer, and stores none, so a reload shows them again. The view state starts +empty each time the app opens a map. + +### Selection, ring + +The **selection** is the one node a client has selected. mmp draws a **ring** +around it, and around each node a peer has selected, in that peer's client +color. Each client keeps its own selection apart from the map data and from +the view state. Peers see it through Yjs awareness (see **Presence**). + ### Map settings What a single mind map remembers about how the app draws it, chiefly the font @@ -147,6 +164,16 @@ admin the delete action. The map list, which the settings' third tab and the start page both show, offers the same delete action for every listed map whose admin id the browser holds, open or not. +### Node mark + +One visual element of a drawn node: its background, name, image, link, hidden +eye icon or lock badge. The renderer in `packages/mmp` draws every node from +the marks in `NODE_MARKS`, so a new visual element is a new mark. The term +follows the visualization sense of a mark: a visual element bound to one data +item. A mark can consist of several DOM elements, such as the link's `` and +``. Write "DOM element" or the tag name when you mean the browser object, +and "node group" for the `` that holds all marks of one node. + ## What you can do to a map ### Add, remove, select, deselect @@ -231,8 +258,8 @@ or "fork". ### Writable -Whether a connection may edit. The app stores one flag; read-only is its -negation. A connection is writable when it presents the map's modification +Whether a connection may edit. The app stores one boolean attribute, and +read-only is its negation. A connection is writable when it presents the map's modification secret. ### Viewer link, editor link @@ -276,3 +303,32 @@ edits. Whether this client reaches the server. Losing the connection raises the "Connection lost" dialog with a reconnect button. + +## Words for code + +### Attribute + +A value a data object holds, such as a node's `isRoot`, `protected` or +`name`. A boolean attribute is still an attribute: write "the `isRoot` +attribute is true", not "the node carries the root flag". + +### Flag + +A switch that configures the app from outside its data: an environment +variable, a feature flag or a command-line option. A value stored on a node or +a map is an attribute. + +### Map data + +The nodes of one mind map as every client shares them. The app draws the map +from the map data and keeps no copy of its own, so your edits, a peer's edits +and an undo all change the map data first, and the app then redraws. + +In the app, the map data syncs with the server and the other clients. Tests, +and a host without sync, use `InMemoryMapData`. The **view state** and the +**selection** stay with one person and never enter the map data. + +### Mark + +Reserved for the **node mark**, one visual element of a drawn node. Do not use +"mark" for a stored value: a node holds an attribute, it does not carry a mark. diff --git a/openspec/changes/add-node-protection/design.md b/openspec/changes/add-node-protection/design.md index 810152f5..5bb5eeb0 100644 --- a/openspec/changes/add-node-protection/design.md +++ b/openspec/changes/add-node-protection/design.md @@ -39,7 +39,7 @@ A node gains `protected: boolean`. The schema declares it `v.optional(v.boolean( At most one flag lies on any path from a root to a leaf: - Protecting a node that is already protected is not offered: the button shows "release". -- Protecting a node clears the flag on every descendant. `MapSyncService.toggleBranchProtection` runs the toggle inside one local Y.Doc transaction, so peers receive every flag write as one update and one undo reverts them together. +- Protecting a node clears the flag on every descendant. `MmpService.toggleBranchProtection` calls mmp, which writes the toggle as one map data batch and so one local Y.Doc transaction, so peers receive every flag write as one update and one undo reverts them together. Releasing from any node of a protected branch clears the flag on `protectingNode`, which releases the whole branch in one write. diff --git a/openspec/changes/add-node-protection/tasks.md b/openspec/changes/add-node-protection/tasks.md index 556f9532..923f12fe 100644 --- a/openspec/changes/add-node-protection/tasks.md +++ b/openspec/changes/add-node-protection/tasks.md @@ -21,7 +21,7 @@ The three sections shipped together in one pull request. Section 1 removes the o ## 3. Protect, release and refuse -- [x] 3.1 Add `protectBranch` and `releaseBranch` to `packages/mmp`, writing the flag and clearing descendant flags, and export them from the entry point. `MapSyncService.toggleBranchProtection` wraps the toggle in one Y.Doc transaction +- [x] 3.1 Add `protectBranch` and `releaseBranch` to `packages/mmp`, writing the flag and clearing descendant flags, and export them from the entry point. `MmpService.toggleBranchProtection` calls mmp, which writes the toggle as one map data batch and so one Y.Doc transaction - [x] 3.2 Refuse local rename, style, image, link, font, drag, add child, paste, remove and cut inside a protected branch, emitting `nodeProtected`, and skip the check when `notifyWithEvent` is false - [x] 3.3 Draw the lock badge on the node carrying the flag and redraw it on local and remote changes - [x] 3.4 Replace the toolbar slot with the lock and unlock button, disabled with no selection and on read-only clients diff --git a/packages/README.md b/packages/README.md index 401cbe9c..13808233 100644 --- a/packages/README.md +++ b/packages/README.md @@ -4,11 +4,11 @@ Libraries shared between the applications in this repository. Each one is a pnpm workspace package under the `@teammapper` scope and is built to `dist/` before the applications compile (`pnpm run build:packages` from the root). -| Package | Purpose | Consumers | -| ------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | -| `shared` (`@teammapper/shared`) | Domain models, validation schemas and algorithms that cross the wire. Runs in Node and in the browser. | `teammapper-backend`, `teammapper-frontend`, `mmp` | -| `mmp` (`@teammapper/mmp`) | The mind map renderer built on d3, forked from cedoor/mmp. Browser-only: it needs a DOM. | `teammapper-frontend` | -| `mermaid-mindmap-parser` | Parser for Mermaid mindmap syntax, vendored from the mermaid project. Has no lint script by design. | `teammapper-frontend` | +| Package | Purpose | Consumers | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `shared` (`@teammapper/shared`) | Domain models, validation schemas and algorithms that cross the wire. Runs in Node and in the browser. | `teammapper-backend`, `teammapper-frontend`, `mmp` | +| `mmp` (`@teammapper/mmp`) | The mind map renderer built on d3, forked from cedoor/mmp. Browser-only: it needs a DOM. `create` takes a `MapData`; mmp exports `InMemoryMapData` and the frontend implements `YjsMapData`. | `teammapper-frontend` | +| `mermaid-mindmap-parser` | Parser for Mermaid mindmap syntax, vendored from the mermaid project. Has no lint script by design. | `teammapper-frontend` | A type that both applications need belongs in `shared`. Rendering code belongs in `mmp`. Nothing in `shared` may import from `mmp`, since the backend loads diff --git a/packages/mermaid-mindmap-parser/package-lock.json b/packages/mermaid-mindmap-parser/package-lock.json deleted file mode 100644 index 49c8e4cc..00000000 --- a/packages/mermaid-mindmap-parser/package-lock.json +++ /dev/null @@ -1,1325 +0,0 @@ -{ - "name": "my-vue-app", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "my-vue-app", - "version": "0.0.1", - "dependencies": { - "dompurify": "3.4.0" - }, - "devDependencies": { - "jison": "0.4.18", - "typescript": "~5.9.3", - "vite": "^6.4.2" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", - "dev": true, - "license": "BSD-3-Clause OR MIT", - "optional": true, - "engines": { - "node": ">=0.4.2" - } - }, - "node_modules/cjson": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.3.0.tgz", - "integrity": "sha512-bBRQcCIHzI1IVH59fR0bwGrFmi3Btb/JNwM/n401i1DnYgWndpsUBiQRAddLflkZage20A2d25OAWZZk0vBRlA==", - "dev": true, - "dependencies": { - "jsonlint": "1.6.0" - }, - "engines": { - "node": ">= 0.3.0" - } - }, - "node_modules/colors": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", - "integrity": "sha512-XjsuUwpDeY98+yz959OlUK6m7mLBM+1MEG5oaenfuQnNnrQk1WvtcvFgN3FNDP3f2NmZ211t0mNEfSEN1h0eIg==", - "dev": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/dompurify": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", - "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/ebnf-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/ebnf-parser/-/ebnf-parser-0.1.10.tgz", - "integrity": "sha512-urvSxVQ6XJcoTpc+/x2pWhhuOX4aljCNQpwzw+ifZvV1andZkAmiJc3Rq1oGEAQmcjiLceyMXOy1l8ms8qs2fQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, - "node_modules/escodegen": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.3.3.tgz", - "integrity": "sha512-z9FWgKc48wjMlpzF5ymKS1AF8OIgnKLp9VyN7KbdtyrP/9lndwUFqCtMm+TAJmJf7KJFFYc4cFJfVTTGkKEwsA==", - "dev": true, - "dependencies": { - "esprima": "~1.1.1", - "estraverse": "~1.5.0", - "esutils": "~1.0.0" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=0.10.0" - }, - "optionalDependencies": { - "source-map": "~0.1.33" - } - }, - "node_modules/esprima": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.1.1.tgz", - "integrity": "sha512-qxxB994/7NtERxgXdFgLHIs9M6bhLXc6qtUmWZ3L8+gTQ9qaoyki2887P2IqAYsoENyr8SUbTutStDniOHSDHg==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/estraverse": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.5.1.tgz", - "integrity": "sha512-FpCjJDfmo3vsc/1zKSeqR5k42tcIhxFIlvq+h9j0fO2q/h2uLKyweq7rYJ+0CoVvrGQOxIS5wyBrW/+vF58BUQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/esutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.0.0.tgz", - "integrity": "sha512-x/iYH53X3quDwfHRz4y8rn4XcEwwCJeWsul9pF1zldMbGtgOtMNBEOuYWwB1EQlK2LRa1fev3YAgym/RElp5Cg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fdir": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/jison": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/jison/-/jison-0.4.18.tgz", - "integrity": "sha512-FKkCiJvozgC7VTHhMJ00a0/IApSxhlGsFIshLW6trWJ8ONX2TQJBBz6DlcO1Gffy4w9LT+uL+PA+CVnUSJMF7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cjson": "0.3.0", - "ebnf-parser": "0.1.10", - "escodegen": "1.3.x", - "esprima": "1.1.x", - "jison-lex": "0.3.x", - "JSONSelect": "0.4.0", - "lex-parser": "~0.1.3", - "nomnom": "1.5.2" - }, - "bin": { - "jison": "lib/cli.js" - }, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/jison-lex": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/jison-lex/-/jison-lex-0.3.4.tgz", - "integrity": "sha512-EBh5wrXhls1cUwROd5DcDHR1sG7CdsCFSqY1027+YA1RGxz+BX2TDLAhdsQf40YEtFDGoiO0Qm8PpnBl2EzDJw==", - "dev": true, - "dependencies": { - "lex-parser": "0.1.x", - "nomnom": "1.5.2" - }, - "bin": { - "jison-lex": "cli.js" - }, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/jsonlint": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.0.tgz", - "integrity": "sha512-x6YLBe6NjdpmIeiklwQOxsZuYj/SOWkT33GlTpaG1UdFGjdWjPcxJ1CWZAX3wA7tarz8E2YHF6KiW5HTapPlXw==", - "dev": true, - "dependencies": { - "JSV": ">= 4.0.x", - "nomnom": ">= 1.5.x" - }, - "bin": { - "jsonlint": "lib/cli.js" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/JSONSelect": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/JSONSelect/-/JSONSelect-0.4.0.tgz", - "integrity": "sha512-VRLR3Su35MH+XV2lrvh9O7qWoug/TUyj9tLDjn9rtpUCNnILLrHjgd/tB0KrhugCxUpj3UqoLqfYb3fLJdIQQQ==", - "dev": true, - "engines": { - "node": ">=0.4.7" - } - }, - "node_modules/JSV": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz", - "integrity": "sha512-ZJ6wx9xaKJ3yFUhq5/sk82PJMuUyLk277I8mQeyDgCTjGdjWJIvPfaU5LIXaMuaN2UO1X3kZH4+lgphublZUHw==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/lex-parser": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/lex-parser/-/lex-parser-0.1.4.tgz", - "integrity": "sha512-DuAEISsr1H4LOpmFLkyMc8YStiRWZCO8hMsoXAXSbgyfvs2WQhSt0+/FBv3ZU/JBFZMGcE+FWzEBSzwUU7U27w==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/nomnom": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.5.2.tgz", - "integrity": "sha512-fiVbT7BqxiQqjlR9U3FDGOSERFCKoXVCdxV2FwZuNN7/cmJ42iQx35nUFOAFDcyvemu9Adp+IlsCGlKQYLmBKw==", - "deprecated": "Package no longer supported. Contact support@npmjs.com for more info.", - "dev": true, - "dependencies": { - "colors": "0.5.x", - "underscore": "1.1.x" - }, - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", - "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map": { - "version": "0.1.43", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", - "integrity": "sha512-VtCvB9SIQhk3aF6h+N85EaqIaBFIAfZ9Cu+NJHHVvc8BbEcnvDcFw6sqQ2dQrT6SlOrZq3tIvyD9+EGq/lJryQ==", - "dev": true, - "optional": true, - "dependencies": { - "amdefine": ">=0.0.4" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/underscore": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.1.7.tgz", - "integrity": "sha512-w4QtCHoLBXw1mjofIDoMyexaEdWGMedWNDhlWTtT1V1lCRqi65Pnoygkh6+WRdr+Bm8ldkBNkNeCsXGMlQS9HQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - } - } -} diff --git a/packages/mermaid-mindmap-parser/package.json b/packages/mermaid-mindmap-parser/package.json index 58a2c7d4..f962184f 100644 --- a/packages/mermaid-mindmap-parser/package.json +++ b/packages/mermaid-mindmap-parser/package.json @@ -30,11 +30,11 @@ "dist" ], "dependencies": { - "dompurify": "3.4.15" + "dompurify": "3.4.16" }, "devDependencies": { "jison": "0.4.18", "typescript": "~5.9.3", - "vite": "^8.3.0" + "vite": "^8.3.1" } } diff --git a/packages/mermaid-mindmap-parser/src/mindmapDb.ts b/packages/mermaid-mindmap-parser/src/mindmapDb.ts index b3e3738c..bcb9e959 100644 --- a/packages/mermaid-mindmap-parser/src/mindmapDb.ts +++ b/packages/mermaid-mindmap-parser/src/mindmapDb.ts @@ -12,11 +12,14 @@ export const sanitizeText = (text: string): string => { let nodes: MindmapNode[] = []; let cnt = 0; let elements: Record = {}; +// Indentation of the root, subtracted from every level so the root sits at 0 +let baseLevel: number | undefined; const clear = () => { nodes = []; cnt = 0; elements = {}; + baseLevel = undefined; }; const getParent = function (level: number) { @@ -34,6 +37,14 @@ const getMindmap = () => { }; const addNode = (level: number, id: string, descr: string, type: number) => { + const isRoot = nodes.length === 0; + if (isRoot) { + baseLevel = level; + level = 0; + } else if (baseLevel !== undefined) { + level -= baseLevel; + } + let padding = 10; switch (type) { case nodeType.ROUNDED_RECT: @@ -51,6 +62,7 @@ const addNode = (level: number, id: string, descr: string, type: number) => { children: [], width: 10, padding, + isRoot, } satisfies MindmapNode; const parent = getParent(level); @@ -59,8 +71,7 @@ const addNode = (level: number, id: string, descr: string, type: number) => { // Keep all nodes in the list nodes.push(node); } else { - if (nodes.length === 0) { - // First node, the root + if (isRoot) { nodes.push(node); } else { // Syntax error ... there can only bee one root @@ -141,8 +152,11 @@ const type2Str = (type: number) => { } }; -// Expose logger to grammar -const getLogger = () => console; +// Expose logger to grammar. Mermaid's own logger drops these calls at its +// default level, and console.trace would print a stack trace per token. +const noop = () => undefined; +const silentLogger = { trace: noop, debug: noop, info: noop }; +const getLogger = () => silentLogger; const getElementById = (id: number) => elements[id]; const db = { diff --git a/packages/mermaid-mindmap-parser/src/types.ts b/packages/mermaid-mindmap-parser/src/types.ts index dbe68340..1d93db85 100644 --- a/packages/mermaid-mindmap-parser/src/types.ts +++ b/packages/mermaid-mindmap-parser/src/types.ts @@ -12,6 +12,7 @@ interface MindmapNode { children: MindmapNode[]; width: number; padding: number; + isRoot: boolean; section?: number; height?: number; class?: string; diff --git a/packages/mermaid-mindmap-parser/vite.config.ts b/packages/mermaid-mindmap-parser/vite.config.ts index df02e2b9..d5480cdf 100644 --- a/packages/mermaid-mindmap-parser/vite.config.ts +++ b/packages/mermaid-mindmap-parser/vite.config.ts @@ -5,7 +5,7 @@ export default { plugins: [jison()], build: { lib: { - entry: resolve(__dirname, 'src/index.ts'), + entry: resolve(import.meta.dirname, 'src/index.ts'), formats: ['es'], }, }, diff --git a/packages/mmp/README.md b/packages/mmp/README.md index ee0edb70..484ef8bc 100644 --- a/packages/mmp/README.md +++ b/packages/mmp/README.md @@ -21,6 +21,41 @@ pnpm --filter @teammapper/mmp run test pnpm --filter @teammapper/mmp run lint ``` +## Map data + +mmp keeps no copy of the map. `create` takes a `MapData`, and mmp reads every +node through the map data and writes through its typed methods. The host owns +the nodes, so a Yjs document, a store or a plain array can back them. mmp +redraws in one listener on every change the map data reports, whether the +change is local, a peer's or an undo, and emits `mapChange` afterwards. mmp +keeps render data only: measured sizes, rings, image loads, the drag preview, +the selection and the view state. + +`InMemoryMapData` implements the interface over a `Map`. The mmp specs run +against it, and a host without its own store can use it. + +```ts +import { create, InMemoryMapData } from '@teammapper/mmp'; + +const data = new InMemoryMapData([ + { id: 'root', parent: null, k: 1, name: 'Root', isRoot: true }, +]); +const ref = document.getElementById('map'); +if (!ref) throw new Error('The page has no #map element'); +const map = create('map', ref, undefined, data); + +map.instance.on('mapChange', () => console.log(data.nodes().length)); + +data.batch(() => { + data.addNodes([{ id: 'child', parent: 'root', k: 1, name: 'Child' }]); + data.updateNode('child', 'name', 'Renamed'); +}); +``` + +The `batch` call above produces one redraw and one `mapChange`. The +`MapData` interface offers `node(id)`, `nodes()`, `mainRootId()`, `addNodes`, +`updateNode`, `removeNode`, `replaceMap`, `batch` and `subscribe`. + ## Third-party libs | Library | Authors or maintainers | License | Link | diff --git a/packages/mmp/package.json b/packages/mmp/package.json index 21605ef7..9adec56e 100644 --- a/packages/mmp/package.json +++ b/packages/mmp/package.json @@ -30,8 +30,9 @@ "dependencies": { "@teammapper/shared": "workspace:*", "d3": "7.9.0", - "dompurify": "3.4.15", - "uuid": "13.0.2" + "dompurify": "3.4.16", + "uuid": "13.0.2", + "valibot": "^1.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/mmp/src/index.ts b/packages/mmp/src/index.ts index 62dc5ce5..5a60ebe3 100644 --- a/packages/mmp/src/index.ts +++ b/packages/mmp/src/index.ts @@ -1,21 +1,31 @@ import { OptionParameters } from './map/options.js'; import MmpMap from './map/map.js'; -import { PropertyMapping } from './map/handlers/nodes.js'; +import type { MapData } from './map/data/map-data.js'; +import { PropertyMapping } from './map/data/property-mapping.js'; +import InMemoryMapData from './map/data/in-memory-map-data.js'; -export { MmpMap }; +export { MmpMap, InMemoryMapData }; export type { OptionParameters, ImageUrlResolver } from './map/options.js'; export type { MapProperties } from './map/types.js'; -export type { NodeColors } from './map/models/node.js'; +export type { NodeColors } from './map/data/node-record.js'; +export type { + MapData, + MapDataChange, + MapNodeRecord, +} from './map/data/map-data.js'; /** - * Return a mmp object with all mmp functions. + * Return a mmp object with all mmp functions. The map reads and writes its + * nodes through `data` for its whole life, and draws every node `data` holds + * before `create` returns. */ export function create( id: string, ref: HTMLElement, - options?: OptionParameters + options: OptionParameters | undefined, + data: MapData ) { - return new MmpMap(id, ref, options); + return new MmpMap(id, ref, options, data); } export const NodePropertyMapping = PropertyMapping; diff --git a/packages/mmp/src/map/data/in-memory-map-data.spec.ts b/packages/mmp/src/map/data/in-memory-map-data.spec.ts new file mode 100644 index 00000000..b5787e47 --- /dev/null +++ b/packages/mmp/src/map/data/in-memory-map-data.spec.ts @@ -0,0 +1,194 @@ +import InMemoryMapData from './in-memory-map-data.js'; +import type { MapDataChange } from './map-data.js'; +import { nodeRecord } from '../../test/stub-map.js'; + +/** root -> a -> b, root -> c. */ +function makeData() { + const data = new InMemoryMapData([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'a', parent: 'root' }), + nodeRecord({ id: 'b', parent: 'a' }), + nodeRecord({ id: 'c', parent: 'root' }), + ]); + const changes: MapDataChange[] = []; + data.subscribe(change => changes.push(change)); + return { data, changes }; +} + +const ids = (data: InMemoryMapData) => data.nodes().map(node => node.id); + +describe('InMemoryMapData', () => { + it('reads the nodes in insertion order and finds the main root', () => { + const { data } = makeData(); + + expect(ids(data)).toEqual(['root', 'a', 'b', 'c']); + expect(data.node('b')?.parent).toBe('a'); + expect(data.node('missing')).toBeUndefined(); + expect(data.mainRootId()).toBe('root'); + }); + + it('reports no main root for a map without one', () => { + expect(new InMemoryMapData().mainRootId()).toBeNull(); + }); + + it('adds nodes and reports them as added', () => { + const { data, changes } = makeData(); + + data.addNodes([nodeRecord({ id: 'd', parent: 'c' })]); + + expect(ids(data)).toEqual(['root', 'a', 'b', 'c', 'd']); + expect(changes).toEqual([ + { replaced: false, added: ['d'], updated: [], removed: [] }, + ]); + }); + + it('reports an overwritten node as updated', () => { + const { data, changes } = makeData(); + + data.addNodes([nodeRecord({ id: 'c', parent: 'root', name: 'C' })]); + + expect(data.node('c')?.name).toBe('C'); + expect(changes).toEqual([ + { replaced: false, added: [], updated: ['c'], removed: [] }, + ]); + }); + + it('writes a property through its path', () => { + const { data, changes } = makeData(); + + data.updateNode('a', 'backgroundColor', '#ff0000'); + data.updateNode('a', 'coordinates', { x: 5, y: 6 }); + + expect(data.node('a')?.colors?.background).toBe('#ff0000'); + expect(data.node('a')?.coordinates).toEqual({ x: 5, y: 6 }); + expect(changes.map(change => change.updated)).toEqual([['a'], ['a']]); + }); + + it('leaves a record read earlier unchanged by a later write', () => { + const { data } = makeData(); + const before = data.node('a'); + + data.updateNode('a', 'name', 'A'); + + expect(before?.name).toBe(''); + expect(data.node('a')?.name).toBe('A'); + }); + + it('changes and announces nothing for an id it lacks', () => { + const { data, changes } = makeData(); + + data.updateNode('missing', 'name', 'x'); + data.removeNode('missing'); + + expect(ids(data)).toEqual(['root', 'a', 'b', 'c']); + expect(changes).toEqual([]); + }); + + it('removes a node with its descendants', () => { + const { data, changes } = makeData(); + + data.removeNode('a'); + + expect(ids(data)).toEqual(['root', 'c']); + expect(changes).toEqual([ + { replaced: false, added: [], updated: [], removed: ['a', 'b'] }, + ]); + }); + + it('reports a replacement, with the surviving ids as updated', () => { + const { data, changes } = makeData(); + + data.replaceMap([ + nodeRecord({ id: 'root', isRoot: true, name: 'New' }), + nodeRecord({ id: 'c', parent: 'root' }), + nodeRecord({ id: 'e', parent: 'root' }), + ]); + + expect(ids(data)).toEqual(['root', 'c', 'e']); + expect(changes).toHaveLength(1); + expect(changes[0].replaced).toBe(true); + expect(changes[0].added).toEqual(['e']); + expect(changes[0].updated.sort()).toEqual(['c', 'root']); + expect(changes[0].removed.sort()).toEqual(['a', 'b']); + }); + + it('notifies once per batch, with the outer batch ending last', () => { + const { data, changes } = makeData(); + + data.batch(() => { + data.updateNode('a', 'name', 'A'); + data.batch(() => { + data.addNodes([nodeRecord({ id: 'd', parent: 'c' })]); + data.updateNode('d', 'name', 'D'); + }); + expect(changes).toHaveLength(0); + data.removeNode('b'); + }); + + expect(changes).toEqual([ + { replaced: false, added: ['d'], updated: ['a'], removed: ['b'] }, + ]); + }); + + it('reports nothing for a node added and removed in one batch', () => { + const { data, changes } = makeData(); + + data.batch(() => { + data.addNodes([nodeRecord({ id: 'd', parent: 'c' })]); + data.removeNode('d'); + }); + + expect(changes).toEqual([]); + }); + + it('reports a node removed and added back in one batch as updated', () => { + const { data, changes } = makeData(); + + data.batch(() => { + data.removeNode('c'); + data.addNodes([nodeRecord({ id: 'c', parent: 'root', name: 'C' })]); + }); + + expect(data.node('c')?.name).toBe('C'); + expect(changes).toEqual([ + { replaced: false, added: [], updated: ['c'], removed: [] }, + ]); + }); + + it('notifies for the writes before a batch throws', () => { + const { data, changes } = makeData(); + + expect(() => + data.batch(() => { + data.updateNode('a', 'name', 'A'); + throw new Error('stop'); + }) + ).toThrow('stop'); + + expect(changes.map(change => change.updated)).toEqual([['a']]); + }); + + it('keeps copies of the nodes it receives', () => { + const node = nodeRecord({ id: 'root', isRoot: true }); + const data = new InMemoryMapData([node]); + const value = { x: 1, y: 2 }; + + node.name = 'changed'; + data.updateNode('root', 'coordinates', value); + value.x = 99; + + expect(data.node('root')?.name).toBe(''); + expect(data.node('root')?.coordinates).toEqual({ x: 1, y: 2 }); + }); + + it('stops notifying a listener that unsubscribed', () => { + const data = new InMemoryMapData([nodeRecord({ id: 'root' })]); + const listener = jest.fn(); + const unsubscribe = data.subscribe(listener); + + unsubscribe(); + data.updateNode('root', 'name', 'x'); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mmp/src/map/data/in-memory-map-data.ts b/packages/mmp/src/map/data/in-memory-map-data.ts new file mode 100644 index 00000000..6f7ae7c3 --- /dev/null +++ b/packages/mmp/src/map/data/in-memory-map-data.ts @@ -0,0 +1,184 @@ +import { collectSubtreeIds } from '@teammapper/shared'; +import type { + ExportNodeProperties, + MapSnapshot, + NodeProperty, +} from '@teammapper/shared'; +import Utils from '../../utils/utils.js'; +import type { MapData, MapDataChange, MapNodeRecord } from './map-data.js'; +import { PropertyMapping } from './property-mapping.js'; + +type Listener = (change: MapDataChange) => void; + +/** + * What the running write or batch did so far. `existed` records, for each id + * a write added, rewrote or deleted, whether the data held it when the change + * began. + */ +interface PendingChange { + existed: Map; + updated: Set; + replaced: boolean; +} + +/** + * Map data that holds the records in memory, by id, in insertion order. The + * mmp specs run against it. Each record goes in as a copy, so no caller + * shares an object with it; a read returns the stored record. + */ +export default class InMemoryMapData implements MapData { + private readonly entries = new Map(); + private readonly listeners = new Set(); + private pending: PendingChange | null = null; + private depth = 0; + + constructor(nodes: MapSnapshot = []) { + nodes.forEach(node => this.entries.set(node.id, Utils.cloneObject(node))); + } + + public node(id: string): MapNodeRecord | undefined { + return this.entries.get(id); + } + + public nodes(): MapNodeRecord[] { + return Array.from(this.entries.values()); + } + + public mainRootId(): string | null { + for (const entry of this.entries.values()) { + if (entry.isRoot) return entry.id; + } + return null; + } + + public addNodes(nodes: ExportNodeProperties[]): void { + this.batch(() => { + for (const node of nodes) { + // The flush reports the id as updated only when it existed before + // the change, so a remove and re-add in one batch counts as an update. + const change = this.recordInChange(node.id); + change.updated.add(node.id); + if (node.isRoot) change.replaced = true; + this.entries.set(node.id, Utils.cloneObject(node)); + } + }); + } + + public updateNode(id: string, property: NodeProperty, value: unknown): void { + const entry = this.entries.get(id); + if (!entry) return; + + // A record read earlier keeps its values, as the contract lets a reader + // hold one for the length of a call. + const next = Utils.cloneObject(entry); + writePath(next, PropertyMapping[property], value); + this.batch(() => { + this.recordInChange(id).updated.add(id); + this.entries.set(id, next); + }); + } + + public removeNode(id: string): void { + if (!this.entries.has(id)) return; + + this.batch(() => { + const ids = [id, ...collectSubtreeIds(this.nodes(), id)]; + for (const removed of ids) { + this.recordInChange(removed); + this.entries.delete(removed); + } + }); + } + + public replaceMap(nodes: MapSnapshot): void { + this.batch(() => { + for (const id of this.entries.keys()) this.recordInChange(id); + this.entries.clear(); + this.addNodes(nodes); + }); + } + + public batch(change: () => void): void { + this.depth++; + try { + change(); + } finally { + this.depth--; + if (this.depth === 0) this.flush(); + } + } + + public subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Record `id` in the running change, and return the change. */ + private recordInChange(id: string): PendingChange { + this.pending ??= { + existed: new Map(), + updated: new Set(), + replaced: false, + }; + if (!this.pending.existed.has(id)) { + this.pending.existed.set(id, this.entries.has(id)); + } + return this.pending; + } + + /** Notify the listeners of the finished change, if it changed anything. */ + private flush() { + const pending = this.pending; + this.pending = null; + if (!pending) return; + + const change: MapDataChange = { + replaced: pending.replaced, + added: [], + updated: [], + removed: [], + }; + for (const [id, existed] of pending.existed) { + const exists = this.entries.has(id); + if (!existed && exists) change.added.push(id); + if (existed && !exists) change.removed.push(id); + if (existed && exists && pending.updated.has(id)) { + change.updated.push(id); + } + } + + const empty = + change.added.length + change.updated.length + change.removed.length === 0; + if (empty && !change.replaced) return; + + this.listeners.forEach(listener => listener(change)); + } +} + +/** Write the value at the path, creating each missing object on the way. */ +function writePath( + entry: ExportNodeProperties, + path: readonly string[], + value: unknown +) { + const key = path[path.length - 1]; + const owner = path.slice(0, -1).reduce>( + (target, segment) => { + const next = target[segment]; + if (typeof next === 'object' && next !== null) { + return next as Record; + } + const created: Record = {}; + target[segment] = created; + return created; + }, + entry as unknown as Record + ); + + owner[key] = + typeof value === 'object' && value !== null + ? Utils.cloneObject(value) + : value; +} diff --git a/packages/mmp/src/map/data/map-data.ts b/packages/mmp/src/map/data/map-data.ts new file mode 100644 index 00000000..b61a98d1 --- /dev/null +++ b/packages/mmp/src/map/data/map-data.ts @@ -0,0 +1,73 @@ +import type { + ExportNodeProperties, + MapSnapshot, + NodeProperty, +} from '@teammapper/shared'; +import type { PartialNodeRecord } from './node-record.js'; + +/** + * One node as the map data holds it, with any attribute a peer left out + * missing. An implementation may return the object it stores, so mmp never + * writes to a record and clones one before it keeps a value or hands it to a + * caller. + */ +export type MapNodeRecord = Readonly; + +/** + * The node ids one write or one batch added, updated or removed. + */ +export interface MapDataChange { + /** The change added or rewrote the main root's entry: a load or an import. */ + replaced: boolean; + /** Ids absent before the change and present after it. */ + added: string[]; + /** Ids present on both sides whose attributes changed. */ + updated: string[]; + /** Ids present before the change and absent after it, descendants included. */ + removed: string[]; +} + +/** + * The nodes of one mind map. mmp reads every attribute through the map data, + * writes through its typed methods and keeps no copy of its own. A write by + * mmp, by a peer or by an undo reaches mmp the same way: as a change passed to + * the listeners. + * + * Contract: + * - A write notifies the listeners before it returns. Inside `batch`, the + * writes notify once, when the outermost batch ends. + * - `updateNode` and `removeNode` on an id the data does not hold change + * nothing and notify nothing. + * - `updateNode` takes the mmp property name, such as `backgroundColor`. + * Each implementation finds the storage path in `PropertyMapping`. + * - A `replaceMap` reports the ids that survive it as `updated`, so the view + * state keeps the child nodes of those nodes hidden. + */ +export interface MapData { + /** The node with `id`, or undefined when the data holds none. */ + node(id: string): MapNodeRecord | undefined; + /** Every node, in the data's own order. */ + nodes(): MapNodeRecord[]; + /** The id of the node whose isRoot attribute is true, or null. */ + mainRootId(): string | null; + + /** Add the nodes, or overwrite the nodes whose ids the data holds. */ + addNodes(nodes: ExportNodeProperties[]): void; + /** Write one property of one node. */ + updateNode(id: string, property: NodeProperty, value: unknown): void; + /** Remove the node and its descendants. */ + removeNode(id: string): void; + /** Replace every node: a load, an import or a new map. */ + replaceMap(nodes: MapSnapshot): void; + /** + * Run the writes `change` makes as one change: one notification, one + * update to peers and one undo step. A nested batch joins the outer one. + */ + batch(change: () => void): void; + + /** + * Call the listener after every change. Returns the function that removes + * the listener. + */ + subscribe(listener: (change: MapDataChange) => void): () => void; +} diff --git a/packages/mmp/src/map/data/node-record.spec.ts b/packages/mmp/src/map/data/node-record.spec.ts new file mode 100644 index 00000000..41899d3e --- /dev/null +++ b/packages/mmp/src/map/data/node-record.spec.ts @@ -0,0 +1,82 @@ +import { + MAX_FONT_STYLE_LENGTH, + MAX_IMAGE_SRC_LENGTH, + MAX_LINK_HREF_LENGTH, + MAX_NODE_NAME_LENGTH, +} from '@teammapper/shared'; +import { resolveNode, type PartialNodeRecord } from './node-record.js'; + +/** A record as a peer may write it, with fields the types do not allow. */ +const peerRecord = (fields: Record) => + ({ id: 'node', ...fields }) as unknown as PartialNodeRecord; + +const defaults = resolveNode({ id: 'node' }); + +describe('resolveNode', () => { + it('keeps every value the shared schemas accept', () => { + const record = { + id: 'node', + parent: 'root', + k: -3, + name: 'Name', + coordinates: { x: 10, y: -20 }, + image: { src: 'https://example.org/a.png', size: 40 }, + colors: { + name: '#123', + background: '#abcdef', + branch: '#00000080', + link: '', + }, + font: { size: 18, style: 'italic', weight: 'bold' }, + link: { href: 'https://example.org' }, + protected: true, + isRoot: true, + }; + + expect(resolveNode(record)).toEqual(record); + }); + + it.each([ + ['a number as name', { name: 42 }], + [ + 'a name over the length cap', + { name: 'a'.repeat(MAX_NODE_NAME_LENGTH + 1) }, + ], + ['a parent that is no string', { parent: { id: 'root' } }], + ['an infinite k', { k: Infinity }], + ['a NaN x coordinate', { coordinates: { x: NaN, y: 0 } }], + ['an infinite y coordinate', { coordinates: { x: 0, y: -Infinity } }], + ['a string coordinate', { coordinates: { x: '5', y: 0 } }], + ['a color that loads a url', { colors: { background: 'url(https://x)' } }], + ['a named color', { colors: { name: 'red' } }], + ['a branch color that is no string', { colors: { branch: 7 } }], + [ + 'an image source over the length cap', + { image: { src: 'a'.repeat(MAX_IMAGE_SRC_LENGTH + 1) } }, + ], + ['an infinite image size', { image: { size: Infinity } }], + [ + 'a link over the length cap', + { link: { href: `https://${'a'.repeat(MAX_LINK_HREF_LENGTH)}` } }, + ], + ['a link that runs script', { link: { href: 'javascript:alert(1)' } }], + [ + 'a font style over the length cap', + { font: { style: 'a'.repeat(MAX_FONT_STYLE_LENGTH + 1) } }, + ], + ['a font size that is no number', { font: { size: '12px' } }], + ['a protection that is no boolean', { protected: 'false' }], + ['an `isRoot` attribute that is no boolean', { isRoot: 1 }], + ['styling objects that are strings', { colors: 'red', font: 'big' }], + ])('falls back to the default for %s', (_, fields) => { + expect(resolveNode(peerRecord(fields))).toEqual(defaults); + }); + + it.each([ + ['an empty string', ''], + ['null', null], + ['undefined', undefined], + ])('reads %s as parent as null', (_, parent) => { + expect(resolveNode({ id: 'node', parent }).parent).toBeNull(); + }); +}); diff --git a/packages/mmp/src/map/data/node-record.ts b/packages/mmp/src/map/data/node-record.ts new file mode 100644 index 00000000..5cebc295 --- /dev/null +++ b/packages/mmp/src/map/data/node-record.ts @@ -0,0 +1,105 @@ +import * as d3 from 'd3'; +import * as v from 'valibot'; +import { CssColorSchema, NodePropertySchemas } from '@teammapper/shared'; +import type { + MapNodeColors, + MapNodeCoordinates, + MapNodeFont, + MapNodeImage, + MapNodeLink, + Resolved, + UserNodeProperties, +} from '@teammapper/shared'; + +/** + * Style values of a node with every field filled. The shared `MapNode*` + * counterparts leave each field optional because the wire format may omit + * it. + */ +export type NodeColors = Resolved; +export type NodeFont = Resolved; +export type NodeImage = Resolved; +export type NodeLink = Resolved; + +/** + * A node record with every attribute filled, read once per draw pass or per + * call and dropped after. `parent` holds null for a node without a parent. + */ +export interface ResolvedNode { + id: string; + parent: string | null; + k: number; + name: string; + coordinates: MapNodeCoordinates; + image: NodeImage; + colors: NodeColors; + font: NodeFont; + link: NodeLink; + protected: boolean; + isRoot: boolean; +} + +/** The node attributes a record may carry, some of them missing. */ +export interface PartialNodeRecord extends UserNodeProperties { + id: string; + parent?: string | null; + k?: number; +} + +const FiniteSchema = v.pipe(v.number(), v.finite()); +const ParentSchema = v.string(); +const BooleanSchema = v.boolean(); + +/** The value when it passes the schema, otherwise undefined. */ +function valid( + schema: S, + value: unknown +): v.InferOutput | undefined { + const result = v.safeParse(schema, value); + return result.success ? result.output : undefined; +} + +/** + * Fill every attribute the record lacks or holds in a shape the shared + * schemas reject, such as a number as name or a color that is no hex color. + * Every record mmp reads passes through here, so a peer's value reaches the + * DOM only after the schema check. The result shares no object with the + * record, so a caller may change it. + */ +export function resolveNode(record: PartialNodeRecord): ResolvedNode { + const { colors, coordinates, font, image, link } = record; + const color = (value: unknown) => valid(CssColorSchema, value) || ''; + return { + id: record.id, + parent: valid(ParentSchema, record.parent) || null, + k: valid(FiniteSchema, record.k) || 0, + name: valid(NodePropertySchemas.name, record.name) || '', + coordinates: { + x: valid(FiniteSchema, coordinates?.x) ?? 0, + y: valid(FiniteSchema, coordinates?.y) ?? 0, + }, + image: { + src: valid(NodePropertySchemas.imageSrc, image?.src) || '', + size: valid(FiniteSchema, image?.size) || 0, + }, + colors: { + name: color(colors?.name), + background: color(colors?.background), + branch: color(colors?.branch), + link: color(colors?.link), + }, + font: { + size: valid(FiniteSchema, font?.size) || 12, + style: valid(NodePropertySchemas.fontStyle, font?.style) || 'normal', + weight: valid(NodePropertySchemas.fontWeight, font?.weight) || 'normal', + }, + link: { href: valid(NodePropertySchemas.linkHref, link?.href) || '' }, + protected: valid(BooleanSchema, record.protected) ?? false, + isRoot: valid(BooleanSchema, record.isRoot) ?? false, + }; +} + +/** The bend of a new node's background shape. */ +export function randomK(): number { + return d3.randomUniform(-20, 20)(); +} diff --git a/packages/mmp/src/map/data/property-mapping.ts b/packages/mmp/src/map/data/property-mapping.ts new file mode 100644 index 00000000..8e0e2385 --- /dev/null +++ b/packages/mmp/src/map/data/property-mapping.ts @@ -0,0 +1,24 @@ +import type { NodeProperty } from '@teammapper/shared'; + +/** + * The path of each node property mmp exposes inside a node record. mmp reads + * a property through this path, and each `MapData` implementation writes an + * `updateNode` through it. + */ +export const PropertyMapping = { + name: ['name'], + protected: ['protected'], + coordinates: ['coordinates'], + imageSrc: ['image', 'src'], + imageSize: ['image', 'size'], + linkHref: ['link', 'href'], + backgroundColor: ['colors', 'background'], + branchColor: ['colors', 'branch'], + fontWeight: ['font', 'weight'], + fontStyle: ['font', 'style'], + fontSize: ['font', 'size'], + nameColor: ['colors', 'name'], +} as const satisfies Record; + +export const isNodeProperty = (property: string): property is NodeProperty => + Object.keys(PropertyMapping).includes(property); diff --git a/packages/mmp/src/map/handlers/apply-coordinates.spec.ts b/packages/mmp/src/map/handlers/apply-coordinates.spec.ts index 25c4a1b3..815dd43e 100644 --- a/packages/mmp/src/map/handlers/apply-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/apply-coordinates.spec.ts @@ -1,20 +1,21 @@ import Nodes from './nodes.js'; import MmpMap from '../map.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import type { MapNodeCoordinates, ExportNodeProperties, } from '@teammapper/shared'; /** - * Snapshots here are shaped the way an import delivers them: the root has - * `parent: ''` and `isRoot: true`, and a node without a saved position has no - * `coordinates` key at all. + * Snapshots here are shaped the way an import of an older export delivers + * them: the root has `parent: ''` and `isRoot: true`, and a node without a + * saved position has no `coordinates` key at all. */ function createHandler(): Nodes { // No live map needed: the snapshot's nodes do not exist yet, so everything is - // derived from the snapshot itself. - return new Nodes({} as unknown as MmpMap); + // derived from the snapshot itself and the sizes its names have. + return new Nodes({ draw: fakeDraw() } as unknown as MmpMap); } function makeNode( diff --git a/packages/mmp/src/map/handlers/branch-visibility.spec.ts b/packages/mmp/src/map/handlers/branch-visibility.spec.ts index 29871272..a655be2b 100644 --- a/packages/mmp/src/map/handlers/branch-visibility.spec.ts +++ b/packages/mmp/src/map/handlers/branch-visibility.spec.ts @@ -1,168 +1,210 @@ -import Nodes from './nodes.js'; -import Node from '../models/node.js'; -import { DefaultNodeValues } from '../options.js'; +import * as d3 from 'd3'; +import { create } from '../../index.js'; import MmpMap from '../map.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; +import { nodeRecord } from '../../test/stub-map.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; /** - * Hiding a branch stays local to one person, so a second person keeps adding - * nodes to a branch this person hid. These tests pin the toggle to one - * decision per branch, so children that disagree end up in the same state - * instead of swapping. + * Hiding the child nodes of a node changes the view state of one person + * alone, so a second person keeps adding nodes below a node whose child + * nodes this person hid. These specs drive a real map and read the drawn + * visibility. A peer's write goes straight to the map data. */ -interface NodesInternals { - nodes: Map; - selectedNode: Node; +beforeAll(stubSvgLengths); + +interface Tree { + map: MmpMap; + data: InMemoryMapData; + root: string; + first: string; + second: string; + grandchild: string; } -function makeTree(): { - handler: Nodes; - internals: NodesInternals; - nodes: Record; -} { - const map = { - options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn() }, - history: { save: jest.fn() }, - } as unknown as MmpMap; - - const handler = new Nodes(map); - map.nodes = handler; - const internals = handler as unknown as NodesInternals; - - const root = new Node({ - id: 'root', - parent: null, - k: 1, - isRoot: true, - coordinates: { x: 0, y: 0 }, - }); - const first = new Node({ - id: 'first', - parent: root, - k: 1, - coordinates: { x: 200, y: 0 }, - }); - const second = new Node({ - id: 'second', - parent: root, - k: 1, - coordinates: { x: 200, y: 100 }, - }); - const grandchild = new Node({ - id: 'grandchild', - parent: first, - k: 1, - coordinates: { x: 400, y: 0 }, - }); +/** root -> first -> grandchild, root -> second, with the root selected. */ +function makeTree(): Tree { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const data = new InMemoryMapData(); + const map = create('map', ref, undefined, data); + map.instance.new(); + + const root = map.instance.exportRootProperties()?.id; + if (!root) throw new Error('the map has no main root'); + const first = map.instance.addNode({ name: 'first' }, root); + const second = map.instance.addNode({ name: 'second' }, root); + if (!first || !second) throw new Error('addNode added no child'); + const grandchild = map.instance.addNode({ name: 'grandchild' }, first.id); + if (!grandchild) throw new Error('addNode added no grandchild'); + map.instance.selectNode(root); + + return { + map, + data, + root, + first: first.id, + second: second.id, + grandchild: grandchild.id, + }; +} - const nodes = { root, first, second, grandchild }; - for (const node of Object.values(nodes)) internals.nodes.set(node.id, node); +function nodeGroup(id: string): SVGGElement { + const group = d3 + .selectAll('g.node') + .filter(datum => datum === id) + .node(); + if (!group) throw new Error('no group for ' + id); + return group; +} - return { handler, internals, nodes }; +function visibility(id: string): string { + return nodeGroup(id).style.visibility; } +function hasEyeIcon(id: string): boolean { + return nodeGroup(id).querySelector('text.hidden-icon') !== null; +} + +/** Select the node and toggle the visibility of its child nodes. */ +function toggle(map: MmpMap, id: string) { + map.instance.selectNode(id); + map.instance.toggleBranchVisibility(); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + describe('toggleBranchVisibility', () => { it('hides every descendant of the selected node', () => { - const { handler, internals, nodes } = makeTree(); - internals.selectedNode = nodes.root; + const { map, root, first, second, grandchild } = makeTree(); - handler.toggleBranchVisibility(); + toggle(map, root); - expect(nodes.root.hasHiddenChildNodes).toBe(true); - expect(nodes.first.hidden).toBe(true); - expect(nodes.second.hidden).toBe(true); - expect(nodes.grandchild.hidden).toBe(true); + expect(visibility(root)).toBe('visible'); + expect(visibility(first)).toBe('hidden'); + expect(visibility(second)).toBe('hidden'); + expect(visibility(grandchild)).toBe('hidden'); }); it('shows every descendant again', () => { - const { handler, internals, nodes } = makeTree(); - internals.selectedNode = nodes.root; + const { map, root, first, second, grandchild } = makeTree(); + + toggle(map, root); + toggle(map, root); + + expect(visibility(first)).toBe('visible'); + expect(visibility(second)).toBe('visible'); + expect(visibility(grandchild)).toBe('visible'); + }); + + it('hides a node a peer adds below a node whose child nodes are hidden', () => { + const { map, data, root } = makeTree(); + toggle(map, root); - handler.toggleBranchVisibility(); - handler.toggleBranchVisibility(); + data.addNodes([ + nodeRecord({ + id: 'late', + parent: root, + name: 'late', + coordinates: { x: 200, y: 200 }, + }), + ]); - expect(nodes.root.hasHiddenChildNodes).toBe(false); - expect(nodes.first.hidden).toBe(false); - expect(nodes.second.hidden).toBe(false); - expect(nodes.grandchild.hidden).toBe(false); + expect(visibility('late')).toBe('hidden'); }); - it('shows a branch whose children disagree instead of swapping them', () => { - const { handler, internals, nodes } = makeTree(); - internals.selectedNode = nodes.root; - handler.toggleBranchVisibility(); - - // A node that reached the map while the branch was already hidden and kept - // its visible state. - const late = new Node({ - id: 'late', - parent: nodes.root, - k: 1, - coordinates: { x: 200, y: 200 }, - }); - internals.nodes.set(late.id, late); - - handler.toggleBranchVisibility(); - - expect(nodes.first.hidden).toBe(false); - expect(nodes.second.hidden).toBe(false); - expect(late.hidden).toBe(false); - expect(nodes.root.hasHiddenChildNodes).toBe(false); + it('keeps the child nodes of an inner node hidden when the outer node shows', () => { + const { map, root, first, second, grandchild } = makeTree(); + toggle(map, first); + + toggle(map, root); + toggle(map, root); + + expect(visibility(first)).toBe('visible'); + expect(visibility(second)).toBe('visible'); + expect(visibility(grandchild)).toBe('hidden'); + expect(map.instance.childNodesHidden(first)).toBe(true); }); - it('leaves a branch hidden further down hidden', () => { - const { handler, internals, nodes } = makeTree(); + it('does nothing to a node without child nodes', () => { + const { map, second } = makeTree(); - internals.selectedNode = nodes.first; - handler.toggleBranchVisibility(); + toggle(map, second); - internals.selectedNode = nodes.root; - handler.toggleBranchVisibility(); - handler.toggleBranchVisibility(); + expect(map.viewState.hidesChildren(second)).toBe(false); + }); + + it('reports no hidden child nodes once a peer removes the last child node', () => { + const { map, data, first, grandchild } = makeTree(); + toggle(map, first); - expect(nodes.first.hidden).toBe(false); - expect(nodes.second.hidden).toBe(false); - expect(nodes.first.hasHiddenChildNodes).toBe(true); - expect(nodes.grandchild.hidden).toBe(true); + data.removeNode(grandchild); + + expect(map.instance.childNodesHidden(first)).toBe(false); + expect(map.viewState.hidesChildren(first)).toBe(true); }); - it('does nothing to a node without children', () => { - const { handler, internals, nodes } = makeTree(); - internals.selectedNode = nodes.second; + it('forgets a removed node whose child nodes were hidden', () => { + const { map, data, root, first } = makeTree(); + toggle(map, first); + map.instance.selectNode(root); - handler.toggleBranchVisibility(); + data.removeNode(first); - expect(nodes.second.hasHiddenChildNodes).toBe(false); + expect(map.viewState.isEmpty()).toBe(true); }); -}); -describe('addNode', () => { - it('hides a node added to a branch the person hid', () => { - const { handler, internals, nodes } = makeTree(); - internals.selectedNode = nodes.root; - handler.toggleBranchVisibility(); + it('does nothing while nothing is selected', () => { + const { map } = makeTree(); + map.nodes.deselectNode(); - const added = handler.addNode( - { coordinates: { x: 200, y: 200 } }, - false, - false, - nodes.root.id - ); + map.instance.toggleBranchVisibility(); - expect(added.hidden).toBe(true); + expect(map.viewState.isEmpty()).toBe(true); }); - it('shows a node added to a branch nobody hid', () => { - const { handler, nodes } = makeTree(); + it('fires no event', () => { + const { map, root } = makeTree(); + const listener = jest.fn(); + map.instance.on('mapChange', listener); + map.instance.on('nodeSelect', listener); - const added = handler.addNode( - { coordinates: { x: 200, y: 200 } }, - false, - false, - nodes.root.id - ); + map.instance.toggleBranchVisibility(); + + expect(map.instance.childNodesHidden(root)).toBe(true); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('the hidden eye icon', () => { + it('shows on a node whose child nodes are hidden only', () => { + const { map, root, first, second } = makeTree(); + + toggle(map, root); + + expect(hasEyeIcon(root)).toBe(true); + expect(hasEyeIcon(first)).toBe(false); + expect(hasEyeIcon(second)).toBe(false); + }); - expect(added.hidden).toBe(false); + it('leaves once a peer removes the last child node and returns with a new one', () => { + const { map, data, first, grandchild } = makeTree(); + toggle(map, first); + + data.removeNode(grandchild); + expect(hasEyeIcon(first)).toBe(false); + + data.addNodes([ + nodeRecord({ + id: 'late', + parent: first, + name: 'late', + coordinates: { x: 9, y: 9 }, + }), + ]); + expect(hasEyeIcon(first)).toBe(true); }); }); diff --git a/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts b/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts index 3148be43..e25c4b8d 100644 --- a/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts @@ -1,124 +1,110 @@ -import Nodes from './nodes.js'; -import Node, { NodeProperties } from '../models/node.js'; -import type { MapNodeCoordinates } from '@teammapper/shared'; -import MmpMap from '../map.js'; - -function makeNode(properties: Partial & { id: string }): Node { - return new Node({ k: 1, parent: null, ...properties }); -} +import type { RecordLookup } from './nodes.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; +import type { MapNodeCoordinates, MapSnapshot } from '@teammapper/shared'; /** - * The node under test is seeded alongside the existing ones because that is the - * state `addNode` calls this in - `getSiblings` splices the node itself back - * out, so it never counts as its own sibling. + * Where a new node under `parent` goes, on a map holding `existing`. The new + * node is not in the map data yet, as when `addNode` calls this. */ -function placementOf(node: Node, existing: Node[]): MapNodeCoordinates { - const handler = new Nodes({ - rootId: 'root', - } as unknown as MmpMap) as unknown as { - nodes: Map; - calculateCoordinates(node: Node): MapNodeCoordinates; - }; - handler.nodes = new Map([...existing, node].map(each => [each.id, each])); - - return handler.calculateCoordinates(node); +function placementOf( + parent: string, + existing: MapSnapshot +): MapNodeCoordinates { + const { nodes } = stubMap(existing); + + return ( + nodes as unknown as { + calculateCoordinates( + parent: string, + lookup: RecordLookup + ): MapNodeCoordinates; + } + ).calculateCoordinates(parent, nodes.record); } describe('calculateCoordinates', () => { - const root = makeNode({ + const root = nodeRecord({ id: 'root', isRoot: true, coordinates: { x: 0, y: 0 }, }); - const leftBranch = makeNode({ + const leftBranch = nodeRecord({ id: 'left', - parent: root, + parent: 'root', coordinates: { x: -200, y: -120 }, }); - const rightBranch = makeNode({ + const rightBranch = nodeRecord({ id: 'right', - parent: root, + parent: 'root', coordinates: { x: 200, y: -120 }, }); it('puts the first child of the root one column to the left and above it', () => { - const child = makeNode({ id: 'first', parent: root }); - - expect(placementOf(child, [root])).toEqual({ x: -200, y: -120 }); + expect(placementOf('root', [root])).toEqual({ x: -200, y: -120 }); }); it('puts the second child of the root on the empty right side', () => { - const child = makeNode({ id: 'second', parent: root }); - - expect(placementOf(child, [root, leftBranch])).toEqual({ + expect(placementOf('root', [root, leftBranch])).toEqual({ x: 200, y: -120, }); }); it('stacks a third child below the lowest sibling on the emptier side', () => { - const child = makeNode({ id: 'third', parent: root }); - - expect(placementOf(child, [root, leftBranch, rightBranch])).toEqual({ + expect(placementOf('root', [root, leftBranch, rightBranch])).toEqual({ x: -200, y: -60, }); }); it('keeps a grandchild on the side of its branch', () => { - const child = makeNode({ id: 'grandchild', parent: leftBranch }); - - expect(placementOf(child, [root, leftBranch])).toEqual({ + expect(placementOf('left', [root, leftBranch])).toEqual({ x: -400, y: -240, }); }); it('stacks a second grandchild below its sibling', () => { - const firstGrandchild = makeNode({ + const firstGrandchild = nodeRecord({ id: 'grandchild', - parent: leftBranch, + parent: 'left', coordinates: { x: -400, y: -240 }, }); - const child = makeNode({ id: 'second-grandchild', parent: leftBranch }); - expect(placementOf(child, [root, leftBranch, firstGrandchild])).toEqual({ + expect(placementOf('left', [root, leftBranch, firstGrandchild])).toEqual({ x: -400, y: -180, }); }); describe('in a second tree right of the main tree', () => { - const secondRoot = makeNode({ + const secondRoot = nodeRecord({ id: 'second-root', coordinates: { x: 1000, y: 0 }, }); - const secondLeft = makeNode({ + const secondLeft = nodeRecord({ id: 'second-left', - parent: secondRoot, + parent: 'second-root', coordinates: { x: 800, y: -120 }, }); const existing = [root, leftBranch, rightBranch, secondRoot]; it('puts the first child of the second root on its left', () => { - const child = makeNode({ id: 'first', parent: secondRoot }); - - expect(placementOf(child, existing)).toEqual({ x: 800, y: -120 }); + expect(placementOf('second-root', existing)).toEqual({ + x: 800, + y: -120, + }); }); it('puts the second child of the second root on its empty right', () => { - const child = makeNode({ id: 'second', parent: secondRoot }); - - expect(placementOf(child, [...existing, secondLeft])).toEqual({ + expect(placementOf('second-root', [...existing, secondLeft])).toEqual({ x: 1200, y: -120, }); }); it('keeps a grandchild on the left of its own tree root', () => { - const child = makeNode({ id: 'grandchild', parent: secondLeft }); - - expect(placementOf(child, [...existing, secondLeft])).toEqual({ + expect(placementOf('second-left', [...existing, secondLeft])).toEqual({ x: 600, y: -240, }); diff --git a/packages/mmp/src/map/handlers/copy-paste.ts b/packages/mmp/src/map/handlers/copy-paste.ts index 53089003..41dba141 100644 --- a/packages/mmp/src/map/handlers/copy-paste.ts +++ b/packages/mmp/src/map/handlers/copy-paste.ts @@ -1,5 +1,5 @@ -import Map from '../map.js'; -import Node from '../models/node.js'; +import MmpMap from '../map.js'; +import { collectSubtreeIds } from '@teammapper/shared'; import type { ExportNodeProperties, MapNodeCoordinates, @@ -7,17 +7,20 @@ import type { } from '@teammapper/shared'; import Log from '../../utils/log.js'; import Utils from '../../utils/utils.js'; -import { Event } from './events.js'; +import type { ResolvedNode } from '../data/node-record.js'; +import { lookupIn, type RecordLookup } from './nodes.js'; import type { Bounds } from './node-geometry.js'; -import { moveBounds, nodeBounds, unionBounds } from './tree-placement.js'; +import { moveBounds, unionBounds } from './tree-placement.js'; const ORIGIN: MapNodeCoordinates = { x: 0, y: 0 }; /** - * Manage the drag events of the nodes. + * Copy, cut and paste branches through the mmp clipboard. The clipboard + * holds copies of the copied records, so a later change of the map leaves + * it as it was. */ export default class CopyPaste { - private map: Map; + private map: MmpMap; private copiedNodes: ExportNodeProperties[] = []; @@ -36,9 +39,9 @@ export default class CopyPaste { /** * Get the associated map instance. - * @param {Map} map + * @param {MmpMap} map */ - constructor(map: Map) { + constructor(map: MmpMap) { this.map = map; } @@ -70,7 +73,7 @@ export default class CopyPaste { if (!node) return false; if (node.isRoot) return Log.error('The root node can not be cut'); - if (this.map.nodes.refusesLocalRemoval(node)) return false; + if (this.map.nodes.refusesRemoval(node.id)) return false; this.copyToClipboard(node); this.map.nodes.removeNode(node.id); @@ -78,29 +81,36 @@ export default class CopyPaste { }; /** - * Write a node and its descendants to the mmp clipboard, together with the - * x of the root of its tree. - * @param {Node} node + * Write copies of a node and its descendants to the mmp clipboard, + * together with the x of the root of its tree. + * @param {ResolvedNode} node */ - private copyToClipboard(node: Node) { - const copied = [node, ...this.map.nodes.getDescendants(node)]; - this.copiedNodes = copied.map(copiedNode => - this.map.nodes.getNodeProperties(copiedNode, false) - ); - this.copiedTreeRootX = this.map.nodes.getTreeRoot(node).coordinates.x; + private copyToClipboard(node: ResolvedNode) { + const nodes = this.map.nodes; + const records = nodes.scan(); + const lookup = lookupIn(records); + const copied = [ + node, + ...collectSubtreeIds([...records.values()], node.id).flatMap( + id => records.get(id) ?? [] + ), + ]; + + this.copiedNodes = copied.map(record => Utils.cloneObject(record)); + this.copiedTreeRootX = nodes.positionOf( + nodes.treeRoot(node.id, lookup), + lookup + ).x; this.copiedFootprint = this.footprintOf(copied, node.coordinates); } /** The bounding box of `nodes` relative to `origin`. */ - private footprintOf(nodes: Node[], origin: MapNodeCoordinates): Bounds { + private footprintOf(nodes: ResolvedNode[], origin: MapNodeCoordinates) { return nodes .map(node => - nodeBounds({ - coordinates: { - x: node.coordinates.x - origin.x, - y: node.coordinates.y - origin.y, - }, - dimensions: node.dimensions, + moveBounds(this.map.nodes.boundsOf(node), { + x: -origin.x, + y: -origin.y, }) ) .reduce(unionBounds); @@ -117,9 +127,9 @@ export default class CopyPaste { const node = this.map.nodes.getTargetNode(id); if (!node) return; - if (this.map.nodes.refusesLocalChange(node)) return; + if (this.map.nodes.refusesChange(node.id)) return; - this.pasteInto(node); + this.pasteInto(node.id); }; /** @@ -153,52 +163,60 @@ export default class CopyPaste { } /** - * Add the copied nodes under `parent`, or as a new tree for null, and - * announce them in one paste event. Returns the node pasted in place of the - * copied node. + * Add the copied nodes under `parent`, or as a new tree for null, in one + * write. The records are built first, each placed against the records + * built before it. Returns the record pasted in place of the copied node. */ - private pasteInto(parent: Node | null): Node { + private pasteInto(parent: string | null): ResolvedNode { this.pastingTree = parent === null; - const newNodes: Node[] = []; + const pasted = new Map(); + const lookup: RecordLookup = id => + pasted.get(id) ?? this.map.nodes.record(id); + const pastedNode = this.addCopiedNode( this.copiedNodes[0], parent, - newNodes + pasted, + lookup, + new Set() ); - - this.map.draw.clear(); - this.map.draw.update(); - this.map.nodes.redrawSelectionRing(); - this.map.history.save(); - - const pasted = newNodes.map(node => this.map.nodes.getNodeProperties(node)); - this.map.events.call(Event.nodePaste, parent?.dom, pasted); + // The batch makes the paste one undo step of its own, apart from an + // edit right before or after it. + const data = this.map.data; + data.batch(() => data.addNodes([...pasted.values()])); return pastedNode; } /** - * Add a copied node under `newParentNode`, then its copied children under - * the node just created. Returns the node just created. + * Build the record of a copied node under `newParent`, then those of its + * copied children under the record just built. Returns that record. + * `visited` holds the copied ids built so far, so a parent cycle among the + * copied nodes ends at the node that closes it. */ private addCopiedNode( nodeProperties: ExportNodeProperties, - newParentNode: Node | null, - newNodes: Node[] - ): Node { - const createdNode = this.map.nodes.addNode( - this.pastedProperties(nodeProperties, newParentNode), - false, - false, - newParentNode?.id ?? null + newParent: string | null, + pasted: Map, + lookup: RecordLookup, + visited: Set + ): ResolvedNode { + visited.add(nodeProperties.id); + const record = this.map.nodes.newRecord( + this.pastedProperties(nodeProperties, newParent, lookup), + newParent, + undefined, + lookup ); - newNodes.push(createdNode); + pasted.set(record.id, record); - this.getChildrenInCopiedNodes(nodeProperties.id).forEach(child => - this.addCopiedNode(child, createdNode, newNodes) - ); + this.getChildrenInCopiedNodes(nodeProperties.id) + .filter(child => !visited.has(child.id)) + .forEach(child => + this.addCopiedNode(child, record.id, pasted, lookup, visited) + ); - return createdNode; + return record; } /** @@ -208,14 +226,15 @@ export default class CopyPaste { */ private pastedProperties( nodeProperties: ExportNodeProperties, - newParentNode: Node | null + newParent: string | null, + lookup: RecordLookup ): UserNodeProperties { const copy = Utils.cloneObject(nodeProperties); - const branch = this.pastedBranchColor(newParentNode); + const branch = this.pastedBranchColor(newParent, lookup); return { name: copy.name, - coordinates: this.pastedCoordinates(nodeProperties, newParentNode), + coordinates: this.pastedCoordinates(nodeProperties, newParent, lookup), image: copy.image, colors: { ...copy.colors, branch }, font: copy.font, @@ -229,11 +248,15 @@ export default class CopyPaste { * root node. A pasted child takes its new parent's branch color, or the * default one. */ - private pastedBranchColor(newParentNode: Node | null): string { - if (!newParentNode) return ''; + private pastedBranchColor( + newParent: string | null, + lookup: RecordLookup + ): string { + if (newParent === null) return ''; return ( - newParentNode.colors?.branch || this.map.options.defaultNode.colors.branch + lookup(newParent)?.colors.branch || + this.map.options.defaultNode.colors.branch ); } @@ -241,51 +264,54 @@ export default class CopyPaste { * The coordinates of a pasted node: * - a pasted root takes `newTreeCoordinates()` for the footprint of the * copied nodes - * - the first node pasted under a parent takes `undefined`, and `addNode` - * places it + * - the first node pasted under a parent takes `undefined`, and + * `newRecord` places it * - every other node keeps its offset to its own copied parent */ private pastedCoordinates( nodeProperties: ExportNodeProperties, - newParentNode: Node | null + newParent: string | null, + lookup: RecordLookup ): MapNodeCoordinates | undefined { - if (!newParentNode) { + if (newParent === null) { return this.map.nodes.newTreeCoordinates(this.requireCopiedFootprint()); } if (nodeProperties.id === this.copiedNodes[0].id) return undefined; - return this.calculatePastedCoordinates(nodeProperties, newParentNode); + return this.calculatePastedCoordinates(nodeProperties, newParent, lookup); } /** * Keep the offset a copied node had to its old parent, mirrored when the new * parent is on the other side of its tree root. * @param {ExportNodeProperties} nodeProperties - * @param {Node} newParentNode + * @param {string} newParent * @returns {MapNodeCoordinates} coordinates */ private calculatePastedCoordinates( nodeProperties: ExportNodeProperties, - newParentNode: Node + newParent: string, + lookup: RecordLookup ): MapNodeCoordinates { const oldParentNode = this.findInCopiedNodes(nodeProperties.parent); const oldParent = oldParentNode?.coordinates ?? ORIGIN; const node = nodeProperties.coordinates ?? ORIGIN; + const newParentPosition = this.map.nodes.positionOf(newParent, lookup); // Only a pasted tree has a root as a new parent. The root has no side, // and its children keep the sides they had. const oldTreeRootX = this.pastingTree ? (this.copiedNodes[0].coordinates?.x ?? 0) : this.copiedTreeRootX; - const newSide = this.map.nodes.getOrientation(newParentNode); + const newSide = this.map.nodes.orientation(newParent, lookup); const mirrored = newSide !== undefined && oldParent.x < oldTreeRootX !== newSide; const dx = mirrored ? node.x - oldParent.x : oldParent.x - node.x; return this.map.nodes.fixCoordinates( { - x: newParentNode.coordinates.x - dx, - y: newParentNode.coordinates.y - (oldParent.y - node.y), + x: newParentPosition.x - dx, + y: newParentPosition.y - (oldParent.y - node.y), }, true ); diff --git a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts index 49021a73..f2ff68e9 100644 --- a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts +++ b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts @@ -1,201 +1,90 @@ -import * as d3 from 'd3'; -import Nodes from './nodes.js'; -import Node from '../models/node.js'; -import MmpMap from '../map.js'; -import { Event } from './events.js'; - -interface StubMap { - id: string; - draw: { update: jest.Mock; drawBranch: jest.Mock }; - history: { save: jest.Mock }; - events: { call: jest.Mock }; -} - -function stubMap(): StubMap { - return { - id: 'test-map', - draw: { - update: jest.fn(), - // The real `drawBranch` returns nothing for a node without a parent. - drawBranch: jest.fn((node: Node) => (node.parent ? 'M0,0' : undefined)), - }, - history: { save: jest.fn() }, - events: { call: jest.fn() }, - }; -} - -/** Branch paths the way `draw.update()` binds them: one per node but the root. */ -function attachBranchPaths(mapId: string, nodes: Node[]): void { - d3.select(document.body) - .append('svg') - .selectAll('path') - .data(nodes.slice(1)) - .enter() - .append('path') - .attr('class', `${mapId}_branch`) - .attr('id', node => `${node.id}_branch`); -} +import { fakeDraw } from '../../test/fake-draw.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; +import type { MapDataChange } from '../data/map-data.js'; +import type { MapSnapshot } from '@teammapper/shared'; + +function handlerWith(snapshot: MapSnapshot) { + const stub = stubMap(snapshot, { + // Every node measured at 100 by 30. + draw: fakeDraw(() => ({ width: 100, height: 30 })), + }); + const changes: MapDataChange[] = []; + stub.data.subscribe(change => changes.push(change)); -/** Only the fields distribution reads, hence the cast. */ -function liveNode( - id: string, - parent: Node | null, - overrides: Partial = {} -): Node { - return { - id, - parent, - name: id, - isRoot: false, - hidden: false, - coordinates: { x: 0, y: 0 }, - dimensions: { width: 100, height: 30 }, - ...overrides, - } as unknown as Node; + return { ...stub, handler: stub.nodes, changes }; } -interface NodesInternals { - nodes: Map; -} - -function handlerWith(nodes: Node[]): { handler: Nodes; map: StubMap } { - const map = stubMap(); - const handler = new Nodes(map as unknown as MmpMap); - (handler as unknown as NodesInternals).nodes = new Map( - nodes.map(node => [node.id, node]) - ); - - return { handler, map }; +function node(id: string, parent: string, isRoot = false) { + return nodeRecord({ id, parent, name: id, isRoot }); } /** Root with four branches of three children: the shape an AI import makes. */ -function aiShapedNodes(): Node[] { - const root = liveNode('root', null, { isRoot: true }); - const nodes: Node[] = [root]; +function aiShapedNodes(): MapSnapshot { + const nodes: MapSnapshot = [node('root', '', true)]; for (const branch of ['A', 'B', 'C', 'D']) { - const branchNode = liveNode(branch, root); - nodes.push(branchNode); + nodes.push(node(branch, 'root')); for (let i = 1; i <= 3; i++) { - nodes.push(liveNode(`${branch}${i}`, branchNode)); + nodes.push(node(`${branch}${i}`, branch)); } } return nodes; } describe('distributeNodes', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('writes new coordinates onto the live nodes', () => { - const nodes = aiShapedNodes(); - const { handler } = handlerWith(nodes); - const before = nodes.map(node => ({ ...node.coordinates })); + it('writes new coordinates to the map data', () => { + const { handler, data } = handlerWith(aiShapedNodes()); handler.distributeNodes(); - const moved = nodes.filter( - (node, i) => - node.coordinates.x !== before[i].x || node.coordinates.y !== before[i].y - ); + const moved = data + .nodes() + .filter(n => n.coordinates?.x !== 0 || n.coordinates?.y !== 0); expect(moved.length).toBeGreaterThan(0); }); - it('hands the hidden nodes to the layout too, so they keep their room', () => { - // A collapsed branch is still laid out, so expanding it again does not - // leave its children piled up. - const root = liveNode('root', null, { isRoot: true }); - const collapsed = liveNode('collapsed', root); - const nodes = [root, collapsed]; - for (let i = 1; i <= 4; i++) { - nodes.push(liveNode(`c${i}`, collapsed, { hidden: true })); - } - const { handler } = handlerWith(nodes); + it('hands every node to the layout, so hidden nodes keep their room', () => { + // The layout never reads the view state. Child nodes this person hid are + // still laid out, so showing them again does not leave them piled up. + const children = [1, 2, 3, 4].map(i => node(`c${i}`, 'parent')); + const { handler, data, map } = handlerWith([ + node('root', '', true), + node('parent', 'root'), + ...children, + ]); + map.viewState.toggle('parent'); handler.distributeNodes(); // Filtered out of the layout input they would all keep y: 0. - const hiddenYs = nodes - .filter(node => node.hidden) - .map(node => node.coordinates.y); - expect(new Set(hiddenYs).size).toBe(4); - }); - - it('updates the transform of a node that has been drawn', () => { - const root = liveNode('root', null, { isRoot: true }); - const setAttribute = jest.fn(); - const child = liveNode('child', root, { - dom: { setAttribute } as unknown as SVGGElement, - }); - const { handler } = handlerWith([root, child]); - - handler.distributeNodes(); - - expect(setAttribute).toHaveBeenCalledWith( - 'transform', - expect.stringContaining('translate(') - ); - }); - - it('records the whole redistribution as a single history entry', () => { - const { handler, map } = handlerWith(aiShapedNodes()); - - handler.distributeNodes(); - - expect(map.history.save).toHaveBeenCalledTimes(1); + const childYs = children.map(child => data.node(child.id)?.coordinates?.y); + expect(new Set(childYs).size).toBe(4); }); - it('redraws the map once rather than once per node', () => { - const { handler, map } = handlerWith(aiShapedNodes()); + it('writes every coordinate in one change', () => { + const { handler, changes } = handlerWith(aiShapedNodes()); handler.distributeNodes(); - expect(map.draw.update).toHaveBeenCalledTimes(1); + expect(changes).toHaveLength(1); + expect(changes[0].updated.length).toBeGreaterThan(1); }); - it('emits a distribute event so the sync layer can propagate the rewrite', () => { - const { handler, map } = handlerWith(aiShapedNodes()); - + it('writes only the coordinates that change', () => { + const { handler, changes } = handlerWith(aiShapedNodes()); handler.distributeNodes(); - const events = map.events.call.mock.calls.map(call => call[0]); - expect(events).toContain(Event.distribute); - }); - - it('does not emit the distribute event when notification is suppressed', () => { - const { handler, map } = handlerWith(aiShapedNodes()); - - handler.distributeNodes(false); - - const events = map.events.call.mock.calls.map(call => call[0]); - expect(events).not.toContain(Event.distribute); - }); - - it('redraws every branch, dropping the path of a second root', () => { - const root = liveNode('root', null, { isRoot: true }); - const child = liveNode('child', root); - const loose = liveNode('loose', null); - const nodes = [root, child, loose]; - const { handler, map } = handlerWith(nodes); - attachBranchPaths(map.id, nodes); - handler.distributeNodes(); - expect(document.getElementById('child_branch')?.getAttribute('d')).toBe( - 'M0,0' - ); - // Nothing to draw for a parentless node, so the attribute is dropped. - expect( - document.getElementById('loose_branch')?.getAttribute('d') - ).toBeNull(); + expect(changes).toHaveLength(1); }); it('leaves an empty map alone', () => { - const { handler, map } = handlerWith([]); + const { handler, events, changes } = handlerWith([]); handler.distributeNodes(); - expect(map.history.save).not.toHaveBeenCalled(); + expect(changes).toEqual([]); + expect(events.emit).not.toHaveBeenCalled(); }); }); diff --git a/packages/mmp/src/map/handlers/drag.spec.ts b/packages/mmp/src/map/handlers/drag.spec.ts new file mode 100644 index 00000000..b91ef4de --- /dev/null +++ b/packages/mmp/src/map/handlers/drag.spec.ts @@ -0,0 +1,179 @@ +import * as d3 from 'd3'; +import type { D3DragEvent } from 'd3'; +import { create } from '../../index.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; +import { nodeRecord } from '../../test/stub-map.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; + +/** + * A drag moves a preview and writes the positions to the map data in one + * batch when it ends. A change of the map data during the drag draws from + * the data and keeps the preview. + */ + +type DragEvent = D3DragEvent; + +interface DragInternals { + started(event: DragEvent, id: string): void; + dragged(event: DragEvent, id: string): void; + ended(event: DragEvent, id: string): void; +} + +beforeAll(stubSvgLengths); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +/** root -> a -> b, root -> c, drawn by a real map. */ +function makeMap() { + const data = new InMemoryMapData([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'a', parent: 'root', coordinates: { x: 200, y: 0 } }), + nodeRecord({ id: 'b', parent: 'a', coordinates: { x: 400, y: 0 } }), + nodeRecord({ id: 'c', parent: 'root', coordinates: { x: -200, y: 0 } }), + ]); + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref, undefined, data); + const changes = jest.fn(); + data.subscribe(changes); + return { data, map, changes, drag: map.drag as unknown as DragInternals }; +} + +function transformOf(id: string): string | null { + const group = d3 + .selectAll('g.node') + .filter(datum => datum === id) + .node(); + return group?.getAttribute('transform') ?? null; +} + +function move(drag: DragInternals, id: string, dx: number, dy: number) { + drag.dragged({ dx, dy } as DragEvent, id); +} + +describe('drag', () => { + it('moves a preview and writes nothing until it ends', () => { + const { data, drag, changes } = makeMap(); + + drag.started({} as DragEvent, 'a'); + move(drag, 'a', 50, 10); + + expect(transformOf('a')).toBe('translate(250,10)'); + expect(transformOf('b')).toBe('translate(450,10)'); + expect(data.node('a')?.coordinates).toEqual({ x: 200, y: 0 }); + expect(changes).not.toHaveBeenCalled(); + }); + + it('writes the moved positions in one change when it ends', () => { + const { data, drag, changes } = makeMap(); + + drag.started({} as DragEvent, 'a'); + move(drag, 'a', 50, 10); + move(drag, 'a', 10, 0); + drag.ended({} as DragEvent, 'a'); + + expect(changes).toHaveBeenCalledTimes(1); + expect(data.node('a')?.coordinates).toEqual({ x: 260, y: 10 }); + expect(data.node('b')?.coordinates).toEqual({ x: 460, y: 10 }); + }); + + it('mirrors the descendants when the node crosses its tree root', () => { + const { data, drag } = makeMap(); + + drag.started({} as DragEvent, 'a'); + move(drag, 'a', -300, 0); + drag.ended({} as DragEvent, 'a'); + + expect(data.node('a')?.coordinates).toEqual({ x: -100, y: 0 }); + expect(data.node('b')?.coordinates).toEqual({ x: -300, y: 0 }); + }); + + it('scans the map data once at the start and never per move', () => { + const { data, drag } = makeMap(); + const scan = jest.spyOn(data, 'nodes'); + + drag.started({} as DragEvent, 'a'); + move(drag, 'a', 5, 5); + move(drag, 'a', 5, 5); + move(drag, 'a', 5, 5); + + expect(scan).toHaveBeenCalledTimes(1); + }); + + it('keeps the preview through a peer write and writes the preview at the end', () => { + const { data, drag } = makeMap(); + drag.started({} as DragEvent, 'a'); + move(drag, 'a', 50, 10); + + data.updateNode('a', 'coordinates', { x: 0, y: 300 }); + data.updateNode('b', 'name', 'peer'); + + expect(transformOf('a')).toBe('translate(250,10)'); + expect(transformOf('b')).toBe('translate(450,10)'); + + drag.ended({} as DragEvent, 'a'); + + expect(data.node('a')?.coordinates).toEqual({ x: 250, y: 10 }); + expect(data.node('b')?.coordinates).toEqual({ x: 450, y: 10 }); + }); + + it('puts the nodes back at their data positions when a peer protects the branch', () => { + const { data, drag, map } = makeMap(); + const refused = jest.fn(); + map.instance.on('nodeProtected', refused); + drag.started({} as DragEvent, 'a'); + move(drag, 'a', 50, 10); + + data.updateNode('root', 'protected', true); + drag.ended({} as DragEvent, 'a'); + + expect(data.node('a')?.coordinates).toEqual({ x: 200, y: 0 }); + expect(transformOf('a')).toBe('translate(200,0)'); + expect(transformOf('b')).toBe('translate(400,0)'); + expect(refused).toHaveBeenCalledTimes(1); + }); + + it('ends without a write when a peer replaces the map', () => { + const { data, drag, changes } = makeMap(); + drag.started({} as DragEvent, 'a'); + move(drag, 'a', 50, 10); + + data.replaceMap([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'a', parent: 'root', coordinates: { x: 600, y: 0 } }), + ]); + changes.mockClear(); + move(drag, 'a', 50, 10); + drag.ended({} as DragEvent, 'a'); + + expect(changes).not.toHaveBeenCalled(); + expect(data.node('a')?.coordinates).toEqual({ x: 600, y: 0 }); + expect(transformOf('a')).toBe('translate(600,0)'); + }); + + it('selects the dragged node', () => { + const { map, drag } = makeMap(); + + drag.started({} as DragEvent, 'c'); + + expect(map.instance.getSelectedNode()?.id).toBe('c'); + }); +}); + +describe('a drawn map', () => { + it('binds node ids to the node groups', () => { + makeMap(); + + expect(d3.selectAll('g.node').data()).toEqual([ + 'root', + 'a', + 'b', + 'c', + ]); + expect(d3.selectAll('path.branch').data()).toEqual( + ['a', 'b', 'c'] + ); + }); +}); diff --git a/packages/mmp/src/map/handlers/drag.ts b/packages/mmp/src/map/handlers/drag.ts index 470c7b57..dce3cd2f 100644 --- a/packages/mmp/src/map/handlers/drag.ts +++ b/packages/mmp/src/map/handlers/drag.ts @@ -1,22 +1,34 @@ import * as d3 from 'd3'; import { DragBehavior, D3DragEvent } from 'd3'; +import { collectSubtreeIds } from '@teammapper/shared'; import Map from '../map.js'; -import Node from '../models/node.js'; -import { Event } from './events.js'; +import { lookupIn } from './nodes.js'; + +type DragEvent = D3DragEvent; + +/** The drag in progress: the dragged node and the nodes it moves along. */ +interface DragSession { + id: string; + descendants: string[]; + /** The root of the dragged node's tree, read once at the start. */ + treeRoot: string; + orientation: boolean | undefined; + // 'refused' when the drag started on a protected node, 'announced' once + // the first move showed the notice. Both states keep the node in place. + refusal: 'none' | 'refused' | 'announced'; + moved: boolean; +} /** - * Manage the drag events of the nodes. + * Manage the drag events of the nodes. A drag moves a preview the renderer + * draws and writes nothing to the map data until it ends. A change of the + * map data during the drag draws from the data and keeps the preview. */ export default class Drag { private map: Map; - private dragBehavior: DragBehavior; - private dragging = false; - private orientation: boolean | undefined; - private descendants: Node[] = []; - // 'refused' when the drag started on a protected node, 'announced' once the - // first move showed the notice. Both states keep the node in place. - private refusal: 'none' | 'refused' | 'announced' = 'none'; + private dragBehavior: DragBehavior; + private session: DragSession | null = null; /** * Get the associated map instance and initialize the d3 drag behavior. @@ -26,124 +38,127 @@ export default class Drag { this.map = map; this.dragBehavior = d3 - .drag() - .on( - 'start', - (event: D3DragEvent, node: Node) => - this.started(event, node) - ) - .on( - 'drag', - (event: D3DragEvent, node: Node) => - this.dragged(event, node) - ) - .on('end', (event: D3DragEvent, node: Node) => - this.ended(event, node) - ); + .drag() + .on('start', (event: DragEvent, id: string) => this.started(event, id)) + .on('drag', (event: DragEvent, id: string) => this.dragged(event, id)) + .on('end', (event: DragEvent, id: string) => this.ended(event, id)); } /** * Return the d3 drag behavior * @returns {DragBehavior} dragBehavior */ - public getDragBehavior(): DragBehavior { + public getDragBehavior(): DragBehavior { return this.dragBehavior; } /** - * Select the node and calculate node position data for dragging. - * @param {Node} node + * End the running drag without a write and drop its preview. A replaced + * map calls this, so a peer's import is never overwritten. */ - private started(_: D3DragEvent, node: Node) { - this.refusal = this.map.nodes.isProtected(node) ? 'refused' : 'none'; - this.orientation = this.map.nodes.getOrientation(node); - this.descendants = this.map.nodes.getDescendants(node); + public cancel() { + this.session = null; + this.map.draw.takePreview(); + } - this.map.nodes.selectNode(node.id); + /** + * Select the node and read, once, the nodes the drag moves along with it. + * @param {string} id + */ + private started(_: DragEvent, id: string) { + const nodes = this.map.nodes; + const records = nodes.scan(); + const lookup = lookupIn(records); + + this.session = { + id, + descendants: collectSubtreeIds([...records.values()], id), + treeRoot: nodes.treeRoot(id, lookup), + orientation: nodes.orientation(id, lookup), + refusal: nodes.isProtected(id) ? 'refused' : 'none', + moved: false, + }; + + nodes.selectNode(id); } /** - * Move the dragged node and all its descendants. A protected node stays - * where it is, and the first move announces the refusal. - * @param {Node} node + * Move the preview of the dragged node and all its descendants. A + * protected node stays where it is, and the first move announces the + * refusal. + * @param {string} id */ - private dragged(event: D3DragEvent, node: Node) { - if (this.refusal === 'refused') this.map.nodes.refuseProtected(node); - if (this.refusal !== 'none') { - this.refusal = 'announced'; + private dragged(event: DragEvent, id: string) { + const session = this.session; + if (session?.id !== id) return; + + if (session.refusal === 'refused') this.map.nodes.refuseProtected(id); + if (session.refusal !== 'none') { + session.refusal = 'announced'; return; } - const dy = event.dy, - dx = event.dx; - - // Set new coordinates - const x = (node.coordinates.x += dx), - y = (node.coordinates.y += dy); - - // Move graphically the node in new coordinates - node.dom.setAttribute('transform', 'translate(' + [x, y] + ')'); + const nodes = this.map.nodes; + const draw = this.map.draw; + const moved = [id, ...session.descendants]; + const { dx, dy } = event; + for (const node of moved) { + const position = nodes.positionOf(node); + draw.setPreview(node, { x: position.x + dx, y: position.y + dy }); + } - this.moveDescendants(node, dx, dy); + this.mirrorDescendants(session); - // Update all mind map branches - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => { - return this.map.draw.drawBranch(node)?.toString() ?? null; - } - ); + draw.renderPositions(moved); // This is here and not in the started function because started function // is also executed when there is no drag events - this.dragging = true; + session.moved = true; } /** - * Move the descendants along with the dragged node, mirrored when the node - * crosses to the other side of its tree root. - * @param {Node} root the dragged node + * Mirror the descendants around the dragged node when it crosses to the + * other side of its tree root. Reads only the tree root's position. */ - private moveDescendants(root: Node, dx: number, dy: number) { - const newOrientation = this.map.nodes.getOrientation(root), - orientationIsChanged = newOrientation !== this.orientation; - - for (const node of this.descendants) { - let x = (node.coordinates.x += dx); - const y = (node.coordinates.y += dy); - - if (orientationIsChanged) { - x = node.coordinates.x += (root.coordinates.x - node.coordinates.x) * 2; - } - - node.dom.setAttribute('transform', 'translate(' + [x, y] + ')'); - } - - if (orientationIsChanged) { - this.orientation = newOrientation; + private mirrorDescendants(session: DragSession) { + const nodes = this.map.nodes; + const dragged = nodes.positionOf(session.id); + const orientation = + nodes.parentOf(session.id) === null + ? undefined + : dragged.x < nodes.positionOf(session.treeRoot).x; + if (orientation === session.orientation) return; + + session.orientation = orientation; + for (const node of session.descendants) { + const position = nodes.positionOf(node); + this.map.draw.setPreview(node, { + x: position.x + (dragged.x - position.x) * 2, + y: position.y, + }); } } /** - * If the node was actually dragged change the state of dragging and save the snapshot. - * @param {Node} node + * After a drag that moved the node, write the preview positions in one + * batch and draw the moved nodes from the map data. A write the data + * skips, or a branch a peer protected during the drag, notifies nothing, + * so the redraw puts those nodes back at their stored positions. + * @param {string} id */ - private ended(_event: D3DragEvent, node: Node) { - if (this.dragging) { - this.dragging = false; - this.map.history.save(); - - for (const node of this.descendants) { - this.map.events.call(Event.nodeUpdate, node.dom, { - nodeProperties: this.map.nodes.getNodeProperties(node), - changedProperty: 'coordinates', - }); - } - - this.map.events.call(Event.nodeUpdate, node.dom, { - nodeProperties: this.map.nodes.getNodeProperties(node), - changedProperty: 'coordinates', - }); - } + private ended(_event: DragEvent, id: string) { + const session = this.session; + if (session?.id !== id) return; + this.session = null; + + const positions = this.map.draw.takePreview(); + if (!session.moved) return; + + // The descendants move along whatever protection they carry themselves, + // so the writes skip the per-node check updateNode makes. + const nodes = this.map.nodes; + if (!nodes.refusesChange(id)) nodes.writePositions(positions); + + this.map.draw.renderPositions([...positions.keys()]); } } diff --git a/packages/mmp/src/map/handlers/draw.spec.ts b/packages/mmp/src/map/handlers/draw.spec.ts new file mode 100644 index 00000000..dfcaf2de --- /dev/null +++ b/packages/mmp/src/map/handlers/draw.spec.ts @@ -0,0 +1,201 @@ +import * as d3 from 'd3'; +import type { ExportNodeProperties } from '@teammapper/shared'; +import { create } from '../../index.js'; +import MmpMap from '../map.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; +import type { ResolvedNode } from '../data/node-record.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; + +/** + * The renderer measures each name once it is drawn, and again whenever the + * browser reports the name changed size: when its font loads or while the + * person types. jsdom lays nothing out, so the specs set the sizes it reports. + */ + +/** Stands in for the browser ResizeObserver, so a test decides when it fires. */ +class FakeResizeObserver { + static current: FakeResizeObserver | null = null; + readonly observed = new Set(); + + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.current = this; + } + + observe(target: Element) { + this.observed.add(target); + } + + unobserve(target: Element) { + this.observed.delete(target); + } + + disconnect() { + this.observed.clear(); + } + + fire(targets: Element[]) { + this.callback( + targets.map(target => ({ target }) as ResizeObserverEntry), + this as unknown as ResizeObserver + ); + } +} + +function observer(): FakeResizeObserver { + if (!FakeResizeObserver.current) throw new Error('No observer was created'); + return FakeResizeObserver.current; +} + +beforeAll(stubSvgLengths); + +function makeMap(): MmpMap { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref, undefined, new InMemoryMapData()); + map.instance.new(); + return map; +} + +function mainRoot(map: MmpMap): ResolvedNode { + const root = map.nodes.mainRoot(); + if (!root) throw new Error('the map has no main root'); + return root; +} + +function rootName(map: MmpMap): HTMLDivElement { + const name = map.dom.g.node()?.querySelector('foreignObject.name > div'); + if (!(name instanceof HTMLDivElement)) throw new Error('No name was drawn'); + return name; +} + +/** Make the browser report `width` by `height` for the element. */ +function layOut(element: HTMLElement, width: number, height: number) { + Object.defineProperty(element, 'offsetWidth', { value: width }); + Object.defineProperty(element, 'offsetHeight', { value: height }); +} + +const originalResizeObserver = globalThis.ResizeObserver; + +beforeEach(() => { + FakeResizeObserver.current = null; + globalThis.ResizeObserver = + FakeResizeObserver as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + document.body.innerHTML = ''; +}); + +describe('measuring names', () => { + it('observes the name of every drawn node', () => { + const map = makeMap(); + + expect(observer().observed).toEqual(new Set([rootName(map)])); + }); + + it('sizes the node from its name once the name changes size', () => { + const map = makeMap(); + const name = rootName(map); + const root = mainRoot(map); + + layOut(name, 140, 30); + observer().fire([name]); + + expect(map.draw.dimensionsOf(root)).toEqual({ width: 185, height: 60 }); + const foreignObject = name.parentElement; + expect(foreignObject?.getAttribute('width')).toBe('140'); + expect(foreignObject?.getAttribute('x')).toBe('-70'); + }); + + it('leaves the model alone when it measures', () => { + const map = makeMap(); + const before = map.instance.exportAsJSON(); + const name = rootName(map); + + layOut(name, 140, 30); + observer().fire([name]); + + expect(map.instance.exportAsJSON()).toEqual(before); + }); + + it('redraws the name when the map is drawn anew during an edit', () => { + const map = makeMap(); + const root = mainRoot(map); + map.draw.enableNodeNameEditing(root.id); + + map.draw.drawAll(); + + expect(rootName(map).innerHTML).toBe(root.name); + }); + + it('commits no typed name when a replaced map removes the edited node', () => { + const data = new InMemoryMapData(); + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref, undefined, data); + map.instance.new(); + const root = mainRoot(map); + map.draw.enableNodeNameEditing(root.id); + const name = rootName(map); + name.innerHTML = 'Typed'; + + data.replaceMap(map.instance.exportAsJSON()); + name.dispatchEvent(new FocusEvent('blur')); + + expect(data.node(root.id)?.name).toBe(root.name); + }); + + it('disconnects the observer when the map is removed', () => { + const map = makeMap(); + + map.instance.destroy(); + + expect(observer().observed.size).toBe(0); + }); + + it('draws the defaults in place of values the schemas reject', () => { + const data = new InMemoryMapData(); + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref, undefined, data); + map.instance.new(); + const root = mainRoot(map); + const peerNode = { + id: 'peer', + parent: root.id, + k: Infinity, + name: 42, + coordinates: { x: NaN, y: Infinity }, + image: { src: 7, size: -Infinity }, + colors: { name: 'url(https://x)', background: 'red', branch: {} }, + font: { size: '12px', style: 3, weight: null }, + link: { href: 'javascript:alert(1)' }, + protected: 'yes', + isRoot: false, + } as unknown as ExportNodeProperties; + + expect(() => data.addNodes([peerNode])).not.toThrow(); + + const group = d3 + .selectAll('g.node') + .filter(id => id === 'peer') + .node(); + if (!group) throw new Error('The peer node was not drawn'); + const html = group.outerHTML; + expect(html).not.toContain('url('); + expect(html).not.toContain('javascript:'); + expect(html).not.toContain('NaN'); + expect(html).not.toContain('Infinity'); + }); + + it('stops observing the name of a removed node', () => { + const map = makeMap(); + const child = map.instance.addNode({ name: 'child' }); + if (!child) throw new Error('addNode added no child'); + + map.instance.removeNode(child.id); + + expect(observer().observed).toEqual(new Set([rootName(map)])); + }); +}); diff --git a/packages/mmp/src/map/handlers/draw.ts b/packages/mmp/src/map/handlers/draw.ts index e4e3095a..062022c3 100644 --- a/packages/mmp/src/map/handlers/draw.ts +++ b/packages/mmp/src/map/handlers/draw.ts @@ -1,36 +1,119 @@ import * as d3 from 'd3'; -import { Path } from 'd3'; import DOMPurify from 'dompurify'; import { isImageDataUrl, isImageReference, - isSafeLinkHref, + type MapNodeCoordinates, + type MapNodeDimensions, + type MapNodeFont, } from '@teammapper/shared'; -import Map, { DomElements } from '../map.js'; +import MmpMap, { DomElements } from '../map.js'; import Utils from '../../utils/utils.js'; -import Node from '../models/node.js'; +import type { MapNodeRecord } from '../data/map-data.js'; +import { resolveNode, type ResolvedNode } from '../data/node-record.js'; +import type { RecordLookup } from './nodes.js'; import { MIN_TEXT_EXTENT, - NODE_HEIGHT_PADDING, - NODE_WIDTH_PADDING, - estimateTextExtent, + measureNodeExtent, + measureTextExtent, + withPadding, } from './node-geometry.js'; +import { + NODE_MARKS, + nameElements, + type LoadedImage, + type MarkContext, + type NodeGroups, +} from './node-marks.js'; + +type BranchPaths = d3.Selection; + +interface Layers { + branches: d3.Selection; + nodes: d3.Selection; +} + +/** An image value the renderer loads, and how far the load got. */ +interface ImageLoad { + src: string; + url: string; + ratio: number | null; +} + +/** The branch drawn to a node, and the parent it leaves from. */ +interface DrawnBranch { + path: SVGPathElement; + parent: string; +} /** - * Draw the map and update it. + * Draws the mind map and redraws the nodes a change adds or updates. d3 binds + * node ids to the DOM. Each draw pass reads the record of each node it draws + * once, through `recordOf`, and drops the records when it ends. + * + * The renderer keeps render data only: the DOM element of each drawn node + * and branch, the parent each drawn branch leaves from, measured name sizes, + * rings, image loads and the drag preview. A node gets as big as its name, + * so the renderer measures each name after drawing it. */ export default class Draw { - private map: Map; - private editing = false; + private map: MmpMap; private mapRef: HTMLElement; + private layerSelections: Layers | null = null; + private editingId: string | null = null; + private tappedTwice = false; + + /** The measured size of each name, by node id. */ + private readonly textExtents = new Map(); + /** The selection ring color, by node id. */ + private readonly rings = new Map(); + private readonly images = new Map(); + /** The DOM element of each drawn node, by node id. */ + private readonly groups = new Map(); + /** The drawn branch of each node that has a parent, by node id. */ + private readonly branches = new Map(); + /** + * The ids of the nodes whose drawn branch leaves from a node, by the + * node's id. A change of a node redraws these branches, and the hidden + * child nodes mark reads whether a node has any. + */ + private readonly branchesFrom = new Map>(); + /** + * The ids of the drawn nodes whose parent the map data lacks, by the + * parent's id. Such a node draws as a root until a later change adds the + * parent, and a draw of the parent then draws these nodes too. + */ + private readonly orphans = new Map>(); + /** The parent id each node in `orphans` waits for, by node id. */ + private readonly missingParents = new Map(); + /** Where a drag shows each node it moves, by node id. */ + private readonly preview = new Map(); + private readonly resizeObserver: ResizeObserver | null; + private readonly observed = new WeakSet(); /** * Get the associated map instance. * @param {Map} map */ - constructor(map: Map, ref: HTMLElement) { + constructor(map: MmpMap, ref: HTMLElement) { this.map = map; this.mapRef = ref; + // A name changes size when its font loads or while the person types. + this.resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(entries => + this.resize( + entries.map(entry => + d3.select(entry.target).datum() + ) + ) + ); + } + + private get layers(): Layers { + if (!this.layerSelections) throw new Error('The map is not created yet'); + return this.layerSelections; } /** @@ -60,417 +143,221 @@ export default class Draw { this.map.nodes.deselectNode(); }); - return { container, svg, g: svg.append('g') }; + const g = svg.append('g'); + this.layerSelections = { + branches: g.append('g').attr('class', 'branches'), + nodes: g.append('g').attr('class', 'nodes'), + }; + + return { container, svg, g }; } /** - * Update the dom of the map with the (new) nodes. + * Remove every drawn node and draw the map again from one scan of the map + * data. `drawAll` clears the rings and keeps the measured sizes and image + * loads of the nodes that stay. */ - public update() { - const nodes = this.map.nodes.getNodes(); - - // Set visibility: hidden instead of filtering out nodes to still allow updates such as text, images and pictograms to take effect "behind the curtain" - const dom = { - nodes: this.map.dom.g - .selectAll('.' + this.map.id + '_node') - .data(nodes, (d: unknown) => (d as Node).id) - .style('visibility', d => (d.hidden ? 'hidden' : 'visible')), - branches: this.map.dom.g - .selectAll('.' + this.map.id + '_branch') - .data(nodes.slice(1), (d: unknown) => (d as Node).id) - .style('visibility', d => (d.hidden ? 'hidden' : 'visible')), - }; - let tapedTwice = false; - - // When doing an initial draw, all nodes appear in dom.nodes - dom.nodes.each((node: Node) => { - this.updateHiddenChildrenIcon(node); - this.updateProtectionIcon(node); - }); - - const outer = dom.nodes - .enter() - .append('g') - .style('cursor', 'pointer') - .style('touch-action', 'none') - /** - * dom.nodes includes all nodes rendered on screen, but dom.nodes.enter() includes "new" nodes given by the client, - * so we need an additional visibility check done here - */ - .style('visibility', (node: Node) => (node.hidden ? 'hidden' : 'visible')) - .attr('class', this.map.id + '_node') - .attr('id', function (node: Node) { - node.dom = this; - return node.id; - }) - .attr( - 'transform', - (node: Node) => - 'translate(' + node.coordinates.x + ',' + node.coordinates.y + ')' - ) - .on('dblclick', (event: MouseEvent, node: Node) => { - if (!this.map.options.edit) return; - - event.stopPropagation(); - this.enableNodeNameEditing(node); - }) - .on( - 'touchstart', - (event: TouchEvent, node: Node) => { - if (!this.map.options.edit) return false; - // When not clicking a link and not in edit mode, disable all mobile native touch events - // A single tap is supposed to move the node in this application - if (!this.isLinkTarget(event) && !this.editing) { - event.preventDefault(); - } - - // a single tap should enter moving node mode - not a selection - if (!tapedTwice) { - tapedTwice = true; - - setTimeout(function () { - tapedTwice = false; - }, 300); - - return false; - } - - this.enableNodeNameEditing(node); - }, - { passive: false } - ); - if (this.map.options.drag === true) { - outer.call(this.map.drag.getDragBehavior()); - } else { - outer.on('mousedown', (node: Node) => { - this.map.nodes.selectNode(node.id); - }); + public drawAll() { + const records = this.map.data.nodes(); + const ids = records.map(record => record.id); + + [...this.groups.keys()].forEach(id => this.dropGroup(id)); + [...this.branches.keys()].forEach(id => this.dropBranch(id)); + this.rings.clear(); + this.orphans.clear(); + this.missingParents.clear(); + + const present = new Set(ids); + for (const state of [this.textExtents, this.images]) { + for (const id of state.keys()) if (!present.has(id)) state.delete(id); } - // Set text of the node - outer - .insert('foreignObject') - .html((node: Node) => this.createNodeNameDOM(node)) - .each((node: Node) => { - this.updateNodeNameContainer(node); - }); - - // Set background of the node - outer - .insert('path', 'foreignObject') - .style('fill', (node: Node) => DOMPurify.sanitize(node.colors.background)) - .style('stroke-width', 3) - .attr('d', (node: Node) => this.drawNodeBackground(node).toString()); - - // Set image and link of the node - outer.each((node: Node) => { - this.setImage(node); - this.setLink(node); - // Sometimes, undo/redo will not render nodes in dom.nodes, but instead all nodes will only be present in dom.nodes.enter(), so we also need to check for hidden children there - this.updateHiddenChildrenIcon(node); - this.updateProtectionIcon(node); - }); - - dom.branches - .enter() - .insert('path', 'g') - .style('fill', (node: Node) => DOMPurify.sanitize(node.colors.branch)) - .style('stroke', (node: Node) => DOMPurify.sanitize(node.colors.branch)) - /** - * dom.branches includes all branches rendered on screen, but dom.branches.enter() includes "new" branches given by the client, - * so we need an additional visibility check done here - */ - .style('visibility', (node: Node) => (node.hidden ? 'hidden' : 'visible')) - .attr('class', this.map.id + '_branch') - .attr('id', (node: Node) => node.id + '_branch') - .attr('d', (node: Node) => this.drawBranch(node)?.toString() ?? null); - - dom.nodes.exit().remove(); - dom.branches.exit().remove(); + this.drawNodes(ids, records); } /** - * Remove all nodes and branches of the map. + * Draw the nodes again from one scan of the map data, keeping their DOM. */ - public clear() { - d3.selectAll( - '.' + this.map.id + '_node, .' + this.map.id + '_branch' - ).remove(); + public redrawAll() { + const records = this.map.data.nodes(); + this.drawNodes( + records.map(record => record.id), + records + ); } /** - * Draw the background shape of the node. - * @param {Node} node - * @returns {Path} path + * Draw the nodes with the ids, their branches and the branches of their + * children. A node without a DOM gets one. Ids the map data lacks are + * skipped. `records` may hold the records a caller already read. + * @param {Iterable} ids + * @param {MapNodeRecord[]} records */ - public drawNodeBackground(node: Node): Path { - const name = node.getNameDOM(), - path = d3.path(); - - node.dimensions.width = name.clientWidth + NODE_WIDTH_PADDING; - node.dimensions.height = name.clientHeight + NODE_HEIGHT_PADDING; - - const x = node.dimensions.width / 2, - y = node.dimensions.height / 2, - k = node.k; - - path.moveTo(-x, k / 3); - path.bezierCurveTo(-x, -y + 10, -x + 10, -y, k, -y); - path.bezierCurveTo(x - 10, -y, x, -y + 10, x, k / 3); - path.bezierCurveTo(x, y - 10, x - 10, y, k, y); - path.bezierCurveTo(-x + 10, y, -x, y - 10, -x, k / 3); - path.closePath(); + public drawNodes(ids: Iterable, records?: MapNodeRecord[]) { + const lookup = this.drawPassLookup(records); + const requested = new Set(ids); + for (const id of [...requested]) { + this.orphans.get(id)?.forEach(orphan => requested.add(orphan)); + } + const present = [...requested].filter(id => lookup(id) !== undefined); + if (present.length === 0) return; - return path; + this.place(present, lookup); + this.render(present, this.branchIdsOf(present), lookup); } /** - * Draw the branch of the node. - * @param {Node} node - * @returns {Path | null} path, or null for a node without a parent + * Remove the DOM and the render data of the nodes. Returns the ids to draw + * again: the parents the removed branches left from, whose hidden child + * nodes mark depends on their children, and nodes left without a parent. + * @param {string[]} ids */ - public drawBranch(node: Node): Path | null { - if (node.parent === null) return null; - - const parent = node.parent, - path = d3.path(), - level = node.getLevel(), - width = 22 - (level < 6 ? level : 6) * 3, - mx = (parent.coordinates.x + node.coordinates.x) / 2, - ory = - parent.coordinates.y < node.coordinates.y + node.dimensions.height / 2 - ? -1 - : 1, - orx = parent.coordinates.x > node.coordinates.x ? -1 : 1, - inv = orx * ory; - - path.moveTo(parent.coordinates.x, parent.coordinates.y - width * 0.8); - path.bezierCurveTo( - mx - width * inv, - parent.coordinates.y - width / 2, - parent.coordinates.x - (width / 2) * inv, - node.coordinates.y + node.dimensions.height / 2 - width / 3, - node.coordinates.x - (node.dimensions.width / 3) * orx, - node.coordinates.y + node.dimensions.height / 2 + 3 - ); - path.bezierCurveTo( - parent.coordinates.x + (width / 2) * inv, - node.coordinates.y + node.dimensions.height / 2 + width / 3, - mx + width * inv, - parent.coordinates.y + width / 2, - parent.coordinates.x, - parent.coordinates.y + width * 0.8 - ); - path.closePath(); + public removeNodes(ids: string[]): string[] { + const redraw: string[] = []; + + for (const id of ids) { + const parent = this.branches.get(id)?.parent; + if (parent !== undefined) redraw.push(parent); + redraw.push(...(this.branchesFrom.get(id) ?? [])); + + this.dropBranch(id); + this.dropGroup(id); + this.waitForParent(id, null); + this.textExtents.delete(id); + this.rings.delete(id); + this.images.delete(id); + this.preview.delete(id); + } - return path; + return redraw; } /** - * Update the node HTML elements. - * @param {Node} node + * Move the drawn nodes to their positions, along with their branches and + * those of their children. + * @param {string[]} ids */ - public updateNodeShapes(node: Node) { - const background = node.getBackgroundDOM(); + public renderPositions(ids: string[]) { + const lookup = this.drawPassLookup(); + const present = ids.filter(id => this.groups.has(id)); - d3.select(background).attr('d', (node: Node) => - this.drawNodeBackground(node).toString() + this.groupsOf(present).attr('transform', id => + translate(this.map.nodes.positionOf(id, lookup)) ); - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => this.drawBranch(node)?.toString() ?? null + this.branchesOf(this.branchIdsOf(present)).attr('d', id => + this.branchShape(id, lookup) ); - - this.updateImagePosition(node); - this.updateLinkPosition(node); - this.updateProtectionIcon(node); - - this.updateNodeNameContainer(node); } /** - * Set main properties of node image and create it if it does not exist. - * @param {Node} node + * The position the drag preview shows the node at, or undefined. + * @param {string} id */ - public setImage(node: Node) { - let domImage = node.getImageDOM(); - - if (!domImage) { - domImage = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'image' - ); - node.dom.appendChild(domImage); - } - - const src = node.image.src; - const url = this.imageUrlOf(src); - - if (url !== null) { - const image = new Image(); - - image.src = url; - - image.onload = () => { - // A newer image replaced this one while it loaded. - if (node.image.src !== src) return; - - const h = node.image.size, - w = (image.width * h) / image.height, - y = -(h + node.dimensions.height / 2 + 5), - x = -w / 2; + public previewOf(id: string): MapNodeCoordinates | undefined { + return this.preview.get(id); + } - domImage.setAttribute('href', url); - domImage.setAttribute('height', h.toString()); - domImage.setAttribute('width', w.toString()); - domImage.setAttribute('y', y.toString()); - domImage.setAttribute('x', x.toString()); - domImage.setAttribute('clip-path', 'inset(0% round 15px)'); - }; + /** + * Show the node at `position` until the preview is taken. + * @param {string} id + * @param {MapNodeCoordinates} position + */ + public setPreview(id: string, position: MapNodeCoordinates) { + this.preview.set(id, { x: position.x, y: position.y }); + } - // Hide the image and keep its value: clearing it would erase the - // image in the Y.Doc for every client on a network error. - image.onerror = () => { - if (node.image.src !== src) return; - domImage.remove(); - }; - } else { - domImage.remove(); - } + /** Return the preview positions and clear the preview. */ + public takePreview(): Map { + const positions = new Map(this.preview); + this.preview.clear(); + return positions; } /** - * Returns the URL an image value loads from: the resolved URL of a - * reference, a base64 raster data URL as is, or null for any other value. + * Stop watching names for size changes. The map calls this on removal. */ - private imageUrlOf(src: string): string | null { - if (isImageReference(src)) { - return this.map.options.resolveImageUrl?.(src) ?? null; - } - return isImageDataUrl(src) ? src : null; + public destroy() { + this.resizeObserver?.disconnect(); } /** - * Set main properties of node image and create it if it does not exist. - * @param {Node} node + * The size of the node: its name plus padding. Before the name is drawn, + * a canvas element estimates its size. + * @param {ResolvedNode} node */ - public setLink(node: Node) { - let domLink = node.getLinkDOM(); - let domText: SVGTextElement | null; - - if (!domLink) { - // create new dom elements if they do not exist - domLink = document.createElementNS('http://www.w3.org/2000/svg', 'a'); - domText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - node.dom.appendChild(domLink); - domLink.appendChild(domText); - } else { - domText = domLink.querySelector('text'); - } - - if (domText) { - // Set the correct styling of the link - this.updateLinkStyle(domText, node); - } + public dimensionsOf = (node: ResolvedNode): MapNodeDimensions => + withPadding(this.textExtentOf(node)); - // A peer's link reaches this client before the server sanitizes it, so - // the renderer checks the scheme: DOMPurify keeps a `javascript:` URL. - if (isSafeLinkHref(node.link.href)) { - domLink.setAttribute('href', node.link.href); - domLink.setAttribute('target', '_self'); - } else { - domLink.remove(); - } - } + /** + * The node size a name will get, estimated before the node is drawn. The + * layout uses it to place the nodes of an imported map. + */ + public estimateExtent = ( + name: string, + font: MapNodeFont + ): MapNodeDimensions => + measureNodeExtent(name, { ...font, family: this.map.options.fontFamily }); /** - * Set a hidden eye icon if child nodes are hidden. - * @param {Node} node + * The color of the ring around the node, null for none. + * @param {string} id */ - public setHiddenChildrenIcon(node: Node) { - let domIcon = node.getHiddenChildIconDOM(); - if (!domIcon) { - domIcon = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - domIcon.textContent = 'visibility_off'; - domIcon.classList.add('hidden-icon'); - domIcon.classList.add('material-icons'); - domIcon.style.setProperty('fill', DOMPurify.sanitize(node.colors.name)); - domIcon.setAttribute('y', (-node.dimensions.height + 30).toString()); - domIcon.setAttribute('x', '-60'); - node.dom.appendChild(domIcon); - } + public ringOf(id: string): string | null { + return this.rings.get(id) ?? null; } /** - * Explicitly remove the hidden eye icon even if not set - * @param {Node} node + * Draw a ring in `color` around the node, or none for null or ''. + * @param {string} id + * @param {string | null} color */ - public removeHiddenChildrenIcon(node: Node) { - const domIcon = node.getHiddenChildIconDOM(); - if (domIcon) { - domIcon.remove(); - } + public setRing(id: string, color: string | null) { + if (color) this.rings.set(id, color); + else this.rings.delete(id); + + this.groupsOf([id]) + .selectChildren('path.background') + .style('stroke', () => color || null); } /** - * Draw a lock badge past the top right corner of a node carrying the - * protection, mirroring the hidden eye icon, and remove it otherwise. - * Descendants protected through an ancestor show no badge. Each call - * repositions and recolors the badge, so it follows a resize or a new name - * color. - * @param {Node} node + * The ring color of a node: its background color, darkened. Null when the + * background holds no color. + * @param {ResolvedNode} node */ - public updateProtectionIcon(node: Node) { - let icon = node.getProtectionIconDOM(); - if (!node.protected) { - icon?.remove(); - return; - } - - if (!icon) { - icon = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - icon.textContent = 'lock'; - icon.classList.add('protected-icon', 'material-icons'); - node.dom.appendChild(icon); - } - icon.style.setProperty('fill', DOMPurify.sanitize(node.colors.name)); - icon.setAttribute('y', (-node.dimensions.height + 30).toString()); - icon.setAttribute('x', (node.dimensions.width / 2).toString()); + public ringColor(node: ResolvedNode): string | null { + return d3.color(node.colors.background)?.darker(0.5).toString() ?? null; } /** - * Update the node image position. - * @param {Node} node + * True while the person edits the name of the node. + * @param {string} id */ - public updateImagePosition(node: Node) { - // An image that failed to load keeps its value but has no element. - const image = node.dom.querySelector('image'); - if (!image) return; - const y = -(image.getBBox().height + node.dimensions.height / 2 + 5); - image.setAttribute('y', y.toString()); + public isEditing(id: string): boolean { + const name = this.nameOf(id); + return name !== null && name.ownerDocument.activeElement === name; } /** - * Update the node link position. - * @param {Node} node + * Take the focus from the name of the node, which ends its editing. + * @param {string} id */ - public updateLinkPosition(node: Node) { - if (isSafeLinkHref(node.link.href)) { - const link = node.getLinkDOM(), - y = node.dimensions.height; - link.setAttribute('y', y.toString()); - } + public blurName(id: string) { + this.nameOf(id)?.blur(); } /** * Enable and manage all events for the name editing. - * @param {Node} node + * @param {string} id */ - public enableNodeNameEditing(node: Node) { - if (this.map.nodes.refusesLocalChange(node)) return; + public enableNodeNameEditing(id: string) { + const node = this.map.nodes.record(id); + if (!node || this.map.nodes.refusesChange(id)) return; - this.editing = true; - const name = node.getNameDOM(); + const name = this.nameOf(id); + if (!name) return; + + this.editingId = id; name.setAttribute('contenteditable', 'true'); name.innerHTML = DOMPurify.sanitize(node.name); @@ -478,16 +365,10 @@ export default class Draw { name.style.setProperty('cursor', 'auto'); - this.updateNodeShapes(node); - name.ondblclick = name.onmousedown = event => { event.stopPropagation(); }; - name.oninput = () => { - this.updateNodeShapes(node); - }; - // Allow only some shortcuts. name.onkeydown = event => { // Unfocus the node. @@ -534,156 +415,470 @@ export default class Draw { }; name.onblur = () => { - this.editing = false; - - if (name.innerHTML !== node.name) { - this.map.nodes.updateNode('name', DOMPurify.sanitize(name.innerHTML)); - } - // Write node.name back, so the DOM drops the typed text when a peer - // protected the branch during the edit and updateNode refused it. - name.innerHTML = DOMPurify.sanitize(node.name); - this.updateNodeShapes(node); + this.editingId = null; name.ondblclick = name.onmousedown = name.onblur = name.onkeydown = - name.oninput = name.onpaste = null; name.setAttribute('contenteditable', 'false'); name.style.setProperty('cursor', 'pointer'); - name.blur(); + // The blur commits to the node it edited, whatever node the selection + // moves to, and reads the name the map data holds now. + const current = this.map.nodes.record(id); + if (current && name.innerHTML !== current.name) { + this.map.nodes.updateNode( + 'name', + DOMPurify.sanitize(name.innerHTML), + id + ); + } + // Draw the stored name back, so the DOM drops the typed text when a + // peer protected the branch during the edit and updateNode refused it. + this.drawNodes([id]); }; } /** - * Check if given node has hidden children and render the eye icon if so - * @param {Node} node + * One draw pass: a lookup that reads each record from the map data at + * most once, or from `records` when the caller already read them. */ - private updateHiddenChildrenIcon(node: Node) { - if (node.hasHiddenChildNodes) { - this.setHiddenChildrenIcon(node); - } else { - this.removeHiddenChildrenIcon(node); + private drawPassLookup(records?: MapNodeRecord[]): RecordLookup { + if (records) { + const resolved = new Map( + records.map(record => [record.id, resolveNode(record)]) + ); + return id => resolved.get(id); } + + const read = new Map(); + return id => { + let known = read.get(id); + if (known === undefined) { + const record = this.map.data.node(id); + known = record ? resolveNode(record) : null; + read.set(id, known); + } + return known ?? undefined; + }; } /** - * Update node name container (foreign object) dimensions. - * @param {Node} node + * Give each node a DOM element when it has none, and its branch the + * parent the record names now. */ - private updateNodeNameContainer(node: Node) { - const name = node.getNameDOM(), - foreignObject: SVGForeignObjectElement = - name?.parentNode as SVGForeignObjectElement; - - const [width, height]: number[] = (() => { - if (!this.browserIsFirefox()) { - // Default case - // Text is rendered based on needed width and height - // works well at least for chrome and safari - name.style.setProperty('width', 'auto'); - name.style.setProperty('height', 'auto'); - return [name.clientWidth, name.clientHeight]; - } else { - // More recent versions of firefox seem to render too late to actually fetch the width and height of the dom element. - // In these cases, try to approximate height and width before rendering. - name.style.setProperty('width', '100%'); - name.style.setProperty('height', '100%'); - const { width, height } = estimateTextExtent( - name.textContent, - node.font.size - ); - return [width, height]; - } - })().map((value: number) => Math.max(value, MIN_TEXT_EXTENT)); + private place(ids: string[], lookup: RecordLookup) { + const entering = ids.filter(id => !this.groups.has(id)); + this.enterNodes( + this.layers.nodes + .selectAll(() => []) + .data(entering) + .enter() + ).each((id, i, groups) => this.groups.set(id, groups[i])); + + const branching: string[] = []; + for (const id of ids) { + const parent = this.map.nodes.parentOf(id, lookup); + const named = lookup(id)?.parent || null; + this.waitForParent(id, parent === null ? named : null); + const drawn = this.branches.get(id); + if (drawn?.parent === parent) continue; + + if (drawn) this.dropBranch(id); + if (parent !== null) branching.push(id); + } - foreignObject.setAttribute('x', (-width / 2).toString()); - foreignObject.setAttribute('y', (-height / 2).toString()); - foreignObject.setAttribute('width', width.toString()); - foreignObject.setAttribute('height', height.toString()); + this.layers.branches + .selectAll(() => []) + .data(branching) + .enter() + .append('path') + .attr('class', 'branch') + .each((id, i, paths) => { + const parent = this.map.nodes.parentOf(id, lookup); + if (parent === null) return; + + this.branches.set(id, { path: paths[i], parent }); + const from = this.branchesFrom.get(parent) ?? new Set(); + from.add(id); + this.branchesFrom.set(parent, from); + }); } /** - * Create a string with HTML of the node name div. - * @param {Node} node - * @returns {string} html + * Draw the given nodes and branches. The renderer measures all names at + * once between drawing and sizing, so the browser lays out the page once + * instead of once per node. */ - private createNodeNameDOM(node: Node) { - const div = document.createElement('div'); + private render(ids: string[], branchIds: string[], lookup: RecordLookup) { + const context = this.markContext(lookup); + const groups = this.groupsOf(ids); + const branches = this.branchesOf(branchIds); + const visibilityOf = (id: string) => + this.map.nodes.isHidden(id, lookup) ? 'hidden' : 'visible'; + + groups + .attr('transform', id => translate(this.map.nodes.positionOf(id, lookup))) + .style('visibility', visibilityOf); + NODE_MARKS.forEach(mark => mark.draw(groups, context)); + this.observe(groups); + branches + .style('fill', id => context.recordOf(id).colors.branch) + .style('stroke', id => context.recordOf(id).colors.branch) + .style('visibility', visibilityOf); + + this.measure(groups); + + this.finish(groups, branches, context, lookup); + } - div.style.setProperty( - 'font-size', - DOMPurify.sanitize(node.font.size.toString()) + 'px' + /** + * Resize the nodes whose names changed size. + * @param {string[]} ids + */ + private resize(ids: string[]) { + const lookup = this.drawPassLookup(); + const changed = this.measure( + this.groupsOf(ids.filter(id => lookup(id) !== undefined)) ); - div.style.setProperty('color', DOMPurify.sanitize(node.colors.name)); - div.style.setProperty('font-style', DOMPurify.sanitize(node.font.style)); - div.style.setProperty('font-weight', DOMPurify.sanitize(node.font.weight)); + if (changed.length === 0) return; - div.style.setProperty('touch-action', 'none'); - div.style.setProperty('display', 'inline-block'); - div.style.setProperty('white-space', 'pre'); - div.style.setProperty('width', 'auto'); - div.style.setProperty('height', 'auto'); - div.style.setProperty('font-family', this.map.options.fontFamily); - div.style.setProperty('text-align', 'center'); - // fix against cursor jumping out of nodes on firefox if empty - div.style.setProperty('min-width', '20px'); + this.finish( + this.groupsOf(changed), + this.branchesOf(this.branchIdsOf(changed)), + this.markContext(lookup), + lookup + ); + } - div.innerHTML = DOMPurify.sanitize(node.name); + /** + * Read and store the size of each drawn name. + * @returns {string[]} the ids of the nodes whose size changed + */ + private measure(groups: NodeGroups): string[] { + const changed: string[] = []; + + nameElements(groups).each((id, i, names) => { + const width = names[i].offsetWidth, + height = names[i].offsetHeight; + // The browser reports 0 for a name it has not laid out yet, such as + // one in a hidden container. Keep the canvas estimate then. + if (width === 0 && height === 0) return; + + const extent = { + width: Math.max(width, MIN_TEXT_EXTENT), + height: Math.max(height, MIN_TEXT_EXTENT), + }; + const known = this.textExtents.get(id); + if (known?.width === extent.width && known.height === extent.height) { + return; + } + this.textExtents.set(id, extent); + changed.push(id); + }); - return div.outerHTML; + return changed; + } + + /** Draw everything that depends on the size of the nodes. */ + private finish( + groups: NodeGroups, + branches: BranchPaths, + context: MarkContext, + lookup: RecordLookup + ) { + NODE_MARKS.forEach(mark => mark.finish(groups, context)); + branches.attr('d', id => this.branchShape(id, lookup)); } /** - * Checks if the target of the event is the link below the node - * @param {TouchEvent} event - * @returns {boolean} + * True when the view state hides the child nodes of the node and at least + * one branch leaves from it. Reads the drawn branches instead of scanning + * the map data, so a template may call it on every change detection. + * @param {string} id */ - private isLinkTarget(event: TouchEvent): boolean { - return (event.target as Element).classList[0] === 'link-text'; + public hidesDrawnChildren(id: string): boolean { + return ( + this.map.viewState.hidesChildren(id) && + (this.branchesFrom.get(id)?.size ?? 0) > 0 + ); + } + + private markContext(lookup: RecordLookup): MarkContext { + const recordOf = (id: string) => lookup(id) ?? resolveNode({ id }); + + return { + recordOf, + textExtentOf: id => this.textExtentOf(recordOf(id)), + dimensionsOf: id => this.dimensionsOf(recordOf(id)), + ringOf: id => this.ringOf(id), + imageOf: id => this.imageOf(recordOf(id)), + isEditing: id => this.editingId === id, + hidesChildren: id => this.hidesDrawnChildren(id), + fontFamily: this.map.options.fontFamily, + showLinktext: this.map.options.showLinktext, + }; + } + + private textExtentOf(node: ResolvedNode): MapNodeDimensions { + return ( + this.textExtents.get(node.id) ?? + measureTextExtent(node.name, { + ...node.font, + family: this.map.options.fontFamily, + }) + ); } /** - * Checks if the browser is firefox - * @returns {boolean} + * The node's image once it has loaded, else null. A new image starts + * loading here, and the node is redrawn when the load ends. + * @param {ResolvedNode} node */ - private browserIsFirefox(): boolean { - return navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + private imageOf(node: ResolvedNode): LoadedImage | null { + const id = node.id; + const src = node.image.src; + const known = this.images.get(id); + if (known?.src === src) { + return known.ratio === null + ? null + : { url: known.url, ratio: known.ratio }; + } + + this.images.delete(id); + const url = this.imageUrlOf(src); + if (url === null) return null; + + const load: ImageLoad = { src, url, ratio: null }; + this.images.set(id, load); + + const image = new Image(); + image.src = url; + const settle = (ratio: number | null) => { + // A newer image replaced this one while it loaded. + if (this.images.get(id) !== load) return; + // A failed image stays hidden and keeps its value: clearing it would + // erase the image in the map data for every client on a network error. + load.ratio = ratio; + const lookup = this.drawPassLookup(); + if (lookup(id)) this.render([id], [], lookup); + }; + image.onload = () => settle(image.width / image.height); + image.onerror = () => settle(null); + + return null; } /** - * Truncates Text to a maximum Length - * @param text - * @param maxLength + * Returns the URL an image value loads from: the resolved URL of a + * reference, a base64 raster data URL as is, or null for any other value. */ - private truncateText(text: string, maxLength = 50): string { - if (text.length <= maxLength) return text; - return text.slice(0, maxLength - 3) + '...'; + private imageUrlOf(src: string): string | null { + if (isImageReference(src)) { + return this.map.options.resolveImageUrl?.(src) ?? null; + } + return isImageDataUrl(src) ? src : null; } /** - * Set linktext or link icon based on options - * @param domText The dom element for the linktext - * @param node The node that should be modified + * The shape of the branch from the node's parent to the node, null for a + * node without a parent. + * @param {string} id + * @param {RecordLookup} lookup */ - private updateLinkStyle(domText: SVGTextElement, node: Node) { - domText.classList.add('link-text'); - const showLinktext = this.map.options.showLinktext; - if (showLinktext) { - domText.textContent = this.truncateText(node.link.href); - domText.classList.remove('material-icons'); - domText.style.setProperty('text-decoration', 'underline'); - domText.style.setProperty('font-style', 'italic'); + private branchShape(id: string, lookup: RecordLookup): string | null { + const nodes = this.map.nodes; + const parentId = nodes.parentOf(id, lookup); + const record = lookup(id); + if (parentId === null || !record) return null; + + const parent = nodes.positionOf(parentId, lookup), + node = nodes.positionOf(id, lookup), + { width: nodeWidth, height: nodeHeight } = this.dimensionsOf(record), + path = d3.path(), + level = nodes.level(id, lookup), + width = 22 - (level < 6 ? level : 6) * 3, + mx = (parent.x + node.x) / 2, + ory = parent.y < node.y + nodeHeight / 2 ? -1 : 1, + orx = parent.x > node.x ? -1 : 1, + inv = orx * ory; + + path.moveTo(parent.x, parent.y - width * 0.8); + path.bezierCurveTo( + mx - width * inv, + parent.y - width / 2, + parent.x - (width / 2) * inv, + node.y + nodeHeight / 2 - width / 3, + node.x - (nodeWidth / 3) * orx, + node.y + nodeHeight / 2 + 3 + ); + path.bezierCurveTo( + parent.x + (width / 2) * inv, + node.y + nodeHeight / 2 + width / 3, + mx + width * inv, + parent.y + width / 2, + parent.x, + parent.y + width * 0.8 + ); + path.closePath(); + + return path.toString(); + } + + /** The drawn nodes with the ids. */ + private groupsOf(ids: string[]): NodeGroups { + const elements = ids.flatMap(id => this.groups.get(id) ?? []); + return d3.selectAll(elements); + } + + /** The drawn branches to the nodes with the ids. */ + private branchesOf(ids: string[]): BranchPaths { + const elements = ids.flatMap(id => this.branches.get(id)?.path ?? []); + return d3.selectAll(elements); + } + + /** The ids of the nodes, followed by those of their drawn children. */ + private branchIdsOf(ids: string[]): string[] { + const branchIds = new Set(ids); + for (const id of ids) { + this.branchesFrom.get(id)?.forEach(child => branchIds.add(child)); + } + return [...branchIds]; + } + + private nameOf(id: string): HTMLDivElement | null { + return nameElements(this.groupsOf([id])).node(); + } + + private enterNodes( + enter: d3.Selection + ): NodeGroups { + const groups = enter + .append('g') + .attr('class', 'node') + .style('cursor', 'pointer') + .style('touch-action', 'none') + .on('dblclick', (event: MouseEvent, id: string) => { + if (!this.map.options.edit) return; + + event.stopPropagation(); + this.enableNodeNameEditing(id); + }) + .on( + 'touchstart', + (event: TouchEvent, id: string) => { + if (!this.map.options.edit) return false; + // A single tap moves the node, so the handler cancels the native + // touch behavior unless the person taps a link or edits a name. + if (!this.isLinkTarget(event) && this.editingId === null) { + event.preventDefault(); + } + + // The first tap starts a move. A second tap within 300 ms edits + // the name. + if (!this.tappedTwice) { + this.tappedTwice = true; + + setTimeout(() => { + this.tappedTwice = false; + }, 300); + + return false; + } + + this.enableNodeNameEditing(id); + }, + { passive: false } + ); + + if (this.map.options.drag === true) { + groups.call(this.map.drag.getDragBehavior()); } else { - domText.textContent = 'link'; - domText.classList.add('material-icons'); + groups.on('mousedown', (_event: MouseEvent, id: string) => { + this.map.nodes.selectNode(id); + }); + } + + return groups; + } + + /** Start observing the name of each node drawn for the first time. */ + private observe(groups: NodeGroups) { + if (!this.resizeObserver) return; + + nameElements(groups).each((_id, i, names) => { + if (this.observed.has(names[i])) return; + this.observed.add(names[i]); + this.resizeObserver?.observe(names[i]); + }); + } + + /** Remove the DOM element of the node. */ + private dropGroup(id: string) { + const group = this.groups.get(id); + if (!group) return; + + nameElements(this.groupsOf([id])).each((_id, i, names) => { + this.resizeObserver?.unobserve(names[i]); + // A blur fired by the removal would commit the typed name over the + // change that removed the node. + names[i].onblur = null; + }); + // Removing a focused name fires no blur in Firefox and WebKit, so the + // edit ends here. + if (id === this.editingId) this.editingId = null; + group.remove(); + this.groups.delete(id); + } + + /** + * Record that the node waits for the parent with the id `parent` to appear + * in the map data, or for none when `parent` is null. + */ + private waitForParent(id: string, parent: string | null) { + const waiting = this.missingParents.get(id); + if (waiting === parent) return; + + if (waiting !== undefined) { + const orphans = this.orphans.get(waiting); + orphans?.delete(id); + if (orphans?.size === 0) this.orphans.delete(waiting); + this.missingParents.delete(id); } - domText.style.setProperty('fill', DOMPurify.sanitize(node.colors.link)); - domText.setAttribute('y', node.dimensions.height.toString()); - domText.setAttribute('text-anchor', 'middle'); + if (parent === null) return; + + this.missingParents.set(id, parent); + const orphans = this.orphans.get(parent) ?? new Set(); + orphans.add(id); + this.orphans.set(parent, orphans); + } + + /** Remove the drawn branch to the node. */ + private dropBranch(id: string) { + const branch = this.branches.get(id); + if (!branch) return; + + branch.path.remove(); + this.branches.delete(id); + const from = this.branchesFrom.get(branch.parent); + from?.delete(id); + if (from?.size === 0) this.branchesFrom.delete(branch.parent); } + + /** + * Checks if the target of the event is the link below the node + * @param {TouchEvent} event + * @returns {boolean} + */ + private isLinkTarget(event: TouchEvent): boolean { + return (event.target as Element).classList[0] === 'link-text'; + } +} + +function translate(position: MapNodeCoordinates): string { + return 'translate(' + position.x + ',' + position.y + ')'; } diff --git a/packages/mmp/src/map/handlers/empty-selection.spec.ts b/packages/mmp/src/map/handlers/empty-selection.spec.ts index 8ff44992..8de48fba 100644 --- a/packages/mmp/src/map/handlers/empty-selection.spec.ts +++ b/packages/mmp/src/map/handlers/empty-selection.spec.ts @@ -1,9 +1,5 @@ -import Nodes from './nodes.js'; -import CopyPaste from './copy-paste.js'; -import Node, { NodeProperties } from '../models/node.js'; -import { DefaultNodeValues } from '../options.js'; -import MmpMap from '../map.js'; -import { Event } from './events.js'; +import { firedEvents, nodeRecord, stubMap } from '../../test/stub-map.js'; +import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; /** * Deselecting leaves no node selected, the main root included. With nothing @@ -11,59 +7,30 @@ import { Event } from './events.js'; * map load selects the main root again. */ -const SVG_NS = 'http://www.w3.org/2000/svg'; - -/** The parts of a node's DOM that selection reads: the background and name. */ -function nodeDom(): SVGGElement { - const group = document.createElementNS(SVG_NS, 'g'); - const background = document.createElementNS(SVG_NS, 'path'); - background.style.fill = 'rgb(255, 255, 255)'; - const foreignObject = document.createElementNS(SVG_NS, 'foreignObject'); - foreignObject.appendChild(document.createElement('div')); - group.append(background, foreignObject); - return group; -} - -function makeNode(properties: Partial & { id: string }): Node { - const node = new Node({ k: 1, parent: null, ...properties }); - node.dom = nodeDom(); - return node; -} - -function makeMap() { - const events = { call: jest.fn() }; - const history = { save: jest.fn() }; - const map = { - rootId: 'root', - options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn(), clear: jest.fn() }, - events, - history, - } as unknown as MmpMap; - - const handler = new Nodes(map); - map.nodes = handler; - map.copyPaste = new CopyPaste(map); - - const root = makeNode({ id: 'root', isRoot: true }); - const branch = makeNode({ - id: 'branch', - parent: root, - coordinates: { x: 200, y: 0 }, - }); - const other = makeNode({ - id: 'other', - parent: root, - coordinates: { x: -200, y: 0 }, - }); - for (const node of [root, branch, other]) handler.setNode(node.id, node); - - return { map, handler, events, history, nodes: { root, branch, other } }; -} - -/** The event names mmp fired, in order. */ -function firedEvents(events: { call: jest.Mock }): string[] { - return events.call.mock.calls.map(call => call[0]); +const COLORS = { background: '#ffffff' }; + +/** root -> branch, root -> other, with nothing selected. */ +function makeMap(extra: MapSnapshot = []) { + const stub = stubMap([ + nodeRecord({ id: 'root', isRoot: true, colors: COLORS }), + nodeRecord({ + id: 'branch', + parent: 'root', + coordinates: { x: 200, y: 0 }, + colors: COLORS, + }), + nodeRecord({ + id: 'other', + parent: 'root', + coordinates: { x: -200, y: 0 }, + colors: COLORS, + }), + ...extra, + ]); + stub.nodes.deselectNode(); + stub.events.emit.mockClear(); + + return { ...stub, handler: stub.nodes }; } describe('deselectNode', () => { @@ -78,18 +45,17 @@ describe('deselectNode', () => { }); it('tells listeners which node lost the selection', () => { - const { handler, events, nodes } = makeMap(); - handler.selectNode(nodes.branch.id); - events.call.mockClear(); + const { map, handler, events } = makeMap(); + handler.selectNode('branch'); + events.emit.mockClear(); handler.deselectNode(); - expect(events.call).toHaveBeenCalledWith( - Event.nodeDeselect, - nodes.branch.dom, - expect.objectContaining({ id: nodes.branch.id }) + expect(events.emit).toHaveBeenCalledWith( + 'nodeDeselect', + expect.objectContaining({ id: 'branch' }) ); - expect(nodes.branch.getBackgroundDOM().style.stroke).toBe(''); + expect(map.draw.ringOf('branch')).toBeNull(); }); it('fires nothing when nothing is selected', () => { @@ -97,19 +63,19 @@ describe('deselectNode', () => { handler.deselectNode(); - expect(events.call).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); }); }); describe('selectNode with nothing selected', () => { it('selects a node without a deselect event', () => { - const { handler, events, nodes } = makeMap(); + const { handler, events } = makeMap(); - const selected = handler.selectNode(nodes.branch.id); + const selected = handler.selectNode('branch'); - expect(selected?.id).toBe(nodes.branch.id); - expect(handler.getSelectedNode()).toBe(nodes.branch); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(selected?.id).toBe('branch'); + expect(handler.getSelectedNode()?.id).toBe('branch'); + expect(firedEvents(events)).toEqual(['nodeSelect']); }); it('ignores a direction', () => { @@ -117,35 +83,35 @@ describe('selectNode with nothing selected', () => { expect(handler.selectNode('left')).toBeNull(); expect(handler.selectNode('up')).toBeNull(); - expect(events.call).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); }); }); describe('selectNode from one node to another', () => { it('fires the deselect of the old node before the select of the new one', () => { - const { handler, events, nodes } = makeMap(); - handler.selectNode(nodes.branch.id); - events.call.mockClear(); + const { handler, events } = makeMap(); + handler.selectNode('branch'); + events.emit.mockClear(); - handler.selectNode(nodes.other.id); + handler.selectNode('other'); - expect(events.call.mock.calls.map(call => [call[0], call[2].id])).toEqual([ - [Event.nodeDeselect, nodes.branch.id], - [Event.nodeSelect, nodes.other.id], + expect(events.emit.mock.calls.map(call => [call[0], call[1].id])).toEqual([ + ['nodeDeselect', 'branch'], + ['nodeSelect', 'other'], ]); }); it('leaves nothing selected while the deselect listeners run', () => { - const { handler, events, nodes } = makeMap(); - handler.selectNode(nodes.branch.id); - const selectedDuringDeselect: (Node | null)[] = []; - events.call.mockImplementation((event: string) => { - if (event === Event.nodeDeselect) { + const { handler, events } = makeMap(); + handler.selectNode('branch'); + const selectedDuringDeselect: (ExportNodeProperties | null)[] = []; + events.emit.mockImplementation((event: string) => { + if (event === 'nodeDeselect') { selectedDuringDeselect.push(handler.getSelectedNode()); } }); - handler.selectNode(nodes.other.id); + handler.selectNode('other'); expect(selectedDuringDeselect).toEqual([null]); }); @@ -153,33 +119,33 @@ describe('selectNode from one node to another', () => { describe('selectRootNode', () => { it('selects the main root after a deselect', () => { - const { handler, nodes } = makeMap(); - handler.selectNode(nodes.branch.id); + const { handler } = makeMap(); + handler.selectNode('branch'); handler.deselectNode(); handler.selectRootNode(); - expect(handler.getSelectedNode()).toBe(nodes.root); + expect(handler.getSelectedNode()?.id).toBe('root'); }); }); describe('operations on the selected node with nothing selected', () => { it('updates no node', () => { - const { handler, history, nodes } = makeMap(); + const { handler, data, events } = makeMap(); handler.updateNode('fontWeight', 'bold'); - expect(nodes.branch.font.weight).not.toBe('bold'); - expect(history.save).not.toHaveBeenCalled(); + expect(data.node('branch')?.font?.weight).not.toBe('bold'); + expect(events.emit).not.toHaveBeenCalled(); }); it('removes no node', () => { - const { handler, history } = makeMap(); + const { handler, data, events } = makeMap(); handler.removeNode(); - expect(handler.getNodes()).toHaveLength(3); - expect(history.save).not.toHaveBeenCalled(); + expect(data.nodes()).toHaveLength(3); + expect(events.emit).not.toHaveBeenCalled(); }); it('lists no children', () => { @@ -189,64 +155,71 @@ describe('operations on the selected node with nothing selected', () => { }); it('throws when nothing is selected and no parent is named', () => { - const { handler } = makeMap(); + const { handler, data } = makeMap(); + + expect(() => handler.addNode({})).toThrow('There is no selected node'); + expect(data.nodes()).toHaveLength(3); + }); + + it('throws and adds no root when the named parent is missing', () => { + const { handler, data } = makeMap(); - expect(() => handler.addNode({}, false, false)).toThrow( - 'There is no selected node' + expect(() => handler.addNode({}, 'removed-by-peer')).toThrow( + 'There are no nodes with id "removed-by-peer"' ); - expect(handler.getNodes()).toHaveLength(3); + expect(data.nodes()).toHaveLength(3); }); it('adds a child to a named parent', () => { - const { handler, nodes } = makeMap(); + const { handler } = makeMap(); - const added = handler.addNode({}, false, false, nodes.branch.id); + const added = handler.addNode({}, 'branch'); - expect(added.parent).toBe(nodes.branch); + expect(added?.parent).toBe('branch'); }); it('copies, cuts and pastes nothing', () => { - const { map, handler, events, nodes } = makeMap(); - map.copyPaste.copy(nodes.branch.id); + const { map, data, events } = makeMap(); + map.copyPaste.copy('branch'); map.copyPaste.copy(); map.copyPaste.cut(); map.copyPaste.paste(); - expect(handler.getNodes()).toHaveLength(3); - expect(events.call).not.toHaveBeenCalled(); + expect(data.nodes()).toHaveLength(3); + expect(events.emit).not.toHaveBeenCalled(); }); }); describe('removeNode and the selection', () => { it('leaves nothing selected after removing the selected node', () => { - const { handler, nodes } = makeMap(); - handler.selectNode(nodes.branch.id); + const { handler } = makeMap(); + handler.selectNode('branch'); handler.removeNode(); - expect(handler.existNode(nodes.branch.id)).toBe(false); + expect(handler.existNode('branch')).toBe(false); expect(handler.getSelectedNode()).toBeNull(); }); it('leaves nothing selected after removing an ancestor of the selected node', () => { - const { handler, nodes } = makeMap(); - const grandchild = makeNode({ id: 'grandchild', parent: nodes.branch }); - handler.setNode(grandchild.id, grandchild); - handler.selectNode(grandchild.id); + const { handler } = makeMap([ + nodeRecord({ id: 'grandchild', parent: 'branch', colors: COLORS }), + ]); + handler.selectNode('grandchild'); - handler.removeNode(nodes.branch.id, false); + handler.removeNode('branch'); - expect(handler.existNode(grandchild.id)).toBe(false); + expect(handler.existNode('grandchild')).toBe(false); expect(handler.getSelectedNode()).toBeNull(); }); it('keeps the selection when another node is removed', () => { - const { handler, nodes } = makeMap(); - handler.selectNode(nodes.branch.id); + const { handler } = makeMap(); + handler.selectNode('branch'); - handler.removeNode(nodes.other.id, false); + handler.removeNode('other'); - expect(handler.getSelectedNode()).toBe(nodes.branch); + expect(handler.getSelectedNode()?.id).toBe('branch'); }); }); diff --git a/packages/mmp/src/map/handlers/events.spec.ts b/packages/mmp/src/map/handlers/events.spec.ts new file mode 100644 index 00000000..dbf13f88 --- /dev/null +++ b/packages/mmp/src/map/handlers/events.spec.ts @@ -0,0 +1,59 @@ +import Events from './events.js'; + +describe('Events', () => { + const selection = { id: 'a', parent: null, k: 1 }; + let events: Events; + + beforeEach(() => { + events = new Events(); + }); + + it('calls every callback of one event with the payload', () => { + const first = jest.fn(); + const second = jest.fn(); + events.on('nodeSelect', first); + events.on('nodeSelect', second); + + events.emit('nodeSelect', selection); + + expect(first).toHaveBeenCalledWith(selection); + expect(second).toHaveBeenCalledWith(selection); + }); + + it('stops calling a callback after its remove function ran', () => { + const removed = jest.fn(); + const kept = jest.fn(); + const remove = events.on('nodeSelect', removed); + events.on('nodeSelect', kept); + + remove(); + events.emit('nodeSelect', selection); + + expect(removed).not.toHaveBeenCalled(); + expect(kept).toHaveBeenCalledWith(selection); + }); + + it('calls the next callback when one removes itself during emit', () => { + const next = jest.fn(); + const remove = events.on('nodeSelect', () => remove()); + events.on('nodeSelect', next); + + events.emit('nodeSelect', selection); + + expect(next).toHaveBeenCalledWith(selection); + }); + + it('silences every callback after unsubscribeAll', () => { + const select = jest.fn(); + const deselect = jest.fn(); + events.on('nodeSelect', select); + events.on('nodeDeselect', deselect); + + events.unsubscribeAll(); + events.emit('nodeSelect', selection); + events.emit('nodeDeselect', selection); + + expect(select).not.toHaveBeenCalled(); + expect(deselect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mmp/src/map/handlers/events.ts b/packages/mmp/src/map/handlers/events.ts index 6efb3275..6b60f15e 100644 --- a/packages/mmp/src/map/handlers/events.ts +++ b/packages/mmp/src/map/handlers/events.ts @@ -1,71 +1,62 @@ -import { dispatch, Dispatch } from 'd3'; -import Utils from '../../utils/utils.js'; import Log from '../../utils/log.js'; +import type { MmpEventPayloadMap, MmpEventType } from '@teammapper/shared'; -export type MmpEventCallback = (...args: unknown[]) => void; +export type MmpEventCallback = ( + payload: MmpEventPayloadMap[K] +) => void; + +type EventCallbacks = { + [P in K]?: Set>; +}; + +const EVENT_TYPES: readonly MmpEventType[] = [ + 'nodeSelect', + 'nodeDeselect', + 'nodeProtected', + 'mapChange', +]; /** - * Manage the events of the map. + * Manage the events of the map. `@teammapper/shared` types each payload, and + * each event holds any number of callbacks. */ export default class Events { - private dispatcher: Dispatch; - - /** - * Initialize the events. - */ - constructor() { - const events = Utils.fromObjectToArray(Event); - - this.dispatcher = dispatch(...events); - } + private callbacks: EventCallbacks = {}; /** - * Call all registered callbacks for specified map event. - * @param {Event} event - * @param {object} that - * @param parameters + * Call every callback registered for the event with its payload. The loop + * runs over a copy, so a callback may remove itself without skipping the next. */ - public call(event: Event, that?: object, ...parameters: unknown[]) { - return this.dispatcher.call(event, that, ...parameters); + public emit( + event: K, + payload: MmpEventPayloadMap[K] + ) { + const callbacks = this.callbacks[event]; + if (callbacks) [...callbacks].forEach(callback => callback(payload)); } /** - * Add a callback for specific map event. - * @param {string} event - * @param {Function} callback + * Add the callback to the event and return a function that removes it again. */ - public on = (event: string, callback: MmpEventCallback) => { - if (typeof event !== 'string') { - Log.error('The event must be a string', 'type'); - } - - const eventName = Event[event as keyof typeof Event]; - - if (!eventName) { - Log.error('The event does not exist'); - } - - this.dispatcher.on(eventName, callback); + public on = ( + event: K, + callback: MmpEventCallback + ): (() => void) => { + if (!EVENT_TYPES.includes(event)) Log.error('The event does not exist'); + + const callbacks: EventCallbacks = this.callbacks; + const set = callbacks[event] ?? new Set>(); + callbacks[event] = set; + set.add(callback); + return () => { + set.delete(callback); + }; }; /** - * Removes / resets all callbacks + * Remove every callback. */ public unsubscribeAll = () => { - Object.values(Event).forEach((event: string) => { - this.dispatcher.on(event, null); - }); + this.callbacks = {}; }; } - -export enum Event { - create = 'mmp-create', - nodeSelect = 'mmp-node-select', - nodeDeselect = 'mmp-node-deselect', - nodeUpdate = 'mmp-node-update', - nodeCreate = 'mmp-node-create', - nodePaste = 'mmp-node-paste', - nodeRemove = 'mmp-node-remove', - distribute = 'mmp-distribute', - nodeProtected = 'mmp-node-protected', -} diff --git a/packages/mmp/src/map/handlers/export.ts b/packages/mmp/src/map/handlers/export.ts index 658e355b..213655b1 100644 --- a/packages/mmp/src/map/handlers/export.ts +++ b/packages/mmp/src/map/handlers/export.ts @@ -2,6 +2,7 @@ import Map from '../map.js'; import Log from '../../utils/log.js'; import type { MapSnapshot } from '@teammapper/shared'; import Utils from '../../utils/utils.js'; +import { resolveNode } from '../data/node-record.js'; import * as d3 from 'd3'; import DOMPurify from 'dompurify'; @@ -20,13 +21,12 @@ export default class Export { } /** - * Return the snapshot (json) of the current map. + * Return the properties of every node, read from the map data at the time + * of the call. Each entry is a copy the caller may change. * @returns {MapSnapshot} json */ public asJSON = (): MapSnapshot => { - const snapshot = this.map.history.current(); - - return Utils.cloneObject(snapshot); + return this.map.data.nodes().map(resolveNode); }; /** diff --git a/packages/mmp/src/map/handlers/history.ts b/packages/mmp/src/map/handlers/history.ts deleted file mode 100644 index a46db01b..00000000 --- a/packages/mmp/src/map/handlers/history.ts +++ /dev/null @@ -1,307 +0,0 @@ -import Map from '../map.js'; -import Node, { NodeProperties } from '../models/node.js'; -import { Event } from './events.js'; -import Log from '../../utils/log.js'; -import Utils from '../../utils/utils.js'; -import { DefaultNodeValues } from '../options.js'; -import type { - ExportNodeProperties, - MapNodeColors, - MapNodeCoordinates, - MapNodeFont, - MapNodeImage, - MapNodeLink, - MapSnapshot, - OldMmpNode, -} from '@teammapper/shared'; - -/** - * Hold the snapshot of the current map and rebuild the map from one. - */ -export default class History { - private map: Map; - - private snapshot: MapSnapshot; - - /** - * Get the associated map instance and start with an empty snapshot. - * @param {Map} map - */ - constructor(map: Map) { - this.map = map; - - this.snapshot = []; - } - - /** - * Return the snapshot of the current map. - * @return {MapSnapshot} snapshot - */ - public current = (): MapSnapshot => { - return this.snapshot; - }; - - /** - * Replace old map with a new one or create a new empty map. - * @param {MapSnapshot} snapshot - */ - public new = (snapshot?: MapSnapshot, notifyWithEvent = true) => { - if (snapshot === undefined) { - this.map.nodes.clear(); - - this.map.draw.clear(); - this.map.draw.update(); - - this.map.nodes.addRootNode(); - - this.map.zoom.center('position', 0); - - this.save(); - - if (notifyWithEvent) this.map.events.call(Event.create, this.map.dom); - } else if (this.checkSnapshotStructure(snapshot)) { - const previousData = this.map.export.asJSON(); - - this.reapplyHiddenState(previousData, snapshot); - - this.redraw(snapshot); - - this.map.zoom.center('position', 0); - - // If the amount of nodes is == 0, automatically rollback to the last clean snapshot and display a toast - if (this.map.nodes.getNodes().length === 0) { - if (previousData.length > 0) { - this.redraw(previousData); - } - Log.error( - 'There was an error importing the map; changes have been rolled back.' - ); - } else { - this.save(); - if (notifyWithEvent) - this.map.events.call(Event.create, this.map.dom, { - previousMap: previousData, - }); - } - } else { - Log.error('The snapshot is not correct'); - } - }; - - /** - * Save the current snapshot of the mind map. - */ - public save() { - this.snapshot = this.getSnapshot(); - } - - /** - * Redraw the map with a new snapshot. - * @param {MapSnapshot} snapshot - */ - private redraw(snapshot: MapSnapshot) { - this.map.nodes.clear(); - - snapshot.forEach((property: ExportNodeProperties) => { - // in case the data model changes this makes sure all properties are at least present using defaults - const mergedProperty = { - ...DefaultNodeValues, - ...property, - } as ExportNodeProperties; - const properties: NodeProperties = { - id: mergedProperty.id, - parent: mergedProperty.parent - ? (this.map.nodes.getNode(mergedProperty.parent) ?? null) - : null, - k: mergedProperty.k, - name: mergedProperty.name, - coordinates: Utils.cloneObject( - mergedProperty.coordinates - ) as MapNodeCoordinates, - image: Utils.cloneObject(mergedProperty.image) as MapNodeImage, - colors: Utils.cloneObject(mergedProperty.colors) as MapNodeColors, - font: Utils.cloneObject(mergedProperty.font) as MapNodeFont, - link: Utils.cloneObject(mergedProperty.link) as MapNodeLink, - protected: mergedProperty.protected, - hidden: mergedProperty.hidden, - hasHiddenChildNodes: mergedProperty.hasHiddenChildNodes, - isRoot: mergedProperty.isRoot, - }; - - const node: Node = new Node(properties); - this.map.nodes.setNode(node.id, node); - - if (mergedProperty.isRoot) this.map.rootId = mergedProperty.id; - }); - - this.map.draw.clear(); - this.map.draw.update(); - - this.map.nodes.selectRootNode(); - } - - /** - * Return a copy of all fundamental node properties. - * @return {MapSnapshot} properties - */ - private getSnapshot(): MapSnapshot { - return this.map.nodes - .getNodes() - .map((node: Node) => { - return this.map.nodes.getNodeProperties(node, false); - }) - .slice(); - } - - /** - * Check the snapshot structure and return true if it is authentic. - * @param {MapSnapshot} snapshot - * @return {boolean} result - */ - private checkSnapshotStructure(snapshot: MapSnapshot): boolean { - if (!Array.isArray(snapshot)) { - return false; - } - - const firstNode = snapshot[0] as unknown; - if ( - firstNode && - typeof firstNode === 'object' && - 'key' in firstNode && - 'value' in firstNode - ) { - this.convertOldMmp(snapshot as unknown as OldMmpNode[]); - } - - for (const node of snapshot) { - if (!this.checkNodeProperties(node)) { - return false; - } - } - - return true; - } - - /** - * Check the snapshot node properties and return true if they are authentic. - * @param {ExportNodeProperties} node - * @return {boolean} result - */ - private checkNodeProperties(node: ExportNodeProperties) { - const conditions: boolean[] = [ - typeof node.id === 'string', - typeof node.parent === 'string' || node.parent === null, - typeof node.k === 'number', - typeof node.name === 'string', - // older maps do not include the link prop yet - node.link === undefined || typeof node.link.href === 'string', - Boolean( - node.coordinates && - typeof node.coordinates.x === 'number' && - typeof node.coordinates.y === 'number' - ), - Boolean( - node.image && - typeof node.image.size === 'number' && - typeof node.image.src === 'string' - ), - Boolean( - node.colors && - typeof node.colors.background === 'string' && - typeof node.colors.branch === 'string' && - typeof node.colors.name === 'string' - ), - Boolean( - node.font && - typeof node.font.size === 'number' && - typeof node.font.weight === 'string' && - typeof node.font.style === 'string' - ), - ]; - - return conditions.every(condition => condition); - } - - /** - * Convert the old mmp (version: 0.1.7) snapshot to new. - * @param {OldMmpNode[]} snapshot - */ - private convertOldMmp(snapshot: OldMmpNode[]) { - for (const node of snapshot) { - const oldNode = Utils.cloneObject(node); - const target = node as unknown as Record; - Utils.clearObject(target); - - target.id = 'map_node_' + oldNode.key.substr(4); - target.parent = oldNode.value.parent - ? 'map_node_' + oldNode.value.parent.substr(4) - : ''; - target.k = oldNode.value.k; - target.name = oldNode.value.name; - target.coordinates = { - x: oldNode.value.x, - y: oldNode.value.y, - }; - target.image = { - size: oldNode.value['image-size'] - ? parseInt(oldNode.value['image-size'], 10) - : 0, - src: oldNode.value['image-src'] || '', - }; - target.colors = { - background: oldNode.value['background-color'], - branch: oldNode.value['branch-color'] || '', - name: oldNode.value['text-color'], - }; - target.font = { - size: oldNode.value['font-size'] - ? parseInt(oldNode.value['font-size'], 10) - : 12, - weight: oldNode.value.bold ? 'bold' : 'normal', - style: oldNode.value.italic ? 'italic' : 'normal', - }; - } - } - - /** - * Find nodes that were previously hidden locally and re-apply attributes - * @param {MapSnapshot} previousData - * @param {MapSnapshot} snapshot - */ - private reapplyHiddenState( - previousData: MapSnapshot, - snapshot: MapSnapshot - ): void { - // Find all nodes where we've set hasHiddenChildNodes in the previous map - const nodesWithHiddenChildren = previousData.filter( - node => node.hasHiddenChildNodes - ); - - // This method will recursively hide all children of children until none are left - const hideChildNodes = (parentId: string) => - snapshot - .filter(node => node.parent === parentId) - .forEach(node => { - node.hidden = true; - hideChildNodes(node.id); - }); - - snapshot.forEach(snapshotNode => { - const nodeWithHiddenChildren = nodesWithHiddenChildren.find( - x => snapshotNode.id === x.id - ); - - if (nodeWithHiddenChildren) { - snapshotNode.hasHiddenChildNodes = true; - - // We need to iterate through the snapshot instead of using this.map.nodes.nodeChildren() to see if we need to set hidden attributes as the latter will not have new nodes added yet - snapshot - .filter(node => node.parent === snapshotNode.id) - .forEach(node => { - node.hidden = true; - hideChildNodes(node.id); - }); - } - }); - } -} diff --git a/packages/mmp/src/map/handlers/layout.ts b/packages/mmp/src/map/handlers/layout.ts index bb93a301..9c3a1051 100644 --- a/packages/mmp/src/map/handlers/layout.ts +++ b/packages/mmp/src/map/handlers/layout.ts @@ -24,7 +24,7 @@ export interface LayoutInputNode { parent: string | null; isRoot?: boolean; name?: string | null; - font?: Pick; + font?: MapNodeFont; coordinates?: MapNodeCoordinates; dimensions?: MapNodeDimensions; } @@ -91,10 +91,20 @@ function findTreeRoots(nodes: LayoutInputNode[]): LayoutInputNode[] { ]; } +/** Sizes a node that carries no measured dimensions from its name. */ +export type NodeExtentMeasure = ( + name: string, + font: MapNodeFont & { size: number } +) => MapNodeDimensions; + +const countCharacters: NodeExtentMeasure = (name, font) => + estimateNodeExtent(name, font.size); + export function computeMapLayout( - nodes: LayoutInputNode[] + nodes: LayoutInputNode[], + measure: NodeExtentMeasure = countCharacters ): Map { - return new MapLayout(nodes).build(); + return new MapLayout(nodes, measure).build(); } class MapLayout { @@ -108,7 +118,10 @@ class MapLayout { private readonly roots: LayoutInputNode[]; private readonly mainAnchor: MapNodeCoordinates; - constructor(nodes: LayoutInputNode[]) { + constructor( + nodes: LayoutInputNode[], + private readonly measure: NodeExtentMeasure + ) { this.nodes = this.dedupeById(nodes); this.roots = findTreeRoots(this.nodes); this.mainAnchor = { @@ -228,7 +241,7 @@ class MapLayout { ? node.font.size : DEFAULT_FONT_SIZE; - return estimateNodeExtent(node.name, fontSize); + return this.measure(node.name, { ...node.font, size: fontSize }); } private heightOf(node: LayoutInputNode): number { diff --git a/packages/mmp/src/map/handlers/legacy-detached.spec.ts b/packages/mmp/src/map/handlers/legacy-detached.spec.ts index 607a4e27..416abfb1 100644 --- a/packages/mmp/src/map/handlers/legacy-detached.spec.ts +++ b/packages/mmp/src/map/handlers/legacy-detached.spec.ts @@ -1,13 +1,16 @@ -import Nodes from './nodes.js'; -import History from './history.js'; -import MmpMap from '../map.js'; +import * as d3 from 'd3'; +import { create } from '../../index.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; import { DefaultNodeValues, DefaultRootNodeValues } from '../options.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; /** * A JSON export or a peer running an older release still carries the * `detached` key. mmp reads no such key, so a former detached node loads as a - * root at its stored position and takes children like any other root. + * root at its stored position and takes children like any other root. A node + * whose parent the map data lacks counts as a root too. */ /** A node the way an older release exported it, `detached` key included. */ @@ -27,7 +30,7 @@ function legacyNode( } as ExportNodeProperties; } -const LEGACY_SNAPSHOT: MapSnapshot = [ +const LEGACY_MAP: MapSnapshot = [ legacyNode('root', '', { ...DefaultRootNodeValues, isRoot: true, @@ -38,68 +41,30 @@ const LEGACY_SNAPSHOT: MapSnapshot = [ legacyNode('pasted', 'note', { coordinates: { x: 700, y: -400 } }), ]; -const SVG_NS = 'http://www.w3.org/2000/svg'; - -/** The parts of a node's DOM that selection reads: the background and name. */ -function nodeDom(fill: string): SVGGElement { - const group = document.createElementNS(SVG_NS, 'g'); - const background = document.createElementNS(SVG_NS, 'path'); - background.style.fill = fill; - const foreignObject = document.createElementNS(SVG_NS, 'foreignObject'); - foreignObject.appendChild(document.createElement('div')); - group.append(background, foreignObject); - return group; +function loadLegacyMap() { + const stub = stubMap(); + stub.map.loader.load(LEGACY_MAP.map(node => ({ ...node }))); + return stub; } -/** - * A map stub around the real node handler and history. Its draw gives each - * node the DOM that selection reads. - */ -function makeMap(): MmpMap { - const update = jest.fn(() => { - for (const node of map.nodes.getNodes()) { - node.dom = nodeDom(node.colors.background); - } - }); - const map = { - rootId: '', - options: { defaultNode: DefaultNodeValues }, - draw: { clear: jest.fn(), update }, - zoom: { center: jest.fn() }, - events: { call: jest.fn() }, - export: { asJSON: () => [] }, - } as unknown as MmpMap; - map.nodes = new Nodes(map); - map.history = new History(map); - return map; -} - -function loadLegacySnapshot(): MmpMap { - const map = makeMap(); - map.history.new( - LEGACY_SNAPSHOT.map(node => ({ ...node })), - false - ); - return map; -} - -describe('a snapshot that still carries the detached key', () => { +describe('a map whose nodes still carry the detached key', () => { it('loads every node', () => { - const map = loadLegacySnapshot(); + const { data } = loadLegacyMap(); expect( - map.nodes - .getNodes() + data + .nodes() .map(node => node.id) .sort() ).toEqual(['child', 'note', 'pasted', 'root']); }); it('loads a former detached node as a root at its stored position', () => { - const note = loadLegacySnapshot().nodes.getNode('note'); + const { data, nodes } = loadLegacyMap(); + const note = data.node('note'); expect({ - parent: note?.parent, + parent: nodes.parentOf('note'), isRoot: note?.isRoot, coordinates: note?.coordinates, }).toEqual({ @@ -110,53 +75,118 @@ describe('a snapshot that still carries the detached key', () => { }); it('keeps the pasted child under the former detached node', () => { - const pasted = loadLegacySnapshot().nodes.getNode('pasted'); + const { nodes } = loadLegacyMap(); - expect(pasted?.parent?.id).toBe('note'); + expect(nodes.parentOf('pasted')).toBe('note'); }); - it('keeps the main root as the only node with the main-root mark', () => { - const map = loadLegacySnapshot(); + it('keeps the main root as the only node with isRoot set', () => { + const { data } = loadLegacyMap(); expect( - map.nodes - .getNodes() + data + .nodes() .filter(node => node.isRoot) .map(node => node.id) ).toEqual(['root']); }); it('adds a child to a former detached node', () => { - const map = loadLegacySnapshot(); + const { nodes } = loadLegacyMap(); + + const added = nodes.addNode({ name: 'new' }, 'note'); + + expect(added?.parent).toBe('note'); + }); - const added = map.nodes.addNode({ name: 'new' }, false, false, 'note'); + it('keeps no detached key', () => { + const { data, map } = loadLegacyMap(); - expect(added.parent?.id).toBe('note'); + expect(data.nodes().some(node => 'detached' in node)).toBe(false); + expect(map.nodes.exportNode('note')).not.toHaveProperty('detached'); }); - it('exports no detached key', () => { - const map = loadLegacySnapshot(); + it('takes a peer write of a former detached node as a root', () => { + const { data, nodes } = loadLegacyMap(); - const exported = map.nodes - .getNodes() - .map(node => map.nodes.getNodeProperties(node)); - expect(exported.some(node => 'detached' in node)).toBe(false); + data.addNodes([ + legacyNode('synced', '', { + detached: true, + coordinates: { x: 5, y: 5 }, + }), + ]); + + expect(nodes.parentOf('synced')).toBeNull(); + }); +}); + +describe('a node whose parent the map data lacks', () => { + const ORPHANED: MapSnapshot = [ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'child', parent: 'root', coordinates: { x: 200, y: 0 } }), + nodeRecord({ + id: 'orphan', + parent: 'missing', + coordinates: { x: 600, y: 0 }, + }), + nodeRecord({ id: 'leaf', parent: 'orphan', coordinates: { x: 800, y: 0 } }), + ]; + + beforeAll(stubSvgLengths); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('answers the tree queries as a root', () => { + const { nodes } = stubMap(ORPHANED); + + expect(nodes.parentOf('orphan')).toBeNull(); + expect(nodes.treeRoot('leaf')).toBe('orphan'); + expect(nodes.level('leaf')).toBe(2); + expect(nodes.orientation('orphan')).toBeUndefined(); }); - it('adds a synced former detached node as a root whatever is selected', () => { - const map = loadLegacySnapshot(); - map.nodes.selectRootNode(); - - map.nodes.addNodes( - [ - legacyNode('synced', '', { - detached: true, - coordinates: { x: 5, y: 5 }, - }), - ], - false + it('draws as a root: a node without a branch', () => { + const ref = document.createElement('div'); + document.body.appendChild(ref); + create('map', ref, undefined, new InMemoryMapData(ORPHANED)); + + const branches = d3.selectAll('path.branch'); + expect(branches.data().sort()).toEqual(['child', 'leaf']); + expect(d3.selectAll('g.node').data()).toContain( + 'orphan' ); + }); - expect(map.nodes.getNode('synced')?.parent).toBeNull(); + it('draws the branch and the parent marks once the parent arrives', () => { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const data = new InMemoryMapData(ORPHANED); + const map = create('map', ref, undefined, data); + map.viewState.toggle('missing'); + + data.addNodes([ + nodeRecord({ + id: 'missing', + parent: 'root', + coordinates: { x: 400, y: 0 }, + }), + ]); + + const branches = d3.selectAll('path.branch'); + expect(branches.data().sort()).toEqual([ + 'child', + 'leaf', + 'missing', + 'orphan', + ]); + expect( + d3.selectAll('text.hidden-icon').data() + ).toEqual(['missing']); + const orphan = d3 + .selectAll('g.node') + .filter(id => id === 'orphan'); + expect(orphan.style('visibility')).toBe('hidden'); }); }); diff --git a/packages/mmp/src/map/handlers/map-loader.spec.ts b/packages/mmp/src/map/handlers/map-loader.spec.ts new file mode 100644 index 00000000..fd135aae --- /dev/null +++ b/packages/mmp/src/map/handlers/map-loader.spec.ts @@ -0,0 +1,319 @@ +import { create } from '../../index.js'; +import MmpMap from '../map.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; +import type { MapSnapshot, OldMmpNode } from '@teammapper/shared'; + +/** + * A map load replaces every node of the map data, and `exportAsJSON` reads + * them back. These specs drive both through `MmpInstance`, as the frontend + * does. + */ + +beforeAll(stubSvgLengths); + +function makeMap(data = new InMemoryMapData()): MmpMap { + const ref = document.createElement('div'); + document.body.appendChild(ref); + return create('map', ref, undefined, data); +} + +function rootOf(map: MmpMap): string { + const root = map.instance.exportRootProperties(); + if (!root) throw new Error('the map has no main root'); + return root.id; +} + +/** A loaded map: the main root with a styled child and a grandchild. */ +function makeLoadedMap(): { + map: MmpMap; + data: InMemoryMapData; + child: string; +} { + const data = new InMemoryMapData(); + const map = makeMap(data); + map.instance.new(); + const child = map.instance.addNode( + { + name: 'child', + colors: { background: '#ff0000' }, + font: { size: 18, weight: 'bold' }, + }, + rootOf(map) + ); + if (!child) throw new Error('addNode added no child'); + map.instance.addNode({ name: 'grandchild' }, child.id); + map.instance.updateNode('linkHref', 'https://example.com/', child.id); + return { map, data, child: child.id }; +} + +/** Two nodes in the format mmp 0.1.7 exported. */ +const LEGACY_MAP: OldMmpNode[] = [ + { + key: 'node0', + value: { + name: 'Legacy root', + x: 10, + y: 20, + k: 1, + 'background-color': '#ffffff', + 'text-color': '#000000', + }, + }, + { + key: 'node1', + value: { + parent: 'node0', + name: 'Legacy child', + x: 200, + y: 50, + k: 2, + 'background-color': '#eeeeee', + 'branch-color': '#ff0000', + 'text-color': '#333333', + 'font-size': '16', + 'image-size': '40', + 'image-src': '', + bold: true, + italic: true, + }, + }, +]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('exportAsJSON', () => { + it('returns no node for an empty map data', () => { + expect(makeMap().instance.exportAsJSON()).toEqual([]); + }); + + it('returns the properties of every node in the order they were added', () => { + const { map, child } = makeLoadedMap(); + const root = rootOf(map); + + expect(map.instance.exportAsJSON()).toMatchObject([ + { id: root, parent: null, isRoot: true, name: 'Root node' }, + { + id: child, + parent: root, + isRoot: false, + protected: false, + name: 'child', + colors: { background: '#ff0000' }, + font: { size: 18, weight: 'bold' }, + link: { href: 'https://example.com/' }, + }, + { parent: child, isRoot: false, name: 'grandchild' }, + ]); + }); + + it('returns plain JSON data', () => { + const { map } = makeLoadedMap(); + const exported = map.instance.exportAsJSON(); + + expect(exported).toStrictEqual(JSON.parse(JSON.stringify(exported))); + }); + + it('returns a copy the caller may change', () => { + const { map, data, child } = makeLoadedMap(); + const before = JSON.stringify(map.instance.exportAsJSON()); + + const exported = map.instance.exportAsJSON(); + exported.forEach(node => { + node.name = 'changed'; + if (node.colors) node.colors.background = '#000000'; + if (node.coordinates) node.coordinates.x = 999; + }); + exported.pop(); + + expect(JSON.stringify(map.instance.exportAsJSON())).toBe(before); + expect(data.node(child)?.name).toBe('child'); + }); + + it('includes a peer write to the map data', () => { + const { map, data, child } = makeLoadedMap(); + + data.updateNode(child, 'name', 'Remote'); + + const node = map.instance.exportAsJSON().find(n => n.id === child); + expect(node?.name).toBe('Remote'); + }); + + it('includes nodes added and leaves out nodes removed', () => { + const { map, child } = makeLoadedMap(); + const added = map.instance.addNode({ name: 'added' }, rootOf(map)); + if (!added) throw new Error('addNode added no node'); + + map.instance.removeNode(child); + + expect(map.instance.exportAsJSON().map(n => n.name)).toEqual([ + 'Root node', + 'added', + ]); + }); +}); + +describe('new', () => { + it('loads an exported map unchanged', () => { + const { map } = makeLoadedMap(); + const exported = map.instance.exportAsJSON(); + + const target = makeMap(); + target.instance.new(exported); + + expect(target.instance.exportAsJSON()).toStrictEqual(exported); + }); + + it('writes one replacement to the map data', () => { + const { map, data } = makeLoadedMap(); + const listener = jest.fn(); + data.subscribe(listener); + + map.instance.new(LEGACY_MAP as unknown as MapSnapshot); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].replaced).toBe(true); + }); + + it('makes the first node without a parent the main root of a map naming none', () => { + const { map, child } = makeLoadedMap(); + const root = rootOf(map); + const [rootNode, ...rest] = map.instance.exportAsJSON(); + const withoutRoot = [...rest, rootNode].map(node => ({ + ...node, + isRoot: false, + })); + const data = new InMemoryMapData(); + const target = makeMap(data); + const listener = jest.fn(); + data.subscribe(listener); + + target.instance.new(withoutRoot); + + expect(data.mainRootId()).toBe(root); + expect(data.node(child)?.isRoot).toBe(false); + expect(listener.mock.calls[0][0].replaced).toBe(true); + expect(target.instance.getSelectedNode()?.id).toBe(root); + }); + + it('converts a map in the legacy format', () => { + const map = makeMap(); + + map.instance.new(LEGACY_MAP as unknown as MapSnapshot); + + expect(map.instance.exportAsJSON()).toMatchObject([ + { + id: 'map_node_0', + parent: null, + isRoot: true, + k: 1, + name: 'Legacy root', + coordinates: { x: 10, y: 20 }, + image: { size: 0, src: '' }, + colors: { background: '#ffffff', branch: '', name: '#000000' }, + font: { size: 12, weight: 'normal', style: 'normal' }, + }, + { + id: 'map_node_1', + parent: 'map_node_0', + isRoot: false, + k: 2, + name: 'Legacy child', + coordinates: { x: 200, y: 50 }, + image: { size: 40, src: '' }, + colors: { background: '#eeeeee', branch: '#ff0000', name: '#333333' }, + font: { size: 16, weight: 'bold', style: 'italic' }, + }, + ]); + }); + + it('stores null as the parent of the main root of a new map', () => { + const data = new InMemoryMapData(); + const map = makeMap(data); + + map.instance.new(); + + expect(data.node(rootOf(map))?.parent).toBeNull(); + }); + + it('stores null as the parent of an imported root that holds an empty string', () => { + const { map } = makeLoadedMap(); + const exported = map.instance + .exportAsJSON() + .map(node => (node.parent ? node : { ...node, parent: '' })); + const data = new InMemoryMapData(); + + makeMap(data).instance.new(exported); + + expect(data.node(exported[0].id)?.parent).toBeNull(); + }); + + it('stores null as the parent of a root in the legacy format', () => { + const data = new InMemoryMapData(); + + makeMap(data).instance.new(LEGACY_MAP as unknown as MapSnapshot); + + expect(data.node('map_node_0')?.parent).toBeNull(); + }); + + it('gives a node without a k a random one', () => { + const map = makeMap(); + const nodes: OldMmpNode[] = JSON.parse(JSON.stringify(LEGACY_MAP)); + nodes[0].value.k = 0; + + map.instance.new(nodes as unknown as MapSnapshot); + + expect(map.instance.exportAsJSON()[0].k).not.toBe(0); + }); + + it('keeps only the fields a node has', () => { + const { map, child } = makeLoadedMap(); + const legacy = map.instance + .exportAsJSON() + .map(node => ({ ...node, hidden: true, hasHiddenChildNodes: true })); + const data = new InMemoryMapData(); + + const target = makeMap(data); + target.instance.new(legacy); + + const stored = data.nodes(); + expect(stored.every(node => !('hidden' in node))).toBe(true); + expect(stored.every(node => !('hasHiddenChildNodes' in node))).toBe(true); + const group = target.dom.g + .selectAll('g.node') + .filter(id => id === child) + .node(); + expect(group?.style.visibility).toBe('visible'); + }); + + it('refuses a map that is not a list of nodes and keeps the current map', () => { + const { map, data } = makeLoadedMap(); + const before = map.instance.exportAsJSON(); + const listener = jest.fn(); + data.subscribe(listener); + + expect(() => + map.instance.new({ id: 'x' } as unknown as MapSnapshot) + ).toThrow('The exported map is not correct'); + expect(map.instance.exportAsJSON()).toStrictEqual(before); + expect(listener).not.toHaveBeenCalled(); + }); + + describe('with an empty node list', () => { + it('keeps the current map and writes nothing', () => { + const { map, data } = makeLoadedMap(); + const before = map.instance.exportAsJSON(); + const listener = jest.fn(); + data.subscribe(listener); + + expect(() => map.instance.new([])).toThrow( + 'The map holds no nodes; the import changed nothing.' + ); + expect(map.instance.exportAsJSON()).toStrictEqual(before); + expect(map.instance.getSelectedNode()?.id).toBe(before[0].id); + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/mmp/src/map/handlers/map-loader.ts b/packages/mmp/src/map/handlers/map-loader.ts new file mode 100644 index 00000000..b63a34d1 --- /dev/null +++ b/packages/mmp/src/map/handlers/map-loader.ts @@ -0,0 +1,192 @@ +import MmpMap from '../map.js'; +import * as v from 'valibot'; +import { v4 as uuidv4 } from 'uuid'; +import { LinkSchema, NodeSchema } from '@teammapper/shared'; +import Log from '../../utils/log.js'; +import Utils from '../../utils/utils.js'; +import { DefaultNodeValues } from '../options.js'; +import { randomK } from '../data/node-record.js'; +import type { + ExportNodeProperties, + MapSnapshot, + OldMmpNode, +} from '@teammapper/shared'; + +/** + * A node of a map the loader accepts. Older maps carry no link and no isRoot + * attribute. + */ +const LoadedNodeSchema = v.object({ + ...NodeSchema.entries, + link: v.optional(LinkSchema), + isRoot: v.optional(v.boolean()), +}); + +/** + * The nodes with a main root. A map that names none makes the first node + * without a parent, in the order of the exported map, its main root, so the + * load centers and selects it and peers see a replacement. Changes the nodes + * in place. + */ +function withMainRoot(nodes: ExportNodeProperties[]): ExportNodeProperties[] { + if (nodes.some(node => node.isRoot)) return nodes; + + const root = nodes.find(node => !node.parent); + if (root) root.isRoot = true; + return nodes; +} + +/** + * Replace every node of the map with the nodes of an exported map, or with a + * new main root. The loader checks the nodes, converts the format mmp 0.1.7 + * exported and writes the result to the map data as one replacement. The + * change listener then draws the map, selects the main root and centers the + * view. ViewState keeps the ids of the nodes whose child nodes are hidden + * apart from the map data, so a load keeps those child nodes hidden. + */ +export default class MapLoader { + private map: MmpMap; + + /** + * Get the associated map instance. + * @param {MmpMap} map + */ + constructor(map: MmpMap) { + this.map = map; + } + + /** + * Replace the map with `input`, or start a new map holding only a main root + * when `input` is undefined. An empty `input` leaves the map as it is and + * throws. + * @param {MapSnapshot} input + */ + public load = (input?: MapSnapshot) => { + if (input === undefined) { + this.map.data.replaceMap([this.newMainRoot()]); + return; + } + + // The conversions below write to the nodes, and the caller keeps its own. + const nodes = Utils.cloneObject(input); + if (!this.isValidMap(nodes)) { + Log.error('The exported map is not correct'); + } + if (nodes.length === 0) { + Log.error('The map holds no nodes; the import changed nothing.'); + } + + this.map.data.replaceMap(withMainRoot(nodes.map(this.completed))); + }; + + /** A main root with the map's root node defaults, at the origin. */ + private newMainRoot(): ExportNodeProperties { + const { name, image, link, colors, font } = Utils.cloneObject( + this.map.options.rootNode + ); + + return { + id: uuidv4(), + parent: null, + k: randomK(), + name, + coordinates: { x: 0, y: 0 }, + image, + colors, + font, + link, + protected: false, + isRoot: true, + }; + } + + /** + * The node as the map data stores it, with only the fields a node has. A + * map exported by an older release may lack a property, and the defaults + * fill it in. A node without a k, or with 0, gets a random one. + * @param {ExportNodeProperties} node + */ + private completed = (node: ExportNodeProperties): ExportNodeProperties => { + const merged = Utils.cloneObject({ ...DefaultNodeValues, ...node }); + + return { + id: merged.id, + parent: merged.parent || null, + k: merged.k || randomK(), + name: merged.name, + coordinates: merged.coordinates, + image: merged.image, + colors: merged.colors, + font: merged.font, + link: merged.link, + protected: merged.protected ?? false, + isRoot: merged.isRoot, + }; + }; + + /** + * Return true when `nodes` is a list of valid nodes. A map in the legacy + * format is converted in place first. + * @param {MapSnapshot} nodes + * @return {boolean} result + */ + private isValidMap(nodes: MapSnapshot): boolean { + if (!Array.isArray(nodes)) { + return false; + } + + const firstNode = nodes[0] as unknown; + if ( + firstNode && + typeof firstNode === 'object' && + 'key' in firstNode && + 'value' in firstNode + ) { + this.convertOldMmp(nodes as unknown as OldMmpNode[]); + } + + return nodes.every(node => v.is(LoadedNodeSchema, node)); + } + + /** + * Convert the nodes of a map that mmp 0.1.7 exported to the current format. + * @param {OldMmpNode[]} nodes + */ + private convertOldMmp(nodes: OldMmpNode[]) { + for (const node of nodes) { + const oldNode = Utils.cloneObject(node); + const target = node as unknown as Record; + Utils.clearObject(target); + + target.id = 'map_node_' + oldNode.key.substr(4); + target.parent = oldNode.value.parent + ? 'map_node_' + oldNode.value.parent.substr(4) + : null; + target.k = oldNode.value.k; + target.isRoot = !oldNode.value.parent; + target.name = oldNode.value.name; + target.coordinates = { + x: oldNode.value.x, + y: oldNode.value.y, + }; + target.image = { + size: oldNode.value['image-size'] + ? parseInt(oldNode.value['image-size'], 10) + : 0, + src: oldNode.value['image-src'] || '', + }; + target.colors = { + background: oldNode.value['background-color'], + branch: oldNode.value['branch-color'] || '', + name: oldNode.value['text-color'], + }; + target.font = { + size: oldNode.value['font-size'] + ? parseInt(oldNode.value['font-size'], 10) + : 12, + weight: oldNode.value.bold ? 'bold' : 'normal', + style: oldNode.value.italic ? 'italic' : 'normal', + }; + } + } +} diff --git a/packages/mmp/src/map/handlers/multi-tree.spec.ts b/packages/mmp/src/map/handlers/multi-tree.spec.ts index a3ce45b7..60310be5 100644 --- a/packages/mmp/src/map/handlers/multi-tree.spec.ts +++ b/packages/mmp/src/map/handlers/multi-tree.spec.ts @@ -1,118 +1,120 @@ -import Nodes from './nodes.js'; -import Node, { NodeProperties } from '../models/node.js'; +import type Nodes from './nodes.js'; +import Export from './export.js'; +import { fakeDraw } from '../../test/fake-draw.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; import { DefaultNodeValues } from '../options.js'; -import MmpMap from '../map.js'; +import type { ResolvedNode } from '../data/node-record.js'; import { NODE_HORIZONTAL_SPACING, type Bounds } from './node-geometry.js'; import { NEW_TREE_FOOTPRINT, NEW_TREE_GAP, - nodeBounds, treeBounds, } from './tree-placement.js'; -import type { ExportNodeProperties } from '@teammapper/shared'; +import type { MapNodeDimensions, MapSnapshot } from '@teammapper/shared'; /** - * A map may hold several trees. A node with no parent is a root, whether or - * not it carries the main-root mark. Nodes measures sides, sibling navigation - * and branch colors against the root of each node's own tree. + * A map may hold several trees. A node with no parent is a root, whatever its + * isRoot attribute holds. Nodes measures sides, sibling navigation and branch + * colors against the root of each node's own tree. */ interface NodesInternals { - nodes: Map; - selectedNode: Node | null; - moveSelectionOnLevel(selected: Node, direction: boolean): void; - moveSelectionOnBranch(selected: Node, direction: boolean): void; + moveSelectionOnLevel(selected: ResolvedNode, direction: boolean): void; + moveSelectionOnBranch(selected: ResolvedNode, direction: boolean): void; } -function makeNode(properties: Partial & { id: string }): Node { - return new Node({ k: 1, parent: null, ...properties }); -} - -function exported( - id: string, - parent: string, - coordinates = { x: 0, y: 0 } -): ExportNodeProperties { - return { - ...DefaultNodeValues, - id, - parent, - k: 1, - coordinates, - colors: { ...DefaultNodeValues.colors }, - }; -} +const ROOT = nodeRecord({ id: 'root', isRoot: true }); +const BRANCH = nodeRecord({ + id: 'branch', + parent: 'root', + coordinates: { x: 200, y: 0 }, +}); +const SECOND_ROOT = nodeRecord({ + id: 'second-root', + coordinates: { x: 1000, y: 0 }, + colors: { ...DefaultNodeValues.colors, branch: '' }, +}); /** - * `view` is the visible area the zoom stub reports. The default null stands - * for jsdom's svg, which has no size. + * The main tree (root, branch) and a second root, plus `extra`, with the + * branch selected. `view` is the visible area the zoom stub reports. The + * default null stands for jsdom's svg, which has no size. */ -function makeMap(view: Bounds | null = null): { - handler: Nodes; - internals: NodesInternals; - nodes: Record; - history: { save: jest.Mock }; - zoom: { visibleArea: jest.Mock; panIntoView: jest.Mock }; -} { - const history = { save: jest.fn() }; - const zoom = { visibleArea: jest.fn(() => view), panIntoView: jest.fn() }; - const map = { - rootId: 'root', - options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn() }, - events: { call: jest.fn() }, - history, +function makeMap(view: Bounds | null = null, extra: MapSnapshot = []) { + const sizes = new Map(); + const zoom = { + center: jest.fn(), + visibleArea: jest.fn(() => view), + panIntoView: jest.fn(), + }; + const stub = stubMap([ROOT, BRANCH, SECOND_ROOT, ...extra], { + draw: fakeDraw(node => sizes.get(node.id) ?? { width: 0, height: 0 }), zoom, - } as unknown as MmpMap; - - const handler = new Nodes(map); - map.nodes = handler; - const internals = handler as unknown as NodesInternals; - - const root = makeNode({ id: 'root', isRoot: true }); - const branch = makeNode({ - id: 'branch', - parent: root, - coordinates: { x: 200, y: 0 }, - }); - const secondRoot = makeNode({ - id: 'second-root', - coordinates: { x: 1000, y: 0 }, }); + stub.nodes.selectNode('branch'); + const internals = stub.nodes as unknown as NodesInternals; - const nodes = { root, branch, secondRoot }; - for (const node of Object.values(nodes)) internals.nodes.set(node.id, node); - internals.selectedNode = branch; + return { ...stub, handler: stub.nodes, internals, zoom, sizes }; +} - return { handler, internals, nodes, history, zoom }; +function record(handler: Nodes, id: string): ResolvedNode { + const node = handler.record(id); + if (!node) throw new Error('no node ' + id); + return node; } -describe('addNodes', () => { +describe('a peer write of a second tree', () => { it('keeps a root parentless whatever node is selected', () => { - const { handler, nodes } = makeMap(); + const { handler, data } = makeMap(); - handler.addNodes([exported('third-root', '', { x: 2000, y: 0 })], false); + data.addNodes([ + nodeRecord({ id: 'third-root', coordinates: { x: 2000, y: 0 } }), + ]); - expect(handler.getNode('third-root')?.parent).toBeNull(); - expect(handler.getChildren(nodes.branch)).toEqual([]); + expect(handler.parentOf('third-root')).toBeNull(); + expect(handler.children('branch')).toEqual([]); }); it('attaches the nodes of a second tree to their own root', () => { - const { handler, nodes } = makeMap(); - - handler.addNodes( - [ - exported('third-root', '', { x: 2000, y: 0 }), - exported('child', 'third-root', { x: 1800, y: -120 }), - exported('grandchild', 'child', { x: 1600, y: -240 }), - ], - false - ); + const { handler, data } = makeMap(); + + data.addNodes([ + nodeRecord({ id: 'third-root', coordinates: { x: 2000, y: 0 } }), + nodeRecord({ + id: 'child', + parent: 'third-root', + coordinates: { x: 1800, y: -120 }, + }), + nodeRecord({ + id: 'grandchild', + parent: 'child', + coordinates: { x: 1600, y: -240 }, + }), + ]); - expect(handler.getNode('third-root')?.parent).toBeNull(); - expect(handler.getNode('child')?.parent?.id).toBe('third-root'); - expect(handler.getNode('grandchild')?.parent?.id).toBe('child'); - expect(handler.getChildren(nodes.branch)).toEqual([]); + expect(handler.parentOf('third-root')).toBeNull(); + expect(handler.parentOf('child')).toBe('third-root'); + expect(handler.parentOf('grandchild')).toBe('child'); + expect(handler.children('branch')).toEqual([]); + }); + + it('draws the added nodes and the parent they join in one pass', () => { + const { data, draw } = makeMap(); + draw.drawNodes.mockClear(); + + data.addNodes([ + nodeRecord({ id: 'third-root', coordinates: { x: 2000, y: 0 } }), + nodeRecord({ + id: 'child', + parent: 'third-root', + coordinates: { x: 1800, y: -120 }, + }), + ]); + + expect(draw.drawNodes).toHaveBeenCalledTimes(1); + expect(new Set(draw.drawNodes.mock.calls[0][0])).toEqual( + new Set(['third-root', 'child']) + ); }); }); @@ -120,51 +122,60 @@ describe('addNode', () => { it('adds a root for an explicit null parent', () => { const { handler } = makeMap(); - const added = handler.addNode( - { coordinates: { x: 2000, y: 0 } }, - false, - false, - null - ); + const added = handler.addNode({ coordinates: { x: 2000, y: 0 } }, null); - expect(added.parent).toBeNull(); - expect(added.isRoot).toBe(false); + expect(added?.parent).toBeNull(); + expect(added?.isRoot).toBe(false); + }); + + it('stores null as the parent of a new root and exports it', () => { + const { handler, data, map } = makeMap(); + + const added = handler.addNode({}, null); + if (!added) throw new Error('addNode added no root'); + + expect(data.node(added.id)?.parent).toBeNull(); + const exported = new Export(map).asJSON(); + expect(exported.find(node => node.id === added.id)?.parent).toBeNull(); }); it('gives a root branch color empty by default', () => { const { handler } = makeMap(); - const added = handler.addNode( - { coordinates: { x: 2000, y: 0 } }, - false, - false, - null - ); + const added = handler.addNode({ coordinates: { x: 2000, y: 0 } }, null); - expect(added.colors.branch).toBe(''); + expect(added?.colors?.branch).toBe(''); }); it('attaches a child to a second root', () => { - const { handler, nodes } = makeMap(); + const { handler } = makeMap(); + + const added = handler.addNode({}, 'second-root'); + + expect(added?.parent).toBe('second-root'); + expect(added?.coordinates).toEqual({ x: 800, y: -120 }); + }); + + it('leaves the selection where it was', () => { + const { handler } = makeMap(); - const added = handler.addNode({}, false, false, nodes.secondRoot.id); + handler.addNode({}, 'second-root'); - expect(added.parent).toBe(nodes.secondRoot); - expect(added.coordinates).toEqual({ x: 800, y: -120 }); + expect(handler.getSelectedNode()?.id).toBe('branch'); }); }); describe('newTreeCoordinates', () => { - function sizeNodes(nodes: Record, width: number): void { - for (const node of Object.values(nodes)) { - node.dimensions = { width, height: 30 }; + function sizeNodes(sizes: Map, width: number) { + for (const id of ['root', 'branch', 'second-root']) { + sizes.set(id, { width, height: 30 }); } } it('places the new root two spacings right of the bounding box of every tree', () => { - const { handler, nodes } = makeMap(); - sizeNodes(nodes, 120); - const rightEdge = nodes.secondRoot.coordinates.x + 60; + const { handler, sizes } = makeMap(); + sizeNodes(sizes, 120); + const rightEdge = 1000 + 60; expect(handler.newTreeCoordinates().x).toBe( rightEdge + 2 * NODE_HORIZONTAL_SPACING @@ -172,28 +183,29 @@ describe('newTreeCoordinates', () => { }); it("keeps the new root's first child clear of the other trees", () => { - const { handler, nodes } = makeMap(); - sizeNodes(nodes, 120); - const rightEdge = nodes.secondRoot.coordinates.x + 60; + const { handler, sizes } = makeMap(); + sizeNodes(sizes, 120); + const rightEdge = 1000 + 60; const root = handler.addNode( { coordinates: handler.newTreeCoordinates() }, - false, - false, null ); + if (!root) throw new Error('addNode added no root'); - const child = handler.addNode({}, false, false, root.id); - child.dimensions = { width: 120, height: 30 }; + const child = handler.addNode({}, root.id); + if (!child?.coordinates || !root.coordinates) { + throw new Error('addNode added no child'); + } expect(child.coordinates.x).toBeLessThan(root.coordinates.x); expect(child.coordinates.x - 60).toBeGreaterThan(rightEdge); }); it("measures the right edge from each node's width", () => { - const { handler, nodes } = makeMap(); - sizeNodes(nodes, 100); - nodes.branch.coordinates = { x: 950, y: 0 }; - nodes.branch.dimensions = { width: 400, height: 30 }; + const { handler, sizes } = makeMap(null); + sizeNodes(sizes, 100); + handler.updateNode('coordinates', { x: 950, y: 0 }, 'branch'); + sizes.set('branch', { width: 400, height: 30 }); expect(handler.newTreeCoordinates().x).toBe( 1150 + 2 * NODE_HORIZONTAL_SPACING @@ -201,9 +213,9 @@ describe('newTreeCoordinates', () => { }); it('keeps the new root level with the main root', () => { - const { handler, nodes } = makeMap(); - nodes.root.coordinates = { x: 0, y: 340 }; - nodes.secondRoot.coordinates = { x: 1000, y: -500 }; + const { handler } = makeMap(); + handler.updateNode('coordinates', { x: 0, y: 340 }, 'root'); + handler.updateNode('coordinates', { x: 1000, y: -500 }, 'second-root'); expect(handler.newTreeCoordinates().y).toBe(340); }); @@ -212,10 +224,10 @@ describe('newTreeCoordinates', () => { const { handler } = makeMap(); const coordinates = handler.newTreeCoordinates(); - const added = handler.addNode({ coordinates }, false, false, null); + const added = handler.addNode({ coordinates }, null); - expect(added.coordinates).toEqual(coordinates); - expect(added.parent).toBeNull(); + expect(added?.coordinates).toEqual(coordinates); + expect(added?.parent).toBeNull(); }); }); @@ -228,8 +240,11 @@ describe('newTreeCoordinates with a viewport', () => { minY: point.y + NEW_TREE_FOOTPRINT.minY, maxY: point.y + NEW_TREE_FOOTPRINT.maxY, }; - const trees = treeBounds(handler.getNodes(), node => - handler.getTreeRoot(node) + const records = handler.scan(); + const trees = treeBounds( + [...records.values()], + node => records.get(handler.treeRoot(node.id)) ?? node, + handler.boundsOf ); return trees.some( @@ -253,13 +268,13 @@ describe('newTreeCoordinates with a viewport', () => { }); it('moves the new tree to the nearest clear spot when a tree fills the middle', () => { - const { handler, nodes } = makeMap({ + const { handler, sizes } = makeMap({ minX: 600, maxX: 1400, minY: -300, maxY: 300, }); - nodes.secondRoot.dimensions = { width: 120, height: 60 }; + sizes.set('second-root', { width: 120, height: 60 }); const coordinates = handler.newTreeCoordinates(); @@ -272,33 +287,34 @@ describe('newTreeCoordinates with a viewport', () => { it('places the new tree outside a viewport a tree fills and pans to it', () => { const view = { minX: 900, maxX: 1100, minY: -100, maxY: 100 }; - const { handler, nodes, zoom } = makeMap(view); - nodes.secondRoot.dimensions = { width: 2000, height: 1000 }; - handler.selectNode = jest.fn(); + const { handler, zoom, sizes } = makeMap(view); + sizes.set('second-root', { width: 2000, height: 1000 }); const { x, y } = handler.newTreeCoordinates(); const crowds = crowdsATree(handler, { x, y }); const root = handler.addTree(); + if (!root) throw new Error('addTree added no root'); const inView = x >= view.minX && x <= view.maxX && y >= view.minY && y <= view.maxY; expect(inView).toBe(false); expect(crowds).toBe(false); expect(root.coordinates).toEqual({ x, y }); - expect(zoom.panIntoView).toHaveBeenCalledWith(nodeBounds(root)); + expect(zoom.panIntoView).toHaveBeenCalledWith( + handler.boundsOf(record(handler, root.id)) + ); }); }); describe('addTree', () => { - it('adds a root with no parent and no main-root mark', () => { + it('adds a root with no parent and isRoot unset', () => { const { handler } = makeMap(); - handler.selectNode = jest.fn(); const root = handler.addTree(); - expect(root.parent).toBeNull(); - expect(root.isRoot).toBe(false); - expect(root.coordinates).toEqual({ + expect(root?.parent).toBeNull(); + expect(root?.isRoot).toBe(false); + expect(root?.coordinates).toEqual({ x: 1000 + 2 * NODE_HORIZONTAL_SPACING, y: 0, }); @@ -306,107 +322,110 @@ describe('addTree', () => { it('selects the new root and pans the view to it', () => { const { handler, zoom } = makeMap(); - const selectNode = jest.fn(); - handler.selectNode = selectNode; const root = handler.addTree(); + if (!root) throw new Error('addTree added no root'); - expect(selectNode).toHaveBeenCalledWith(root.id); - expect(zoom.panIntoView).toHaveBeenCalledWith(nodeBounds(root)); + expect(handler.getSelectedNode()?.id).toBe(root.id); + expect(zoom.panIntoView).toHaveBeenCalledWith( + handler.boundsOf(record(handler, root.id)) + ); }); }); -describe('getOrientation', () => { +describe('orientation', () => { it("reads the side against the root of the node's own tree", () => { - const { handler, nodes } = makeMap(); - const leftOfSecond = makeNode({ - id: 'left-of-second', - parent: nodes.secondRoot, - coordinates: { x: 800, y: 0 }, - }); + const { handler } = makeMap(null, [ + nodeRecord({ + id: 'left-of-second', + parent: 'second-root', + coordinates: { x: 800, y: 0 }, + }), + ]); - expect(handler.getOrientation(leftOfSecond)).toBe(true); - expect(handler.getOrientation(nodes.branch)).toBe(false); + expect(handler.orientation('left-of-second')).toBe(true); + expect(handler.orientation('branch')).toBe(false); }); it('gives every root no side', () => { - const { handler, nodes } = makeMap(); + const { handler } = makeMap(); - expect(handler.getOrientation(nodes.root)).toBeUndefined(); - expect(handler.getOrientation(nodes.secondRoot)).toBeUndefined(); + expect(handler.orientation('root')).toBeUndefined(); + expect(handler.orientation('second-root')).toBeUndefined(); }); }); -describe('getTreeRoot', () => { +describe('treeRoot', () => { it('returns the ancestor with no parent', () => { - const { handler, nodes } = makeMap(); - const child = makeNode({ id: 'child', parent: nodes.secondRoot }); - const grandchild = makeNode({ id: 'grandchild', parent: child }); + const { handler } = makeMap(null, [ + nodeRecord({ id: 'child', parent: 'second-root' }), + nodeRecord({ id: 'grandchild', parent: 'child' }), + ]); - expect(handler.getTreeRoot(grandchild)).toBe(nodes.secondRoot); - expect(handler.getTreeRoot(nodes.secondRoot)).toBe(nodes.secondRoot); + expect(handler.treeRoot('grandchild')).toBe('second-root'); + expect(handler.treeRoot('second-root')).toBe('second-root'); }); it('stops at a cycle of ancestors', () => { - const { handler } = makeMap(); - const first = makeNode({ id: 'first' }); - const second = makeNode({ id: 'second', parent: first }); - first.parent = second; + const { handler } = makeMap(null, [ + nodeRecord({ id: 'first', parent: 'second' }), + nodeRecord({ id: 'second', parent: 'first' }), + ]); - expect(handler.getTreeRoot(first)).toBe(second); + expect(handler.treeRoot('first')).toBe('second'); }); }); describe('moveSelectionOnLevel', () => { it('stays on the side of the second root', () => { - const { handler, internals, nodes } = makeMap(); - const children = [ - makeNode({ + const { handler, internals } = makeMap(null, [ + nodeRecord({ id: 'left', - parent: nodes.secondRoot, + parent: 'second-root', coordinates: { x: 800, y: 0 }, }), - makeNode({ + nodeRecord({ id: 'right', - parent: nodes.secondRoot, + parent: 'second-root', coordinates: { x: 1200, y: 100 }, }), - makeNode({ + nodeRecord({ id: 'left-low', - parent: nodes.secondRoot, + parent: 'second-root', coordinates: { x: 800, y: 200 }, }), - ]; - children.forEach(child => internals.nodes.set(child.id, child)); - const selectNode = jest.fn(); - handler.selectNode = selectNode; + ]); + // A node id 'left' reads as a direction, so the spy selects nothing. + const selectNode = jest + .spyOn(handler, 'selectNode') + .mockImplementation(() => null); - internals.moveSelectionOnLevel(children[0], false); + internals.moveSelectionOnLevel(record(handler, 'left'), false); expect(selectNode).toHaveBeenCalledWith('left-low'); }); }); describe('moveSelectionOnBranch', () => { - function selectionFromSecondRoot(direction: boolean): string[] { - const { handler, internals, nodes } = makeMap(); - const children = [ - makeNode({ + function selectionFromSecondRoot(direction: boolean): unknown[] { + const { handler, internals } = makeMap(null, [ + nodeRecord({ id: 'left', - parent: nodes.secondRoot, + parent: 'second-root', coordinates: { x: 800, y: 0 }, }), - makeNode({ + nodeRecord({ id: 'right', - parent: nodes.secondRoot, + parent: 'second-root', coordinates: { x: 1200, y: 0 }, }), - ]; - children.forEach(child => internals.nodes.set(child.id, child)); - const selectNode = jest.fn(); - handler.selectNode = selectNode; + ]); + // A node id 'left' reads as a direction, so the spy selects nothing. + const selectNode = jest + .spyOn(handler, 'selectNode') + .mockImplementation(() => null); - internals.moveSelectionOnBranch(nodes.secondRoot, direction); + internals.moveSelectionOnBranch(record(handler, 'second-root'), direction); return selectNode.mock.calls.map(call => call[0]); } @@ -422,33 +441,23 @@ describe('moveSelectionOnBranch', () => { describe('updateNode branchColor', () => { it('refuses a branch color on a second root', () => { - const { handler, nodes } = makeMap(); + const { handler, data } = makeMap(); expect(() => - handler.updateNode( - 'branchColor', - '#ff0000', - false, - false, - nodes.secondRoot.id - ) + handler.updateNode('branchColor', '#ff0000', 'second-root') ).toThrow('A root node has no branches'); - expect(nodes.secondRoot.colors.branch).toBe(''); + expect(data.node('second-root')?.colors?.branch).toBe(''); }); - it('accepts the unchanged branch color of a root, as a colors sync sends it', () => { - const { handler, nodes, history } = makeMap(); - nodes.secondRoot.colors.branch = '#577a96'; + it('accepts the unchanged branch color of a root, as a colors write sends it', () => { + const { handler, data } = makeMap(); + data.updateNode('second-root', 'branchColor', '#577a96'); + const listener = jest.fn(); + data.subscribe(listener); - handler.updateNode( - 'branchColor', - '#577a96', - false, - true, - nodes.secondRoot.id - ); + handler.updateNode('branchColor', '#577a96', 'second-root'); - expect(nodes.secondRoot.colors.branch).toBe('#577a96'); - expect(history.save).not.toHaveBeenCalled(); + expect(data.node('second-root')?.colors?.branch).toBe('#577a96'); + expect(listener).not.toHaveBeenCalled(); }); }); diff --git a/packages/mmp/src/map/handlers/node-geometry.spec.ts b/packages/mmp/src/map/handlers/node-geometry.spec.ts index 24420848..41328104 100644 --- a/packages/mmp/src/map/handlers/node-geometry.spec.ts +++ b/packages/mmp/src/map/handlers/node-geometry.spec.ts @@ -4,6 +4,7 @@ import { NODE_WIDTH_PADDING, estimateNodeExtent, estimateTextExtent, + linesOf, } from './node-geometry.js'; /** @@ -77,6 +78,16 @@ describe('estimateTextExtent', () => { }); }); +describe('linesOf', () => { + it('reads an entity as the character it shows', () => { + expect(linesOf('R&D')).toEqual(['R&D']); + }); + + it('starts a new line at a line break element', () => { + expect(linesOf('first
second')).toEqual(['first', 'second']); + }); +}); + describe('estimateNodeExtent', () => { it('adds 45px of width and 30px of height for the node shape', () => { const label = 'a reasonably long label'; diff --git a/packages/mmp/src/map/handlers/node-geometry.ts b/packages/mmp/src/map/handlers/node-geometry.ts index 7ab650c8..54a8b810 100644 --- a/packages/mmp/src/map/handlers/node-geometry.ts +++ b/packages/mmp/src/map/handlers/node-geometry.ts @@ -1,11 +1,9 @@ -import type { MapNodeDimensions } from '@teammapper/shared'; +import type { MapNodeDimensions, MapNodeFont } from '@teammapper/shared'; /** - * A node's real size is only known once it has been drawn. The renderer needs - * an approximation when the browser reports no measurement (Firefox renders too - * late to read one), and the layout needs one for a snapshot that has never - * been drawn at all. Both use these numbers: packing the map for boxes smaller - * than the ones that end up drawn is what makes branches overlap. + * Node sizes and their estimates. A node's real size is known once its name + * is drawn. Before that, these functions estimate it. An estimate that comes + * out too small makes the layout overlap branches. */ /** An axis-aligned bounding box in map coordinates. */ @@ -27,12 +25,35 @@ export const NODE_HEIGHT_PADDING = 30; export const MIN_TEXT_EXTENT = 25; const WIDTH_PER_CHARACTER = 1 / 1.2; const LINE_HEIGHT_FACTOR = 1.2; +const DEFAULT_FONT_SIZE = 16; +/** The font a name is measured in: the node's font and the map's family. */ +export interface TextFont extends MapNodeFont { + family: string; +} + +/** + * The lines a name shows. A name holds the HTML its editor wrote, so + * `R&D` shows as `R&D` and `
` starts a new line. + */ +export function linesOf(name: string): string[] { + let text = name; + if (typeof document !== 'undefined') { + const template = document.createElement('template'); + template.innerHTML = name.replace(//gi, '\n'); + text = template.content.textContent ?? ''; + } + return text.split(/\r?\n|\r/g); +} + +/** + * Counts characters. Stands in for `measureTextExtent` where no canvas exists. + */ export function estimateTextExtent( text: string, fontSize: number ): MapNodeDimensions { - const lines = text.split(/\r?\n|\r/g); + const lines = linesOf(text); const longest = Math.max(...lines.map(line => line.length), 1); return { @@ -48,10 +69,59 @@ export function estimateNodeExtent( text: string, fontSize: number ): MapNodeDimensions { - const { width, height } = estimateTextExtent(text, fontSize); + return withPadding(estimateTextExtent(text, fontSize)); +} + +let canvasContext: OffscreenCanvasRenderingContext2D | null | undefined; + +function measuringContext(): OffscreenCanvasRenderingContext2D | null { + if (canvasContext === undefined) { + canvasContext = + typeof OffscreenCanvas === 'undefined' + ? null + : new OffscreenCanvas(1, 1).getContext('2d'); + } + return canvasContext; +} + +/** + * The size of a name in `font`, measured on a canvas. An estimate until the + * name is drawn. + */ +export function measureTextExtent( + text: string, + font: TextFont +): MapNodeDimensions { + const size = font.size ?? DEFAULT_FONT_SIZE; + const context = measuringContext(); + if (!context) return estimateTextExtent(text, size); + + context.font = [ + font.style ?? 'normal', + font.weight ?? 'normal', + size + 'px', + font.family, + ].join(' '); + const lines = linesOf(text); + const width = Math.max(...lines.map(line => context.measureText(line).width)); + + return { + width: Math.max(Math.ceil(width), MIN_TEXT_EXTENT), + height: Math.max(lines.length * size * LINE_HEIGHT_FACTOR, MIN_TEXT_EXTENT), + }; +} + +export function measureNodeExtent( + text: string, + font: TextFont +): MapNodeDimensions { + return withPadding(measureTextExtent(text, font)); +} +/** The box of a node around a name of the given extent. */ +export function withPadding(text: MapNodeDimensions): MapNodeDimensions { return { - width: width + NODE_WIDTH_PADDING, - height: height + NODE_HEIGHT_PADDING, + width: text.width + NODE_WIDTH_PADDING, + height: text.height + NODE_HEIGHT_PADDING, }; } diff --git a/packages/mmp/src/map/handlers/node-image.spec.ts b/packages/mmp/src/map/handlers/node-image.spec.ts index cacb81f2..9c4c71c7 100644 --- a/packages/mmp/src/map/handlers/node-image.spec.ts +++ b/packages/mmp/src/map/handlers/node-image.spec.ts @@ -1,8 +1,8 @@ -import Draw from './draw.js'; -import Nodes from './nodes.js'; -import Node from '../models/node.js'; -import { DefaultNodeValues } from '../options.js'; +import { create } from '../../index.js'; +import { DefaultRootNodeValues } from '../options.js'; import MmpMap from '../map.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; /** * A node image is a base64 raster data URL, drawn as is, or an @@ -11,7 +11,6 @@ import MmpMap from '../map.js'; * the value. */ -const SVG_NS = 'http://www.w3.org/2000/svg'; const REFERENCE = 'image:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; const DATA_URL = 'data:image/png;base64,logo'; @@ -43,36 +42,38 @@ const lastImage = (): FakeImage => { return image; }; -function makeMap(resolveImageUrl?: (reference: string) => string | null) { - const map = { - rootId: 'root', - options: { defaultNode: DefaultNodeValues, resolveImageUrl }, - draw: { update: jest.fn(), clear: jest.fn() }, - events: { call: jest.fn() }, - history: { save: jest.fn() }, - } as unknown as MmpMap; - const draw = new Draw(map, document.createElement('div')); - map.draw = draw; - map.nodes = new Nodes(map); - return { map, draw }; +/** A map whose only node, the root, shows the image `src`. */ +function makeMap( + src: string, + resolveImageUrl?: (reference: string) => string | null +) { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const data = new InMemoryMapData([ + { + ...DefaultRootNodeValues, + id: 'root', + parent: null, + k: 1, + isRoot: true, + image: { src, size: 60 }, + }, + ]); + return create('map', ref, { resolveImageUrl }, data); } -function makeNode(src: string): Node { - const node = new Node({ - id: 'root', - k: 1, - parent: null, - isRoot: true, - image: { src, size: 60 }, - }); - node.dom = document.createElementNS(SVG_NS, 'g'); - node.dimensions = { width: 100, height: 40 }; - return node; +function setImage(map: MmpMap, src: string) { + map.instance.updateNode('imageSrc', src, 'root'); } +const drawnImage = (map: MmpMap) => + map.dom.g.node()?.querySelector('image') ?? null; + describe('node images', () => { const originalImage = globalThis.Image; + beforeAll(stubSvgLengths); + beforeEach(() => { FakeImage.created = []; globalThis.Image = FakeImage as unknown as typeof Image; @@ -80,32 +81,25 @@ describe('node images', () => { afterEach(() => { globalThis.Image = originalImage; + document.body.innerHTML = ''; }); it('draws a data URL as is', () => { - const { draw } = makeMap(); - const node = makeNode(DATA_URL); + const map = makeMap(DATA_URL); - draw.setImage(node); lastImage().load(); - expect(node.dom.querySelector('image')?.getAttribute('href')).toBe( - DATA_URL - ); + expect(drawnImage(map)?.getAttribute('href')).toBe(DATA_URL); }); it('draws a reference from the URL the resolver returns', () => { const resolve = jest.fn(() => '/api/maps/m/images/i'); - const { draw } = makeMap(resolve); - const node = makeNode(REFERENCE); + const map = makeMap(REFERENCE, resolve); - draw.setImage(node); lastImage().load(); expect(resolve).toHaveBeenCalledWith(REFERENCE); - expect(node.dom.querySelector('image')?.getAttribute('href')).toBe( - '/api/maps/m/images/i' - ); + expect(drawnImage(map)?.getAttribute('href')).toBe('/api/maps/m/images/i'); }); it.each([ @@ -115,87 +109,69 @@ describe('node images', () => { 'data:image/svg+xml,svg', 'data:text/html,hello', ])('loads nothing for the value %p', src => { - const { draw } = makeMap(); - const node = makeNode(src); - - draw.setImage(node); + const map = makeMap(src); expect(FakeImage.created).toHaveLength(0); - expect(node.dom.querySelector('image')).toBeNull(); + expect(drawnImage(map)).toBeNull(); }); it('draws no image for a reference without a resolver', () => { - const { draw } = makeMap(); - const node = makeNode(REFERENCE); - - draw.setImage(node); + const map = makeMap(REFERENCE); expect(FakeImage.created).toHaveLength(0); - expect(node.dom.querySelector('image')).toBeNull(); + expect(drawnImage(map)).toBeNull(); }); it('hides an image that fails to load and keeps its value', () => { - const { draw } = makeMap(() => '/api/maps/m/images/i'); - const node = makeNode(REFERENCE); + const map = makeMap(REFERENCE, () => '/api/maps/m/images/i'); - draw.setImage(node); lastImage().fail(); - expect(node.dom.querySelector('image')).toBeNull(); - expect(node.image.src).toBe(REFERENCE); + expect(drawnImage(map)).toBeNull(); + expect(map.instance.exportRootProperties()?.image?.src).toBe(REFERENCE); }); it('keeps the reference when the size of an image that failed to load changes', () => { - const { map, draw } = makeMap(() => '/api/maps/m/images/i'); - const node = makeNode(REFERENCE); - map.nodes.setNode(node.id, node); - draw.setImage(node); + const map = makeMap(REFERENCE, () => '/api/maps/m/images/i'); lastImage().fail(); - map.nodes.updateNode('imageSize', 90, false, false, node.id); + map.instance.updateNode('imageSize', 90, 'root'); - expect(node.image).toEqual({ src: REFERENCE, size: 90 }); + expect(map.instance.exportRootProperties()?.image).toEqual({ + src: REFERENCE, + size: 90, + }); + expect(drawnImage(map)).toBeNull(); }); - it('positions no image when a data URL failed to load', () => { - const { draw } = makeMap(); - const node = makeNode(DATA_URL); - draw.setImage(node); + it('loads an image that failed once only when its value changes', () => { + const map = makeMap(DATA_URL); lastImage().fail(); - expect(() => draw.updateImagePosition(node)).not.toThrow(); - expect(node.image.src).toBe(DATA_URL); + map.instance.updateNode('nameColor', '#ff0000', 'root'); + + expect(FakeImage.created).toHaveLength(1); }); it('keeps the new image when the replaced one fails to load late', () => { - const { draw } = makeMap(() => '/api/maps/m/images/i'); - const node = makeNode(REFERENCE); + const map = makeMap(REFERENCE, () => '/api/maps/m/images/i'); - draw.setImage(node); const stale = lastImage(); - node.image.src = DATA_URL; - draw.setImage(node); + setImage(map, DATA_URL); lastImage().load(); stale.fail(); - expect(node.dom.querySelector('image')?.getAttribute('href')).toBe( - DATA_URL - ); + expect(drawnImage(map)?.getAttribute('href')).toBe(DATA_URL); }); it('ignores a load that finishes after the image was replaced', () => { - const { draw } = makeMap(() => '/api/maps/m/images/i'); - const node = makeNode(REFERENCE); + const map = makeMap(REFERENCE, () => '/api/maps/m/images/i'); - draw.setImage(node); const stale = lastImage(); - node.image.src = DATA_URL; - draw.setImage(node); + setImage(map, DATA_URL); lastImage().load(); stale.load(); - expect(node.dom.querySelector('image')?.getAttribute('href')).toBe( - DATA_URL - ); + expect(drawnImage(map)?.getAttribute('href')).toBe(DATA_URL); }); }); diff --git a/packages/mmp/src/map/handlers/node-link.spec.ts b/packages/mmp/src/map/handlers/node-link.spec.ts deleted file mode 100644 index 1ea802c5..00000000 --- a/packages/mmp/src/map/handlers/node-link.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import Draw from './draw.js'; -import Nodes from './nodes.js'; -import Node from '../models/node.js'; -import { DefaultNodeValues } from '../options.js'; -import MmpMap from '../map.js'; - -const SVG_NS = 'http://www.w3.org/2000/svg'; - -function makeDraw(): Draw { - const map = { - rootId: 'root', - options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn(), clear: jest.fn() }, - events: { call: jest.fn() }, - history: { save: jest.fn() }, - } as unknown as MmpMap; - const draw = new Draw(map, document.createElement('div')); - map.draw = draw; - map.nodes = new Nodes(map); - return draw; -} - -function makeNode(href: string): Node { - const node = new Node({ - id: 'root', - k: 1, - parent: null, - isRoot: true, - link: { href }, - }); - node.dom = document.createElementNS(SVG_NS, 'g'); - node.dimensions = { width: 100, height: 40 }; - return node; -} - -describe('node links', () => { - it('draws an https link', () => { - const node = makeNode('https://example.com'); - - makeDraw().setLink(node); - - expect(node.dom.querySelector('a')?.getAttribute('href')).toBe( - 'https://example.com' - ); - }); - - it.each(['javascript:alert(1)', 'data:text/html,x', 'example.com'])( - 'draws no link for %s', - href => { - const node = makeNode(href); - - makeDraw().setLink(node); - - expect(node.dom.querySelector('a')).toBeNull(); - } - ); -}); diff --git a/packages/mmp/src/map/handlers/node-marks.spec.ts b/packages/mmp/src/map/handlers/node-marks.spec.ts new file mode 100644 index 00000000..b812118e --- /dev/null +++ b/packages/mmp/src/map/handlers/node-marks.spec.ts @@ -0,0 +1,102 @@ +import * as d3 from 'd3'; +import type { MapNodeDimensions } from '@teammapper/shared'; +import { + resolveNode, + type PartialNodeRecord, + type ResolvedNode, +} from '../data/node-record.js'; +import { NODE_MARKS, type MarkContext } from './node-marks.js'; + +/** + * The marks draw into a node group alone, so these specs draw one node + * without a map. The group binds the node's id, and the marks read the + * record through `recordOf`. + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function makeNode(properties: Partial = {}): ResolvedNode { + return resolveNode({ id: 'node', k: 1, ...properties }); +} + +/** Draw and finish every mark of the node at the given size. */ +function drawMarks( + node: ResolvedNode, + dimensions: MapNodeDimensions = { width: 100, height: 40 }, + group = document.createElementNS(SVG_NS, 'g') +): SVGGElement { + const context: MarkContext = { + recordOf: () => node, + textExtentOf: () => ({ width: 60, height: 20 }), + dimensionsOf: () => dimensions, + ringOf: () => null, + imageOf: () => null, + isEditing: () => false, + hidesChildren: () => false, + fontFamily: 'sans-serif', + showLinktext: false, + }; + const groups = d3.select(group).datum(node.id); + + NODE_MARKS.forEach(mark => mark.draw(groups, context)); + NODE_MARKS.forEach(mark => mark.finish(groups, context)); + + return group; +} + +describe('node links', () => { + it('draws an https link', () => { + const group = drawMarks( + makeNode({ link: { href: 'https://example.com' } }) + ); + + expect(group.querySelector('a')?.getAttribute('href')).toBe( + 'https://example.com' + ); + }); + + // The map refuses an unsafe link, but a peer's link reaches the renderer + // before the server sanitizes it, so the mark checks it once more. + it.each(['javascript:alert(1)', 'data:text/html,x', 'example.com'])( + 'draws no link for %s', + href => { + const group = drawMarks(makeNode({ link: { href } })); + + expect(group.querySelector('a')).toBeNull(); + } + ); +}); + +describe('lock badge', () => { + const badge = (group: SVGGElement) => + group.querySelector('text.protected-icon'); + + it('draws a badge on a node carrying the attribute only', () => { + expect(badge(drawMarks(makeNode({ protected: true })))?.textContent).toBe( + 'lock' + ); + expect(badge(drawMarks(makeNode()))).toBeNull(); + }); + + it('moves and recolors the badge when the node changes', () => { + const group = drawMarks(makeNode({ protected: true })); + + drawMarks( + makeNode({ protected: true, colors: { name: '#ff0000' } }), + { width: 200, height: 40 }, + group + ); + + expect(group.querySelectorAll('text.protected-icon')).toHaveLength(1); + expect(badge(group)?.getAttribute('x')).toBe('100'); + expect((badge(group) as SVGTextElement).style.fill).toBe('#ff0000'); + }); + + it('removes the badge once the protection is released', () => { + const group = drawMarks(makeNode({ protected: true })); + + drawMarks(makeNode({ protected: false }), undefined, group); + + expect(badge(group)).toBeNull(); + }); +}); diff --git a/packages/mmp/src/map/handlers/node-marks.ts b/packages/mmp/src/map/handlers/node-marks.ts new file mode 100644 index 00000000..340abb0c --- /dev/null +++ b/packages/mmp/src/map/handlers/node-marks.ts @@ -0,0 +1,280 @@ +import * as d3 from 'd3'; +import DOMPurify from 'dompurify'; +import { isSafeLinkHref, type MapNodeDimensions } from '@teammapper/shared'; +import type { ResolvedNode } from '../data/node-record.js'; + +/** The drawn nodes, each bound to its node id. */ +export type NodeGroups = d3.Selection< + SVGGElement, + string, + d3.BaseType, + unknown +>; + +/** An image the renderer has loaded, with its width over its height. */ +export interface LoadedImage { + url: string; + ratio: number; +} + +/** + * What a mark reads from the renderer, by node id. Marks never measure the + * page and read node records only through `recordOf`. + */ +export interface MarkContext { + /** The record of the node, read once per draw pass. */ + recordOf(id: string): ResolvedNode; + /** The size of the node's name. */ + textExtentOf(id: string): MapNodeDimensions; + /** The size of the node's box: the name and its padding. */ + dimensionsOf(id: string): MapNodeDimensions; + /** The color of the ring around the node, null for none. */ + ringOf(id: string): string | null; + /** The node's image once it has loaded, else null. */ + imageOf(id: string): LoadedImage | null; + /** True while the person edits the node's name. */ + isEditing(id: string): boolean; + /** True when the view state hides the child nodes the node has. */ + hidesChildren(id: string): boolean; + fontFamily: string; + showLinktext: boolean; +} + +/** + * One visual element of a node, such as its background or lock badge. + * `draw` runs before the names are measured and `finish` after. + */ +export interface NodeMark { + /** Draw what does not depend on the node's size. */ + draw(groups: NodeGroups, context: MarkContext): void; + /** Draw what depends on the node's size. */ + finish(groups: NodeGroups, context: MarkContext): void; +} + +/** + * Add the DOM element to each node `shown` accepts and remove it from the + * rest. The first class in `classes` names the DOM element. + */ +function join( + groups: NodeGroups, + tag: string, + classes: string, + shown: (id: string) => boolean = () => true +) { + return groups + .selectChildren(`${tag}.${classes.split(' ')[0]}`) + .data(id => (shown(id) ? [id] : [])) + .join(enter => enter.append(tag).attr('class', classes)); +} + +/** The DOM element of a mark on each node that has one. */ +function select(groups: NodeGroups, selector: string) { + return groups.selectChildren(selector); +} + +/** The DOM elements the name mark draws each name into. */ +export function nameElements(groups: NodeGroups) { + return select( + groups, + 'foreignObject.name' + ).selectChildren('div'); +} + +const background: NodeMark = { + draw(groups, context) { + join(groups, 'path', 'background') + .style('fill', id => context.recordOf(id).colors.background) + .style('stroke-width', 3) + .style('stroke', id => context.ringOf(id)); + }, + finish(groups, context) { + select(groups, 'path.background').attr('d', id => + backgroundShape(context.recordOf(id), context.dimensionsOf(id)) + ); + }, +}; + +function backgroundShape( + node: ResolvedNode, + { width, height }: MapNodeDimensions +) { + const path = d3.path(), + x = width / 2, + y = height / 2, + k = node.k; + + path.moveTo(-x, k / 3); + path.bezierCurveTo(-x, -y + 10, -x + 10, -y, k, -y); + path.bezierCurveTo(x - 10, -y, x, -y + 10, x, k / 3); + path.bezierCurveTo(x, y - 10, x - 10, y, k, y); + path.bezierCurveTo(-x + 10, y, -x, y - 10, -x, k / 3); + path.closePath(); + + return path.toString(); +} + +const name: NodeMark = { + draw(groups, context) { + join(groups, 'foreignObject', 'name') + .selectChildren('div') + .data(id => [id]) + .join(enter => + enter + .append('xhtml:div') + .style('touch-action', 'none') + .style('display', 'inline-block') + .style('white-space', 'pre') + // As wide as the text, whatever box surrounds it. + .style('width', 'max-content') + .style('text-align', 'center') + .style('cursor', 'pointer') + // fix against cursor jumping out of nodes on firefox if empty + .style('min-width', '20px') + ) + .style('font-family', context.fontFamily) + .style('font-size', id => context.recordOf(id).font.size + 'px') + .style('font-style', id => context.recordOf(id).font.style) + .style('font-weight', id => context.recordOf(id).font.weight) + .style('color', id => context.recordOf(id).colors.name) + .each((id, i, divs) => { + // Keep the text the person is typing. + if (context.isEditing(id)) return; + const html = DOMPurify.sanitize(context.recordOf(id).name); + if (divs[i].innerHTML !== html) divs[i].innerHTML = html; + }); + }, + finish(groups, context) { + select(groups, 'foreignObject.name').each( + (id, i, objects) => { + const { width, height } = context.textExtentOf(id); + d3.select(objects[i]) + .attr('x', -width / 2) + .attr('y', -height / 2) + .attr('width', width) + .attr('height', height); + } + ); + }, +}; + +const image: NodeMark = { + draw(groups, context) { + join( + groups, + 'image', + 'image', + id => context.imageOf(id) !== null + ) + .attr('href', id => context.imageOf(id)?.url ?? null) + .attr('clip-path', 'inset(0% round 15px)'); + }, + finish(groups, context) { + select(groups, 'image.image').each((id, i, images) => { + const height = context.recordOf(id).image.size, + width = height * (context.imageOf(id)?.ratio ?? 1); + d3.select(images[i]) + .attr('height', height) + .attr('width', width) + .attr('x', -width / 2) + .attr('y', -(height + context.dimensionsOf(id).height / 2 + 5)); + }); + }, +}; + +const MAX_LINK_TEXT_LENGTH = 50; + +function linkText(href: string): string { + if (href.length <= MAX_LINK_TEXT_LENGTH) return href; + return href.slice(0, MAX_LINK_TEXT_LENGTH - 3) + '...'; +} + +const link: NodeMark = { + draw(groups, context) { + // A peer's link reaches this client before the server sanitizes it, so + // the renderer checks the scheme: DOMPurify keeps a `javascript:` URL. + join(groups, 'a', 'link', id => + isSafeLinkHref(context.recordOf(id).link.href) + ) + .attr('href', id => context.recordOf(id).link.href) + .attr('target', '_self') + .selectChildren('text') + .data(id => [id]) + .join(enter => enter.append('text')) + // The touch handler recognizes the link by its first class. + .attr('class', 'link-text') + .classed('material-icons', !context.showLinktext) + .text(id => + context.showLinktext ? linkText(context.recordOf(id).link.href) : 'link' + ) + .style('text-decoration', () => + context.showLinktext ? 'underline' : null + ) + .style('font-style', () => (context.showLinktext ? 'italic' : null)) + .style('fill', id => context.recordOf(id).colors.link) + .attr('text-anchor', 'middle'); + }, + finish(groups, context) { + select(groups, 'a.link') + .selectChildren('text') + .attr('y', id => context.dimensionsOf(id).height); + }, +}; + +/** + * A material icon above the node, where `x` places it from the node's width. + */ +function icon( + className: string, + glyph: string, + shown: (id: string, context: MarkContext) => boolean, + x: (width: number) => number +): NodeMark { + return { + draw(groups, context) { + join(groups, 'text', `${className} material-icons`, id => + shown(id, context) + ) + .text(glyph) + .style('fill', id => context.recordOf(id).colors.name); + }, + finish(groups, context) { + select(groups, 'text.' + className).each( + (id, i, icons) => { + const { width, height } = context.dimensionsOf(id); + d3.select(icons[i]) + .attr('x', x(width)) + .attr('y', -height + 30); + } + ); + }, + }; +} + +/** A hidden eye icon on a node whose child nodes are hidden. */ +const hiddenChildren = icon( + 'hidden-icon', + 'visibility_off', + (id, context) => context.hidesChildren(id), + () => -60 +); + +/** + * A lock badge past the top right corner of a node carrying the protection. + * Descendants protected through an ancestor show no badge. + */ +const protection = icon( + 'protected-icon', + 'lock', + (id, context) => context.recordOf(id).protected, + width => width / 2 +); + +/** Every mark of a node, back to front. */ +export const NODE_MARKS: NodeMark[] = [ + background, + name, + image, + link, + hiddenChildren, + protection, +]; diff --git a/packages/mmp/src/map/handlers/node-protection.spec.ts b/packages/mmp/src/map/handlers/node-protection.spec.ts index 93952447..a556e503 100644 --- a/packages/mmp/src/map/handlers/node-protection.spec.ts +++ b/packages/mmp/src/map/handlers/node-protection.spec.ts @@ -1,106 +1,56 @@ -import CopyPaste from './copy-paste.js'; -import Drag from './drag.js'; import Draw from './draw.js'; -import Nodes from './nodes.js'; -import MmpMap from '../map.js'; -import Node, { NodeProperties } from '../models/node.js'; -import { DefaultNodeValues } from '../options.js'; -import { Event } from './events.js'; -import type { - ExportNodeProperties, - MapNodeCoordinates, -} from '@teammapper/shared'; +import { emitted, nodeRecord, stubMap } from '../../test/stub-map.js'; +import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; import type { D3DragEvent } from 'd3'; +import type MmpMap from '../map.js'; /** * A protected branch refuses local edits and announces the refusal with - * `nodeProtected`. Remote writes, which arrive with notifyWithEvent false, - * still apply. + * `nodeProtected`. A peer's write straight to the map data still applies. */ -type DragEvent = D3DragEvent; +type DragEvent = D3DragEvent; interface DragInternals { - started(event: DragEvent, node: Node): void; - dragged(event: DragEvent, node: Node): void; - ended(event: DragEvent, node: Node): void; -} - -/** A node with the DOM elements the update handlers write to. */ -function makeNode(properties: Partial & { id: string }): Node { - const node = new Node({ - k: 1, - parent: null, - colors: { ...DefaultNodeValues.colors }, - ...properties, - }); - const svg = 'http://www.w3.org/2000/svg'; - node.dom = document.createElementNS(svg, 'g'); - const foreignObject = document.createElementNS(svg, 'foreignObject'); - foreignObject.appendChild(document.createElement('div')); - node.dom.appendChild(foreignObject); - return node; + started(event: DragEvent, id: string): void; + dragged(event: DragEvent, id: string): void; + ended(event: DragEvent, id: string): void; } /** root -> a -> b, root -> c, with `a` protected. */ -function makeMap() { - const events = { call: jest.fn() }; - const map = { - id: 'map', - rootId: 'root', - options: { defaultNode: DefaultNodeValues }, - draw: { - update: jest.fn(), - clear: jest.fn(), - updateNodeShapes: jest.fn(), - updateProtectionIcon: jest.fn(), - drawBranch: jest.fn(() => null), - }, - history: { save: jest.fn() }, - events, - } as unknown as MmpMap; - - const nodes = new Nodes(map); - map.nodes = nodes; - nodes.fixCoordinates = (coordinates: MapNodeCoordinates) => coordinates; - nodes.selectNode = jest.fn(); - nodes.redrawSelectionRing = jest.fn(); - - const root = makeNode({ id: 'root', isRoot: true }); - const a = makeNode({ +const SNAPSHOT: MapSnapshot = [ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'a', - parent: root, + parent: 'root', protected: true, coordinates: { x: 200, y: 0 }, - }); - const b = makeNode({ id: 'b', parent: a, coordinates: { x: 400, y: 0 } }); - const c = makeNode({ id: 'c', parent: root, coordinates: { x: -200, y: 0 } }); - const tree = { root, a, b, c }; - Object.values(tree).forEach(node => nodes.setNode(node.id, node)); - - return { map, nodes, tree, events, clipboard: new CopyPaste(map) }; -} - -function calls(events: { call: jest.Mock }, event: Event): unknown[][] { - return events.call.mock.calls.filter(([name]) => name === event); + }), + nodeRecord({ id: 'b', parent: 'a', coordinates: { x: 400, y: 0 } }), + nodeRecord({ id: 'c', parent: 'root', coordinates: { x: -200, y: 0 } }), +]; + +function makeMap(snapshot: MapSnapshot = SNAPSHOT) { + const stub = stubMap(snapshot); + return { ...stub, clipboard: stub.map.copyPaste }; } -function refusals(events: { call: jest.Mock }): string[] { - return calls(events, Event.nodeProtected).map( - ([, , properties]) => (properties as ExportNodeProperties).id +function refusals(emit: jest.Mock): string[] { + return emitted(emit, 'nodeProtected').map( + node => (node as ExportNodeProperties).id ); } -function drag(map: MmpMap, node: Node, dx: number, dy: number) { - const handler = new Drag(map) as unknown as DragInternals; +function drag(map: MmpMap, id: string, dx: number, dy: number) { + const handler = map.drag as unknown as DragInternals; const event = { dx, dy } as DragEvent; - handler.started(event, node); - handler.dragged(event, node); - handler.ended(event, node); + handler.started(event, id); + handler.dragged(event, id); + handler.ended(event, id); } describe('protectingNode', () => { - it('returns the node carrying the flag for the node and its descendants', () => { + it('returns the node carrying the `protected` attribute for the node and its descendants', () => { const { nodes } = makeMap(); expect(nodes.protectingNode('a')).toBe('a'); @@ -109,119 +59,118 @@ describe('protectingNode', () => { }); it('stops at a parent cycle', () => { - const { nodes, tree } = makeMap(); - tree.a.protected = false; - tree.a.parent = tree.b; + const { nodes } = makeMap([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'a', parent: 'b' }), + nodeRecord({ id: 'b', parent: 'a' }), + ]); expect(nodes.protectingNode('b')).toBeNull(); }); }); describe('protectBranch', () => { - it('moves the flag of a protected child to the protected parent', () => { - const { nodes, tree } = makeMap(); + it('moves the `protected` attribute of a protected child to the protected parent', () => { + const { nodes, data } = makeMap(); nodes.protectBranch('root'); - expect(tree.root.protected).toBe(true); - expect(tree.a.protected).toBe(false); + expect(data.node('root')?.protected).toBe(true); + expect(data.node('a')?.protected).toBe(false); expect(nodes.protectingNode('b')).toBe('root'); }); - it('announces every flag it writes', () => { - const { nodes, events } = makeMap(); + it('writes every protected attribute in one change', () => { + const { nodes, data } = makeMap(); + const listener = jest.fn(); + data.subscribe(listener); nodes.protectBranch('root'); - const changed = calls(events, Event.nodeUpdate).map( - ([, , update]) => - (update as { nodeProperties: ExportNodeProperties }).nodeProperties.id - ); - expect(changed).toEqual(['a', 'root']); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].updated.sort()).toEqual(['a', 'root']); }); it('does nothing for a node already protected by an ancestor', () => { - const { nodes, tree } = makeMap(); + const { nodes, data } = makeMap(); nodes.protectBranch('b'); - expect(tree.b.protected).toBe(false); - expect(tree.a.protected).toBe(true); + expect(data.node('b')?.protected).toBe(false); + expect(data.node('a')?.protected).toBe(true); }); }); describe('releaseBranch', () => { it('releases the whole branch from a descendant', () => { - const { nodes, tree } = makeMap(); + const { nodes, data } = makeMap(); nodes.releaseBranch('b'); - expect(tree.a.protected).toBe(false); + expect(data.node('a')?.protected).toBe(false); expect(nodes.protectingNode('b')).toBeNull(); }); }); -describe('local edits inside a protected branch', () => { +describe('edits inside a protected branch', () => { it('refuses a rename and leaves the name unchanged', () => { - const { nodes, tree, events } = makeMap(); + const { nodes, data, events } = makeMap(); - nodes.updateNode('name', 'new', true, true, 'b'); + nodes.updateNode('name', 'new', 'b'); - expect(tree.b.name).toBe(''); - expect(refusals(events)).toEqual(['b']); + expect(data.node('b')?.name).toBe(''); + expect(refusals(events.emit)).toEqual(['b']); }); it('refuses a style change', () => { - const { nodes, tree } = makeMap(); + const { nodes, data } = makeMap(); - nodes.updateNode('fontWeight', 'bold', true, true, 'a'); + nodes.updateNode('fontWeight', 'bold', 'a'); - expect(tree.a.font.weight).toBe('normal'); + expect(data.node('a')?.font?.weight).toBe('normal'); }); - it('allows hiding and showing', () => { - const { nodes, tree, events } = makeMap(); + it('lets toggleBranchVisibility hide the child nodes of a protected node', () => { + const { map, nodes, events } = makeMap(); + nodes.selectNode('a'); + events.emit.mockClear(); - nodes.updateNode('hidden', true, true, true, 'b'); + nodes.toggleBranchVisibility(); - expect(tree.b.hidden).toBe(true); - expect(refusals(events)).toEqual([]); + expect(map.viewState.hidesChildren('a')).toBe(true); + expect(refusals(events.emit)).toEqual([]); }); it('refuses removing an ancestor of a protected node', () => { - const { nodes, tree, events } = makeMap(); - const d = makeNode({ id: 'd', parent: tree.root }); - nodes.setNode(d.id, d); - tree.a.parent = d; + const { nodes, events } = makeMap([ + ...SNAPSHOT.filter(node => node.id !== 'a'), + nodeRecord({ id: 'd', parent: 'root' }), + nodeRecord({ id: 'a', parent: 'd', protected: true }), + ]); nodes.removeNode('d'); expect(nodes.existNode('d')).toBe(true); expect(nodes.existNode('a')).toBe(true); - expect(refusals(events)).toEqual(['d']); + expect(refusals(events.emit)).toEqual(['d']); }); it('refuses adding a child', () => { - const { nodes, events } = makeMap(); + const { nodes, data, events } = makeMap(); - const added = nodes.addNodeUnlessProtected({}, true, true, 'b'); + const added = nodes.addNode({}, 'b'); expect(added).toBeNull(); - expect(nodes.getNodes()).toHaveLength(4); - expect(refusals(events)).toEqual(['b']); + expect(data.nodes()).toHaveLength(4); + expect(refusals(events.emit)).toEqual(['b']); }); it('adds a child to an unprotected node', () => { const { nodes } = makeMap(); - const added = nodes.addNodeUnlessProtected( - { coordinates: { x: -400, y: 0 } }, - true, - true, - 'c' - ); + const added = nodes.addNode({ coordinates: { x: -400, y: 0 } }, 'c'); - expect(added?.parent?.id).toBe('c'); + expect(added?.parent).toBe('c'); }); it('refuses a cut and keeps the clipboard empty', () => { @@ -229,120 +178,85 @@ describe('local edits inside a protected branch', () => { expect(clipboard.cut('b')).toBe(false); expect(nodes.existNode('b')).toBe(true); - expect(refusals(events)).toEqual(['b']); + expect(refusals(events.emit)).toEqual(['b']); }); it('refuses pasting onto a protected node', () => { - const { nodes, clipboard, events } = makeMap(); + const { data, clipboard, events } = makeMap(); clipboard.copy('c'); clipboard.paste('b'); - expect(nodes.getNodes()).toHaveLength(4); - expect(refusals(events)).toEqual(['b']); + expect(data.nodes()).toHaveLength(4); + expect(refusals(events.emit)).toEqual(['b']); }); it('refuses editing the name', () => { - const { map, tree, events } = makeMap(); + const { map, events } = makeMap(); + // The map was never drawn, so an editor that opened would throw. const draw = new Draw(map, document.createElement('div')); - draw.enableNodeNameEditing(tree.b); + draw.enableNodeNameEditing('b'); - expect(tree.b.getNameDOM().getAttribute('contenteditable')).toBeNull(); - expect(refusals(events)).toEqual(['b']); + expect(refusals(events.emit)).toEqual(['b']); }); }); describe('changes that stay allowed', () => { - it('applies a remote rename inside a protected branch', () => { - const { nodes, tree, events } = makeMap(); + it('applies a peer rename inside a protected branch', () => { + const { data, events } = makeMap(); - nodes.updateNode('name', 'remote', false, false, 'b'); + data.updateNode('b', 'name', 'peer'); - expect(tree.b.name).toBe('remote'); - expect(refusals(events)).toEqual([]); + expect(data.node('b')?.name).toBe('peer'); + expect(refusals(events.emit)).toEqual([]); }); - it('applies a remote removal of a protected node', () => { - const { nodes } = makeMap(); + it('applies a peer removal of a protected node', () => { + const { nodes, data } = makeMap(); - nodes.removeNode('a', false); + data.removeNode('a'); expect(nodes.existNode('a')).toBe(false); expect(nodes.existNode('b')).toBe(false); }); it('pastes a copy of a protected branch unprotected', () => { - const { nodes, clipboard, events } = makeMap(); + const { data, clipboard } = makeMap(); + const listener = jest.fn(); + data.subscribe(listener); clipboard.copy('a'); clipboard.paste('c'); - const pasted = (calls(events, Event.nodePaste)[0][2] ?? - []) as ExportNodeProperties[]; - expect(pasted).toHaveLength(2); - expect(pasted.every(node => node.protected === false)).toBe(true); - expect(nodes.getNodes()).toHaveLength(6); + const added: string[] = listener.mock.calls[0][0].added; + expect(added).toHaveLength(2); + expect(added.every(id => data.node(id)?.protected === false)).toBe(true); + expect(data.nodes()).toHaveLength(6); }); }); describe('drag', () => { it('leaves a protected node at its position', () => { - const { map, tree, events } = makeMap(); + const { map, data, events } = makeMap(); + const listener = jest.fn(); + data.subscribe(listener); - drag(map, tree.b, 50, 50); + drag(map, 'b', 50, 50); - expect(tree.b.coordinates).toEqual({ x: 400, y: 0 }); - expect(calls(events, Event.nodeUpdate)).toHaveLength(0); - expect(refusals(events)).toEqual(['b']); + expect(data.node('b')?.coordinates).toEqual({ x: 400, y: 0 }); + expect(listener).not.toHaveBeenCalled(); + expect(refusals(events.emit)).toEqual(['b']); }); it('moves a protected child along with its unprotected parent', () => { - const { map, nodes, tree } = makeMap(); + const { map, nodes, data } = makeMap(); nodes.releaseBranch('a'); - tree.b.protected = true; - - drag(map, tree.a, 50, 10); - - expect(tree.a.coordinates).toEqual({ x: 250, y: 10 }); - expect(tree.b.coordinates).toEqual({ x: 450, y: 10 }); - }); -}); - -describe('lock badge', () => { - it('draws a badge on the node carrying the flag only', () => { - const { map, tree } = makeMap(); - const draw = new Draw(map, document.createElement('div')); - - draw.updateProtectionIcon(tree.a); - draw.updateProtectionIcon(tree.b); - - expect(tree.a.getProtectionIconDOM()?.textContent).toBe('lock'); - expect(tree.b.getProtectionIconDOM()).toBeNull(); - }); - - it('moves and recolors the badge when the node changes', () => { - const { map, tree } = makeMap(); - const draw = new Draw(map, document.createElement('div')); - draw.updateProtectionIcon(tree.a); - - tree.a.dimensions = { width: 200, height: 40 }; - tree.a.colors.name = '#ff0000'; - draw.updateProtectionIcon(tree.a); - - const icon = tree.a.getProtectionIconDOM(); - expect(icon?.getAttribute('x')).toBe('100'); - expect(icon?.style.getPropertyValue('fill')).toBe('#ff0000'); - }); - - it('removes the badge once the protection is released', () => { - const { map, tree } = makeMap(); - const draw = new Draw(map, document.createElement('div')); - draw.updateProtectionIcon(tree.a); + data.updateNode('b', 'protected', true); - tree.a.protected = false; - draw.updateProtectionIcon(tree.a); + drag(map, 'a', 50, 10); - expect(tree.a.getProtectionIconDOM()).toBeNull(); + expect(data.node('a')?.coordinates).toEqual({ x: 250, y: 10 }); + expect(data.node('b')?.coordinates).toEqual({ x: 450, y: 10 }); }); }); diff --git a/packages/mmp/src/map/handlers/node-selection.spec.ts b/packages/mmp/src/map/handlers/node-selection.spec.ts index 1237d6ca..0a64ba45 100644 --- a/packages/mmp/src/map/handlers/node-selection.spec.ts +++ b/packages/mmp/src/map/handlers/node-selection.spec.ts @@ -1,6 +1,5 @@ -import Nodes from './nodes.js'; -import Node, { NodeProperties } from '../models/node.js'; -import MmpMap from '../map.js'; +import type { ResolvedNode } from '../data/node-record.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; /** * The left and right arrow keys walk a branch. A node left of the root moves @@ -9,68 +8,55 @@ import MmpMap from '../map.js'; * it to the lowest child on the side that key names. */ -const ROOT = new Node({ - id: 'root', - parent: null, - k: 1, - isRoot: true, - coordinates: { x: 0, y: 0 }, -}); - -function makeNode( - id: string, - parent: Node, - coordinates: NodeProperties['coordinates'] -): Node { - return new Node({ id, parent, k: 1, coordinates }); -} - -const LEFT_CHILD = makeNode('left', ROOT, { x: -200, y: 0 }); -const LEFT_GRANDCHILD = makeNode('left-low', LEFT_CHILD, { x: -400, y: 100 }); -const RIGHT_CHILD = makeNode('right', ROOT, { x: 200, y: 0 }); +const SNAPSHOT = [ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'left', parent: 'root', coordinates: { x: -200, y: 0 } }), + nodeRecord({ + id: 'left-low', + parent: 'left', + coordinates: { x: -400, y: 100 }, + }), + nodeRecord({ id: 'right', parent: 'root', coordinates: { x: 200, y: 0 } }), +]; function selectionAfterBranchMove( - selected: Node, + selected: string, direction: boolean -): string[] { - const handler = new Nodes({ rootId: ROOT.id } as unknown as MmpMap); - const internals = handler as unknown as { - nodes: Map; - moveSelectionOnBranch(selected: Node, direction: boolean): void; +): unknown[] { + const { nodes } = stubMap(SNAPSHOT); + const internals = nodes as unknown as { + moveSelectionOnBranch(selected: ResolvedNode, direction: boolean): void; }; + const record = nodes.record(selected); + if (!record) throw new Error('no node ' + selected); + // A node id 'left' reads as a direction, so the spy selects nothing. + const selectNode = jest + .spyOn(nodes, 'selectNode') + .mockImplementation(() => null); - for (const node of [ROOT, LEFT_CHILD, LEFT_GRANDCHILD, RIGHT_CHILD]) { - internals.nodes.set(node.id, node); - } - - const selectNode = jest.fn(); - handler.selectNode = selectNode; - - internals.moveSelectionOnBranch(selected, direction); + internals.moveSelectionOnBranch(record, direction); return selectNode.mock.calls.map(call => call[0]); } describe('moveSelectionOnBranch', () => { it('moves the root to a left-hand child on Left', () => { - expect(selectionAfterBranchMove(ROOT, true)).toEqual([LEFT_CHILD.id]); + expect(selectionAfterBranchMove('root', true)).toEqual(['left']); }); it('moves the root to a right-hand child on Right', () => { - expect(selectionAfterBranchMove(ROOT, false)).toEqual([RIGHT_CHILD.id]); + expect(selectionAfterBranchMove('root', false)).toEqual(['right']); }); it('moves a left-hand node outward on Left', () => { - expect(selectionAfterBranchMove(LEFT_CHILD, true)).toEqual([ - LEFT_GRANDCHILD.id, - ]); + expect(selectionAfterBranchMove('left', true)).toEqual(['left-low']); }); it('moves a left-hand node back to its parent on Right', () => { - expect(selectionAfterBranchMove(LEFT_CHILD, false)).toEqual([ROOT.id]); + expect(selectionAfterBranchMove('left', false)).toEqual(['root']); }); it('moves a right-hand node back to its parent on Left', () => { - expect(selectionAfterBranchMove(RIGHT_CHILD, true)).toEqual([ROOT.id]); + expect(selectionAfterBranchMove('right', true)).toEqual(['root']); }); }); diff --git a/packages/mmp/src/map/handlers/nodes.ts b/packages/mmp/src/map/handlers/nodes.ts index 6dc89618..75233407 100644 --- a/packages/mmp/src/map/handlers/nodes.ts +++ b/packages/mmp/src/map/handlers/nodes.ts @@ -1,11 +1,21 @@ -import Node, { NodeProperties } from '../models/node.js'; import MmpMap from '../map.js'; import * as d3 from 'd3'; -import DOMPurify from 'dompurify'; +import * as v from 'valibot'; import { v4 as uuidv4 } from 'uuid'; -import { Event } from './events.js'; +import { + collectSubtreeIds, + CssColorSchema, + NodePropertySchemas, +} from '@teammapper/shared'; import Log from '../../utils/log.js'; import Utils from '../../utils/utils.js'; +import type { MapData, MapDataChange } from '../data/map-data.js'; +import { isNodeProperty, PropertyMapping } from '../data/property-mapping.js'; +import { + randomK, + resolveNode, + type ResolvedNode, +} from '../data/node-record.js'; import { computeMapLayout, LayoutInputNode } from './layout.js'; import { NODE_HORIZONTAL_SPACING, @@ -21,11 +31,7 @@ import { } from './tree-placement.js'; import type { ExportNodeProperties, - MapNodeColors, MapNodeCoordinates, - MapNodeFont, - MapNodeImage, - MapNodeLink, MapSnapshot, NodeProperty, NodePropertyValue, @@ -33,96 +39,269 @@ import type { } from '@teammapper/shared'; const NODE_VERTICAL_SIBLING_OFFSET = 60; // The y-axis spacing between sibling nodes -export const PropertyMapping = { - name: ['name'], - protected: ['protected'], - coordinates: ['coordinates'], - imageSrc: ['image', 'src'], - imageSize: ['image', 'size'], - linkHref: ['link', 'href'], - backgroundColor: ['colors', 'background'], - branchColor: ['colors', 'branch'], - fontWeight: ['font', 'weight'], - fontStyle: ['font', 'style'], - fontSize: ['font', 'size'], - nameColor: ['colors', 'name'], - hidden: ['hidden'], -} as const; /** - * Manage the nodes of the map. + * Reads the record of a node: straight from the map data, or from the + * records one draw pass or one scan already read. + */ +export type RecordLookup = (id: string) => ResolvedNode | undefined; + +/** The lookup over the records of one `Nodes.scan`. */ +export const lookupIn = + (records: ReadonlyMap): RecordLookup => + id => + records.get(id); + +/** + * Read, change and select the nodes of the map. Nodes reads every attribute + * through the map data and writes through its typed methods. Nodes keeps the + * selected node's id and nothing else of the map. The change listener + * `onChange` draws every change: a local write, a peer's write and an undo + * take this one path. + * + * A change costs work in proportion to the nodes it adds, updates or removes. + * The tree queries that walk up along the parents run per drawn node; the + * ones that scan every node (children, siblings, descendants) run once per + * user action and never per change, per draw or per drag frame. */ export default class Nodes { /** - * Get the associated map instance and initialize the nodes. + * Get the associated map instance. * @param {MmpMap} map */ constructor(map: MmpMap) { this.map = map; - - this.nodes = new Map(); } - static NodePropertyMapping: typeof PropertyMapping = PropertyMapping; private map: MmpMap; - private nodes: Map; - // deselectNode sets this to null. A map load selects the main root. - private selectedNode: Node | null = null; + // deselectNode sets this to null. A replaced map selects the main root. + private selectedId: string | null = null; + // The ring color mmp last drew on the selected node. A highlight may have + // replaced the ring since, and refreshRing keeps it until the color changes. + private selectionRing: string | null = null; + + private get data(): MapData { + return this.map.data; + } /** - * Add the root node to the map. - * @param {MapNodeCoordinates} coordinates + * Draw a change of the map data, keep the selection and the view state in + * line with it, and announce it with `mapChange`. + * @param {MapDataChange} change */ - public addRootNode(coordinates?: MapNodeCoordinates) { - const rootId = uuidv4(); - - const properties = Utils.mergeObjects(this.map.options.rootNode, { - coordinates: { - x: 0, - y: 0, - }, - id: rootId, - parent: null, - hidden: false, - isRoot: true, - }) as unknown as NodeProperties; - - this.map.rootId = rootId; - - const node: Node = new Node(properties); - - if (coordinates) { - node.coordinates.x = coordinates.x || node.coordinates.x; - node.coordinates.y = coordinates.y || node.coordinates.y; - } + public onChange = (change: MapDataChange) => { + if (change.replaced) this.drawReplaced(); + else this.drawChange(change); - this.nodes.set(properties.id, node); - - this.map.draw.update(); + this.map.viewState.forget(change.removed); + this.map.events.emit('mapChange', undefined); + }; + /** + * Draw the whole map again, select the main root and center the view on + * it. A running drag ends without a write, so a peer's import is never + * overwritten. The selection drops without `nodeDeselect`: the old DOM is + * gone, and a blur there would commit a name edit. + */ + public drawReplaced() { + this.map.drag.cancel(); + this.map.draw.drawAll(); + this.selectedId = null; this.selectRootNode(); + this.map.zoom.center('position', 0); } /** - * Add a node like addNode. A local add under a protected parent adds - * nothing, announces the refusal and returns null. addNode itself stays - * unguarded because remote writes, paste and new trees call it. + * Draw the nodes a change added or updated, and drop the removed ones. An + * added or removed node also redraws its parent, whose hidden child nodes + * mark depends on its children. */ - public addNodeUnlessProtected = ( - ...args: Parameters - ): Node | null => { - const [, notifyWithEvent = true, , parentId] = args; - const parentNode = this.resolveParent(parentId); - if (parentNode && this.refusesLocalChange(parentNode, notifyWithEvent)) { - return null; + private drawChange({ added, updated, removed }: MapDataChange) { + const redraw = this.map.draw.removeNodes(removed); + for (const id of added) { + const parent = this.parentOf(id); + if (parent) redraw.push(parent); + } + this.map.draw.drawNodes([...added, ...updated, ...redraw]); + + const selected = this.selectedId; + if (selected === null) return; + if (!this.data.node(selected)) { + this.deselectNode(); + } else if (updated.includes(selected)) { + this.refreshRing(selected); } + } + + /** + * Draw the ring of the node again when its background changed: the ring + * darkens along with it. Any other update keeps the ring the node carries, + * which may be a highlight drawn after the selection. + */ + private refreshRing(id: string) { + const node = this.record(id); + if (!node || this.map.draw.ringOf(id) === null) return; - return this.addNode(...args); + const color = this.map.draw.ringColor(node); + if (color === this.selectionRing) return; + + this.selectionRing = color; + this.map.draw.setRing(id, color); + } + + /** + * The node with `id`, every attribute filled, read from the map data. + * @param {string} id + */ + public record = (id: string): ResolvedNode | undefined => { + const record = this.data.node(id); + return record ? resolveNode(record) : undefined; }; /** - * Add a node in the map. + * Every node, read in one scan of the map data, by id. For a user action + * only. + */ + public scan(): Map { + return new Map( + this.data.nodes().map(record => [record.id, resolveNode(record)]) + ); + } + + /** + * The id of the node's parent, or null for a root. A node whose parent + * the map data lacks counts as a root. + * @param {string} id + * @param {RecordLookup} lookup + */ + public parentOf(id: string, lookup: RecordLookup = this.record) { + const parent = lookup(id)?.parent; + return parent && lookup(parent) ? parent : null; + } + + /** + * The ids along the parents of the node, nearest first. A parent cycle + * stops at the node that closes it. + */ + private ancestors(id: string, lookup: RecordLookup): string[] { + const visited = new Set([id]); + const ancestors: string[] = []; + + for ( + let parent = this.parentOf(id, lookup); + parent !== null && !visited.has(parent); + parent = this.parentOf(parent, lookup) + ) { + visited.add(parent); + ancestors.push(parent); + } + return ancestors; + } + + /** + * The id of the root of the tree the node belongs to. + * @param {string} id + * @param {RecordLookup} lookup + */ + public treeRoot(id: string, lookup: RecordLookup = this.record): string { + return this.ancestors(id, lookup).pop() ?? id; + } + + /** + * The depth of the node in its tree: 1 for a root. + * @param {string} id + * @param {RecordLookup} lookup + */ + public level(id: string, lookup: RecordLookup = this.record): number { + return this.ancestors(id, lookup).length + 1; + } + + /** + * Tell whether the view state hides the node: one of its ancestors hides + * its child nodes. Without hidden child nodes no walk runs. + * @param {string} id + * @param {RecordLookup} lookup + */ + public isHidden(id: string, lookup: RecordLookup = this.record): boolean { + const viewState = this.map.viewState; + if (viewState.isEmpty()) return false; + + return this.ancestors(id, lookup).some(ancestor => + viewState.hidesChildren(ancestor) + ); + } + + /** + * The position mmp draws the node at: its drag preview, or the coordinates + * of its record. + * @param {string} id + * @param {RecordLookup} lookup + */ + public positionOf( + id: string, + lookup: RecordLookup = this.record + ): MapNodeCoordinates { + const position = this.map.draw.previewOf(id) ?? + lookup(id)?.coordinates ?? { x: 0, y: 0 }; + return { x: position.x, y: position.y }; + } + + /** + * Whether the node is drawn left of the root of its own tree. A root has + * no side and returns undefined. The drag preview counts, so a dragged + * node mirrors its descendants as it crosses its tree root. + * @param {string} id + * @param {RecordLookup} lookup + */ + public orientation( + id: string, + lookup: RecordLookup = this.record + ): boolean | undefined { + if (this.parentOf(id, lookup) === null) return undefined; + + const root = this.treeRoot(id, lookup); + return this.positionOf(id, lookup).x < this.positionOf(root, lookup).x; + } + + /** + * The children of the node, in the order of the map data. Scans every + * node once. + * @param {string} id + */ + public children(id: string): ResolvedNode[] { + return this.data + .nodes() + .filter(node => node.parent === id && node.id !== id) + .map(resolveNode); + } + + /** + * The other children of the node's parent, none for a root. Scans every + * node once. + * @param {string} id + */ + private siblings(id: string): ResolvedNode[] { + const parent = this.parentOf(id); + if (parent === null) return []; + + return this.children(parent).filter(node => node.id !== id); + } + + /** + * The ids of every node below the node. Scans every node once. + * @param {string} id + */ + public descendants(id: string): string[] { + return collectSubtreeIds(this.data.nodes(), id); + } + + /** + * Add a node to the map and return a copy of it. The node takes the + * defaults for every property the caller leaves out, and coordinates next + * to its siblings when the caller passes none. The method refuses a child + * of a protected branch: it announces the refusal and returns null. The + * map does not select the new node. * @param {UserNodeProperties} userProperties * @param {string | null} parentId the parent's id, null to add a root, or * undefined to add a child of the selected node @@ -130,91 +309,74 @@ export default class Nodes { */ public addNode = ( userProperties?: UserNodeProperties, - notifyWithEvent = true, - updateHistory = true, parentId?: string | null, overwriteId?: string - ): Node => { - const parentNode = this.resolveParent(parentId); - const properties: NodeProperties = Utils.mergeObjects( + ): ExportNodeProperties | null => { + const parent = this.resolveParent(parentId); + if (parent !== null && this.refusesChange(parent)) return null; + + const record = this.newRecord(userProperties, parent, overwriteId); + this.data.addNodes([record]); + + return this.exportNode(record.id); + }; + + /** + * The record of a new node under `parent`, or of a new root for null. + * `lookup` reads the parent and its tree, which may be records a paste has + * not written yet. + */ + public newRecord( + userProperties: UserNodeProperties | undefined, + parent: string | null, + id: string = uuidv4(), + lookup: RecordLookup = this.record + ): ResolvedNode { + const properties: UserNodeProperties = Utils.mergeObjects( this.map.options.defaultNode, userProperties, true - ) as NodeProperties; - - properties.id = overwriteId || uuidv4(); - properties.parent = parentNode; + ); // A root draws no branch. Its children fall through to the automatic // branch colors, as the main root's children do. - if (!parentNode && userProperties?.colors?.branch === undefined) { + if (parent === null && userProperties?.colors?.branch === undefined) { properties.colors = { ...properties.colors, branch: '' }; } - // A node added to a branch this person hid starts hidden itself, so a node - // somebody else creates there does not appear on its own. - if (parentNode && this.hidesChildNodes(parentNode)) { - properties.hidden = true; - } - - const node: Node = new Node(properties); - - this.nodes.set(properties.id, node); - - if ( - !properties.coordinates?.x && - !properties.coordinates?.y && - node.parent - ) { - node.coordinates = this.calculateCoordinates(node); - } - - this.map.draw.update(); + const record = resolveNode({ + ...properties, + id, + parent, + k: randomK(), + protected: false, + }); - if (updateHistory) { - this.map.history.save(); + if (!properties.coordinates?.x && !properties.coordinates?.y && parent) { + record.coordinates = this.calculateCoordinates(parent, lookup); } - - if (notifyWithEvent) - this.map.events.call( - Event.nodeCreate, - node.dom, - this.getNodeProperties(node) - ); - return node; - }; + return record; + } /** * The parent a node added through addNode gets: none for an explicit null, * the named node for an id, the selected node otherwise. Throws when the + * named parent is missing, such as one a peer just removed, and when the * caller names no parent and nothing is selected, because a new root node * would hide the caller's mistake. */ - private resolveParent(parentId: string | null | undefined): Node | null { + private resolveParent(parentId: string | null | undefined): string | null { if (parentId === null) return null; - if (parentId) return this.getNode(parentId) ?? null; - if (!this.selectedNode) Log.error('There is no selected node'); - - return this.selectedNode; - } - - /** - * Adds multiple nodes at once and saves one snapshot to history. A node - * with an empty parent becomes a root, whatever node is selected. - * @param {ExportNodeProperties[]} nodes - * @param {boolean} updateHistory - */ - public addNodes = (nodes: ExportNodeProperties[], updateHistory = true) => { - nodes.forEach(node => { - if (!this.existNode(node.id)) { - this.addNode(node, false, false, node.parent || null, node.id); + if (parentId) { + if (!this.data.node(parentId)) { + Log.error('There are no nodes with id "' + parentId + '"'); } - }); - - if (updateHistory) { - this.map.history.save(); + return parentId; } - }; + if (!this.selectedId) Log.error('There is no selected node'); + + return this.selectedId; + } /** * Select a node or return the current selected node, null when nothing is @@ -229,18 +391,17 @@ export default class Nodes { } if (!this.nodeSelectionTo(id)) { - const node = this.nodes.get(id); + const node = this.record(id); if (node) { - const background = node.getBackgroundDOM(); - - const color = this.ringColor(node); + const color = this.map.draw.ringColor(node); - if (color && background.style.stroke !== color) { - this.releaseSelection(node); + if (color && this.map.draw.ringOf(id) !== color) { + this.releaseSelection(id); - background.style.stroke = color; + this.selectionRing = color; + this.map.draw.setRing(id, color); - this.announceSelection(node); + this.announceSelection(id); } } else { Log.error('The node id or the direction is not correct'); @@ -248,7 +409,7 @@ export default class Nodes { } } - return this.selectedNode ? this.getNodeProperties(this.selectedNode) : null; + return this.getSelectedNode(); }; /** @@ -256,105 +417,68 @@ export default class Nodes { * every node a new DOM without the ring. */ public redrawSelectionRing() { - if (!this.selectedNode) return; - - const color = this.ringColor(this.selectedNode); - if (color) this.selectedNode.getBackgroundDOM().style.stroke = color; - } + const node = this.selectedId ? this.record(this.selectedId) : undefined; + if (!node) return; - /** - * The ring colour of a node: its background fill, darkened. Null when the - * fill holds no colour. - * @param {Node} node - * @returns {string | null} - */ - private ringColor(node: Node): string | null { - const fill = node.getBackgroundDOM().style.fill; - return d3.color(fill)?.darker(0.5).toString() ?? null; + const color = this.map.draw.ringColor(node); + this.selectionRing = color; + if (color) this.map.draw.setRing(node.id, color); } /** * Make the node the selected node and tell listeners it took the selection. - * @param {Node} node + * @param {string} id */ - private announceSelection(node: Node) { - this.selectedNode = node; - this.map.events.call( - Event.nodeSelect, - node.dom, - this.getNodeProperties(node) - ); + private announceSelection(id: string) { + this.selectedId = id; + const node = this.exportNode(id); + if (node) this.map.events.emit('nodeSelect', node); } /** * Clear the ring and the focus of the selected node, leave nothing * selected, and tell listeners the node lost the selection. `next` names * the node about to take the selection, or null for a deselect. - * @param {Node | null} next + * @param {string | null} next */ - private releaseSelection(next: Node | null) { - const previous = this.selectedNode; + private releaseSelection(next: string | null) { + const previous = this.selectedId; if (!previous) return; - previous.getBackgroundDOM().style.stroke = ''; + this.map.draw.setRing(previous, null); // Keep focus on the node the user is editing (#1249): on mobile, // d3-drag's `started` callback fires on the second tap that enters edit // mode and calls selectNode for the same node, which used to steal focus // from the just-focused contenteditable and stop the soft keyboard from // opening. - const prevName = previous.getNameDOM(); const editingSameNode = - previous === next && document.activeElement === prevName; + previous === next && this.map.draw.isEditing(previous); if (!editingSameNode) { Utils.removeAllRanges(); - prevName.blur(); + // The blur commits a running name edit of the node. + this.map.draw.blurName(previous); } - // The blur runs first: the name editor's onblur commits the name through - // updateNode without an id, which targets the selected node. - this.selectedNode = null; - this.map.events.call( - Event.nodeDeselect, - previous.dom, - this.getNodeProperties(previous) + this.selectedId = null; + // For a node the map data no longer holds, the event carries its id alone. + this.map.events.emit( + 'nodeDeselect', + this.exportNode(previous) ?? { id: previous, parent: null, k: 0 } ); } /** - * Highlighs node with a border + * Draw a ring in `color` around the node, as a peer's selection does. A + * peer picks its own color, so an invalid one draws nothing. * @param {string} id * @param {string} color - * @returns {void} */ - public highlightNodeWithColor = ( - id: string, - color: string, - notifyWithEvent = true - ): void => { - if (id !== undefined) { - if (typeof id !== 'string') { - Log.error('The node id must be a string', 'type'); - } - - const node = this.nodes.get(id); - if (node) { - const background = node.getBackgroundDOM(); + public highlightNodeWithColor = (id: string, color: string): void => { + if (!this.data.node(id)) Log.error('The node id is not correct'); + if (!v.is(CssColorSchema, color)) return; - if (background.style.stroke !== color) { - background.style.stroke = DOMPurify.sanitize(color); - - if (notifyWithEvent) - this.map.events.call( - Event.nodeUpdate, - node.dom, - this.getNodeProperties(node) - ); - } - } else { - Log.error('The node id is not correct'); - } - } + this.map.draw.setRing(id, color); }; /** @@ -366,10 +490,9 @@ export default class Nodes { if (id !== undefined) { if (typeof id !== 'string') { Log.error('The node id must be a string', 'type'); - return false; } - return this.nodes.has(id); + return this.data.node(id) !== undefined; } return false; }; @@ -378,54 +501,42 @@ export default class Nodes { * Enable the node name editing of the selected node. */ public editNode = () => { - if (this.selectedNode) { - this.map.draw.enableNodeNameEditing(this.selectedNode); + if (this.selectedId) { + this.map.draw.enableNodeNameEditing(this.selectedId); } }; /** - * Toggle (hide/show) all child nodes of selected node + * Hide the child nodes of the selected node, or show them again, in the + * view state, and redraw the branch. The toggle skips a node without child + * nodes, unless the view state already lists it. A node further down keeps + * its own child nodes hidden. Fires no event. */ public toggleBranchVisibility = () => { - if (!this.selectedNode) return; - - const children = this.getChildren(this.selectedNode); + const id = this.selectedId; + if (!id) return; - // One hidden child shows the whole branch, and children that all show hide - // it. Deciding once for the branch repairs children that disagree, which - // happens after somebody else adds a node to a branch hidden here. - this.selectedNode.hasHiddenChildNodes = - children.length > 0 && !children.some(x => x.hidden); - - this.applyHiddenStateToDescendants(this.selectedNode); - - this.map.draw.update(); - this.map.history.save(); - }; + const viewState = this.map.viewState; + if (!viewState.hidesChildren(id) && this.children(id).length === 0) { + return; + } - /** - * Set the hidden flag of every descendant from its parent, so a descendant - * hides whenever its parent hides its children. A branch this person hid - * further down stays hidden, because getDescendants returns each parent - * before its own children. - * @param {Node} node - */ - private applyHiddenStateToDescendants = (node: Node) => { - this.getDescendants(node).forEach(descendant => { - const parent = descendant.parent; - const hidden = parent ? this.hidesChildNodes(parent) : false; - this.updateNode('hidden', hidden, false, false, descendant.id); - }); + viewState.toggle(id); + this.map.draw.drawNodes([id, ...this.descendants(id)]); }; /** - * Tell whether the children of a node are hidden, which happens when the - * node itself is hidden or when this person hid its branch. - * @param {Node} node + * Tell whether the view state hides the child nodes of the node with `id`. + * Without an `id`, the method asks about the selected node and returns + * false when nothing is selected. A node without child nodes returns false + * and shows no hidden child nodes mark. + * @param {string} id * @returns {boolean} */ - private hidesChildNodes = (node: Node): boolean => - node.hidden || node.hasHiddenChildNodes; + public childNodesHidden = (id?: string): boolean => { + const node = this.getTargetNode(id); + return node ? this.map.draw.hidesDrawnChildren(node.id) : false; + }; /** * Deselect the current selected node, the main root included, and leave @@ -440,148 +551,106 @@ export default class Nodes { * id. Return null when the caller passes no id and nothing is selected. * Throw when `id` names no node. * @param {string} id - * @returns {Node | null} + * @returns {ResolvedNode | null} */ - public getTargetNode = (id?: string): Node | null => { + public getTargetNode = (id?: string): ResolvedNode | null => { if (id && typeof id !== 'string') { Log.error('The node id must be a string', 'type'); } - if (!id) return this.selectedNode; + const target = id || this.selectedId; + if (!target) return null; - const node = this.getNode(id); + const node = this.record(target); if (node === undefined) { - Log.error('There are no nodes with id "' + id + '"'); + Log.error('There are no nodes with id "' + target + '"'); } return node; }; /** - * Update the properties of the selected node. + * Write one property of the node with `id`, or of the selected node. The + * value must pass the shared schema. A protected branch refuses every + * property but `protected` itself, and an unchanged value writes nothing. */ public updateNode = ( property: NodeProperty | string, value: NodePropertyValue | unknown, - notifyWithEvent = true, - updateHistory = true, id?: string ) => { const node = this.getTargetNode(id); if (!node) return; - if (typeof property !== 'string') { - Log.error('The property must be a string', 'type'); + if (typeof property !== 'string' || !isNodeProperty(property)) { + Log.error('The property does not exist'); } - // Hiding and changing the protection itself stay allowed. - const guarded = property !== 'protected' && property !== 'hidden'; - if (guarded && this.refusesLocalChange(node, notifyWithEvent)) return; - - let updated: boolean | void = false; - const propertyPath = - PropertyMapping[property as keyof typeof PropertyMapping]; - const previousValue: unknown = propertyPath - ? Utils.get(node, propertyPath) - : undefined; - - switch (property) { - case 'name': - updated = this.updateNodeName(node, value as string); - break; - case 'protected': - updated = this.updateNodeProtected(node, value as boolean); - break; - case 'coordinates': - updated = this.updateNodeCoordinatesWithoutDescendants( - node, - value as MapNodeCoordinates - ); - break; - case 'imageSrc': - updated = this.updateNodeImageSrc(node, value as string); - break; - case 'imageSize': - updated = this.updateNodeImageSize(node, value as number); - break; - case 'linkHref': - updated = this.updateNodeLinkHref(node, value as string); - break; - case 'backgroundColor': - updated = this.updateNodeBackgroundColor(node, value as string); - break; - case 'branchColor': - updated = this.updateNodeBranchColor(node, value as string); - break; - case 'fontWeight': - updated = this.updateNodeFontWeight(node, value as string); - break; - case 'fontStyle': - updated = this.updateNodeFontStyle(node, value as string); - break; - case 'fontSize': - updated = this.updateNodeFontSize(node, value as number); - break; - case 'nameColor': - updated = this.updateNodeNameColor(node, value as string); - break; - case 'hidden': - updated = this.updateNodeHidden(node, value as boolean); - break; - default: - Log.error('The property does not exist'); - } - if (updated !== false && updateHistory) { - this.map.history.save(); - } + // Changing the protection itself stays allowed. + if (property !== 'protected' && this.refusesChange(node.id)) return; - if (updated !== false && notifyWithEvent) { - this.map.events.call(Event.nodeUpdate, node.dom, { - nodeProperties: this.getNodeProperties(node), - changedProperty: property, - previousValue, - }); - } + const previousValue = Utils.get(node, PropertyMapping[property]); + const nextValue = this.validatedValue(node, property, value); + if (Nodes.sameValue(previousValue, nextValue)) return; + + this.data.updateNode(node.id, property, nextValue); }; /** - * Remove the selected node. - * @param {string} id + * Check a new value of the property against the shared schema and the + * node, and return the value the node takes. An empty value clears a + * text property and leaves a number as it is. */ - public removeNode = (id?: string, notifyWithEvent = true) => { - const node = this.getTargetNode(id); - if (!node) return; + private validatedValue( + node: ResolvedNode, + property: NodeProperty, + value: unknown + ): unknown { + const result = v.safeParse(NodePropertySchemas[property], value); + if (!result.success) { + Log.error(`The value of ${property} is not valid`, 'type'); + } - if (this.refusesLocalRemoval(node, notifyWithEvent)) return; + if (property === 'imageSize' && node.image.src === '') { + Log.error('The node does not have an image'); + } - if (!node.isRoot) { - this.nodes.delete(node.id); + const previousValue = Utils.get(node, PropertyMapping[property]); + const nextValue = + result.output ?? (typeof previousValue === 'string' ? '' : previousValue); - this.getDescendants(node).forEach((node: Node) => { - this.nodes.delete(node.id); - }); + // A colors write carries the branch color with the other colors, + // unchanged, so a root accepts its own value without an error. + if ( + property === 'branchColor' && + this.parentOf(node.id) === null && + nextValue !== node.colors.branch + ) { + Log.error('A root node has no branches'); + } - this.map.draw.clear(); - this.map.draw.update(); + return nextValue; + } - this.map.history.save(); + private static sameValue(a: unknown, b: unknown): boolean { + if (Utils.isPureObjectType(a) && Utils.isPureObjectType(b)) { + return JSON.stringify(a) === JSON.stringify(b); + } + return a === b; + } - if (notifyWithEvent) - this.map.events.call( - Event.nodeRemove, - undefined, - this.getNodeProperties(node) - ); + /** + * Remove the node with `id`, or the selected node, with its descendants. + * The main root and a branch holding a protected node stay. + * @param {string} id + */ + public removeNode = (id?: string) => { + const node = this.getTargetNode(id); + if (!node) return; - // Deselect only when the removal deleted the selected node or one of - // its ancestors. - if (this.selectedNode && !this.nodes.has(this.selectedNode.id)) { - this.deselectNode(); - } else { - this.redrawSelectionRing(); - } - } else { - Log.error('The root node can not be deleted'); - } + if (this.refusesRemoval(node.id)) return; + if (node.isRoot) Log.error('The root node can not be deleted'); + + this.data.removeNode(node.id); }; /** @@ -592,90 +661,84 @@ export default class Nodes { * @returns {string | null} */ public protectingNode = (id?: string): string | null => { - const visited = new Set(); - let node = this.getTargetNode(id) ?? null; + const node = this.getTargetNode(id); + if (!node) return null; - while (node && !visited.has(node)) { - if (node.protected) return node.id; - visited.add(node); - node = node.parent; - } - return null; + return ( + [node.id, ...this.ancestors(node.id, this.record)].find( + candidate => this.record(candidate)?.protected + ) ?? null + ); }; /** * Tell whether the node or one of its ancestors carries the protection. - * @param {Node} node + * @param {string} id * @returns {boolean} */ - public isProtected(node: Node): boolean { - return this.protectingNode(node.id) !== null; + public isProtected(id: string): boolean { + return this.protectingNode(id) !== null; } /** - * Refuse a local change of the node when its branch is protected: announce - * the refusal and return true. A remote write arrives with notifyWithEvent - * false, and the method lets it through. - * @param {Node} node - * @param {boolean} notifyWithEvent + * Refuse a change of the node when its branch is protected: announce the + * refusal and return true. + * @param {string} id * @returns {boolean} */ - public refusesLocalChange(node: Node, notifyWithEvent = true): boolean { - if (!notifyWithEvent || !this.isProtected(node)) return false; + public refusesChange(id: string): boolean { + if (!this.isProtected(id)) return false; - this.refuseProtected(node); + this.refuseProtected(id); return true; } /** - * Refuse a local removal of the node when the node is protected or holds a + * Refuse a removal of the node when the node is protected or holds a * protected descendant, since the removal would delete a protected node. - * @param {Node} node - * @param {boolean} notifyWithEvent + * @param {string} id * @returns {boolean} */ - public refusesLocalRemoval(node: Node, notifyWithEvent = true): boolean { - if (!notifyWithEvent) return false; - if (this.refusesLocalChange(node)) return true; - if (!this.getDescendants(node).some(descendant => descendant.protected)) { - return false; - } + public refusesRemoval(id: string): boolean { + if (this.refusesChange(id)) return true; + + const records = this.scan(); + const holdsProtected = collectSubtreeIds([...records.values()], id).some( + descendant => records.get(descendant)?.protected + ); + if (!holdsProtected) return false; - this.refuseProtected(node); + this.refuseProtected(id); return true; } /** - * Announce that a protected branch refused a local edit of the node. - * @param {Node} node + * Announce that a protected branch refused an edit of the node. + * @param {string} id */ - public refuseProtected(node: Node) { - this.map.events.call( - Event.nodeProtected, - node.dom, - this.getNodeProperties(node) - ); + public refuseProtected(id: string) { + const node = this.exportNode(id); + if (node) this.map.events.emit('nodeProtected', node); } /** * Protect the node with `id`, or the selected node, and every node below - * it. The method clears the flag of every protected descendant, so each - * path from a root to a leaf holds at most one flag. A node that is - * already protected stays as it is. The caller wraps the call in one sync - * transaction when it needs peers to receive the writes together. + * it. The method sets `protected` to false on every protected descendant, + * so each path from a root to a leaf holds at most one protected node. A + * node that is already protected stays as it is. The writes form one batch. * @param {string} id */ public protectBranch = (id?: string) => { const node = this.getTargetNode(id); - if (!node || this.isProtected(node)) return; - - this.getDescendants(node) - .filter(descendant => descendant.protected) - .forEach(descendant => - this.updateNode('protected', false, true, false, descendant.id) - ); - this.updateNode('protected', true, true, false, node.id); - this.map.history.save(); + if (!node || this.isProtected(node.id)) return; + + const records = this.scan(); + this.data.batch(() => { + collectSubtreeIds([...records.values()], node.id) + .filter(descendant => records.get(descendant)?.protected) + .forEach(descendant => this.updateNode('protected', false, descendant)); + this.updateNode('protected', true, node.id); + }); }; /** @@ -687,11 +750,11 @@ export default class Nodes { const protecting = this.protectingNode(id); if (protecting === null) return; - this.updateNode('protected', false, true, true, protecting); + this.updateNode('protected', false, protecting); }; /** - * Return the children of the node. + * Return copies of the children of the node. * @param {string} id * @returns {ExportNodeProperties[]} */ @@ -699,36 +762,17 @@ export default class Nodes { const node = this.getTargetNode(id); if (!node) return []; - return this.getChildren(node).map((n: Node) => this.getNodeProperties(n)); + return this.children(node.id); }; /** - * Return the export properties of the node. - * @param {Node} node - * @param {boolean} fixedCoordinates - * @returns {ExportNodeProperties} properties + * Return a copy of the node with every attribute filled, which the caller + * may change, or null when the map data lacks the node. + * @param {string} id + * @returns {ExportNodeProperties | null} */ - public getNodeProperties( - node: Node, - fixedCoordinates = false - ): ExportNodeProperties { - return { - id: node.id, - parent: node.parent ? node.parent.id : '', - name: node.name, - coordinates: fixedCoordinates - ? this.fixCoordinates(node.coordinates, true) - : (Utils.cloneObject(node.coordinates) as MapNodeCoordinates), - image: Utils.cloneObject(node.image) as MapNodeImage, - colors: Utils.cloneObject(node.colors) as MapNodeColors, - font: Utils.cloneObject(node.font) as MapNodeFont, - link: Utils.cloneObject(node.link) as MapNodeLink, - protected: node.protected, - isRoot: node.isRoot, - hidden: node.hidden, - hasHiddenChildNodes: node.hasHiddenChildNodes, - k: node.k, - }; + public exportNode(id: string): ExportNodeProperties | null { + return this.record(id) ?? null; } /** @@ -777,7 +821,7 @@ export default class Nodes { */ private nodeSelectionTo(direction: string): boolean { // Arrow keys move no selection while nothing is selected. - const selected = this.selectedNode; + const selected = this.selectedId ? this.record(this.selectedId) : null; switch (direction) { case 'up': @@ -795,49 +839,6 @@ export default class Nodes { } } - /** - * Return the children of a node. - * @param {Node} node - * @returns {Node[]} - */ - public getChildren(node: Node): Node[] { - return Array.from(this.nodes.values()).filter((n: Node) => { - return n.parent && n.parent.id === node.id; - }); - } - - /** - * Return whether a node is left of the root of its own tree (true if left). - * A root has no side and returns undefined. - * @return {boolean} - */ - public getOrientation(node: Node): boolean | undefined { - if (!node.parent) { - return; - } - - const root = this.getTreeRoot(node); - - return (node.coordinates?.x ?? 0) < (root.coordinates?.x ?? 0); - } - - /** - * Return the root of the tree a node belongs to: the ancestor with no - * parent. A cycle of ancestors stops at the node that closes it. - * @returns {Node} root - */ - public getTreeRoot(node: Node): Node { - const visited = new Set([node]); - let current = node; - - while (current.parent && !visited.has(current.parent)) { - current = current.parent; - visited.add(current); - } - - return current; - } - /** * Returns the coordinates for the root of a new tree whose nodes take * `footprint`, given relative to the root. The search starts in the middle @@ -864,7 +865,13 @@ export default class Nodes { x: (view.minX + view.maxX) / 2, y: (view.minY + view.maxY) / 2, }; - const trees = treeBounds(this.getNodes(), node => this.getTreeRoot(node)); + const records = this.scan(); + const lookup = lookupIn(records); + const trees = treeBounds( + [...records.values()], + node => records.get(this.treeRoot(node.id, lookup)) ?? node, + this.boundsOf + ); return findClearSpot(start, footprint, trees, NEW_TREE_GAP); }; @@ -872,189 +879,102 @@ export default class Nodes { /** * Add the root of a new tree at `newTreeCoordinates`, then select it and * pan the view the shortest distance that shows it. The root has no parent - * and no main-root mark. The frontend's nodeCreate handler may select the - * root already. addTree selects it anyway, so the mmp API does not depend - * on that handler. - * @returns {Node} the new root + * and its isRoot attribute is false. + * @returns {ExportNodeProperties | null} a copy of the new root */ - public addTree = (): Node => { + public addTree = (): ExportNodeProperties | null => { const root = this.addNode( { name: '', coordinates: this.newTreeCoordinates() }, - true, - true, null ); + if (!root) return null; + this.selectNode(root.id); - this.map.zoom.panIntoView(nodeBounds(root)); + this.map.zoom.panIntoView(this.boundsOf(resolveNode(root))); return root; }; private rightOfEveryTree(): MapNodeCoordinates { - const rightEdge = this.getNodes().reduce( - (edge, node) => - Math.max(edge, node.coordinates.x + node.dimensions.width / 2), + const rightEdge = [...this.scan().values()].reduce( + (edge, node) => Math.max(edge, this.boundsOf(node).maxX), -Infinity ); return { x: rightEdge + 2 * NODE_HORIZONTAL_SPACING, - y: this.getRoot().coordinates.y, + y: this.mainRoot()?.coordinates.y ?? 0, }; } /** - * Return all descendants of a node. - * @returns {Node[]} nodes - */ - public getDescendants(node: Node): Node[] { - let nodes: Node[] = []; - this.getChildren(node).forEach((node: Node) => { - nodes.push(node); - nodes = nodes.concat(this.getDescendants(node)); - }); - return nodes; - } - - /** - * Return an array of all nodes. - */ - public getNodes(): Node[] { - return Array.from(this.nodes.values()); - } - - /** - * Return the root parameters - */ - public exportRootProperties = (): ExportNodeProperties => { - return this.getNodeProperties(this.getRoot()); - }; - - /** - * Set a node as a id-value copy. - */ - public setNode(key: string, node: Node) { - this.nodes.set(key, node); - } - - /** - * Return the current selected node, or null when nothing is selected. - * @returns {Node | null} + * Return a copy of the selected node, or null when nothing is selected. + * @returns {ExportNodeProperties | null} */ - public getSelectedNode = (): Node | null => { - return this.selectedNode; + public getSelectedNode = (): ExportNodeProperties | null => { + return this.selectedId ? this.exportNode(this.selectedId) : null; }; /** - * Select the main root: draw its ring and fire `nodeSelect`. + * Select the main root: draw its ring and fire `nodeSelect`. A map + * without a main root selects nothing. */ public selectRootNode() { - // A full draw replaces every node object. Drop a selected node the map no - // longer holds and fire no deselect: its DOM is detached, and a blur there - // would commit a name edit. - const selected = this.selectedNode; - if (selected && this.nodes.get(selected.id) !== selected) { - this.selectedNode = null; - } + const root = this.data.mainRootId(); + if (root === null) return; - const root = this.getRoot(); - this.selectNode(root.id); + this.selectNode(root); // selectNode draws no ring on a main root without a background colour, // and the main root still takes the selection. - if (this.selectedNode !== root) { + if (this.selectedId !== root) { this.releaseSelection(root); this.announceSelection(root); } } /** - * Delete all nodes. - */ - public clear() { - this.nodes.clear(); - } - - /** - * Return the root node. - * @returns {Node} rootNode - */ - public getRoot = (): Node => { - const root = this.nodes.get(this.map.rootId); - - if (root === undefined) { - Log.error('The map has no root node'); - } - - return root; - }; - - /** - * Return the node with the id equal to id passed as parameter. - * @param {string} id - * @returns {Node | undefined} - */ - public getNode = (id: string): Node | undefined => { - if (id !== undefined) { - if (typeof id !== 'string') { - Log.error('The node id must be a string', 'type'); - return undefined; - } - return this.nodes.get(id); - } - return undefined; - }; - - /** - * Return the siblings of a node. - * @param {Node} node - * @returns {Array} siblings + * Return the main root, or null for a map without one. + * @returns {ResolvedNode | null} */ - private getSiblings(node: Node): Node[] { - if (!node.parent) { - return []; - } - - const parentChildren: Node[] = this.getChildren(node.parent); - - if (parentChildren.length > 1) { - parentChildren.splice(parentChildren.indexOf(node), 1); - return parentChildren; - } - - return []; + public mainRoot(): ResolvedNode | null { + const id = this.data.mainRootId(); + return id === null ? null : (this.record(id) ?? null); } /** - * Where a node added interactively goes: one column out from its parent and - * below its lowest sibling. + * Where a node added interactively under `parent` goes: one column out + * from its parent and below its lowest sibling. */ - private calculateCoordinates(node: Node): MapNodeCoordinates { - const parent = node.parent; - const anchorX = parent?.coordinates?.x ?? node.coordinates?.x ?? 0; - const anchorY = parent?.coordinates?.y ?? node.coordinates?.y ?? 0; - const { column, siblings } = this.pickColumn(node); + private calculateCoordinates( + parent: string, + lookup: RecordLookup + ): MapNodeCoordinates { + const anchor = lookup(parent)?.coordinates ?? { x: 0, y: 0 }; + const { column, siblings } = this.pickColumn(parent, lookup); - return { x: anchorX + column, y: this.stackBelow(anchorY, siblings) }; + return { x: anchor.x + column, y: this.stackBelow(anchor.y, siblings) }; } /** - * The column a new node lands in, as an offset from its parent, plus the - * siblings sharing that column. A child of a root takes the side of its - * tree that currently holds fewer siblings. + * The column mmp places a new node under `parent` in, as an offset from + * the parent, plus the siblings sharing that column. A child of a root takes + * the side of its tree that holds fewer siblings. */ - private pickColumn(node: Node): { column: number; siblings: Node[] } { - const siblings = this.getSiblings(node); - const parent = node.parent; + private pickColumn( + parent: string, + lookup: RecordLookup + ): { column: number; siblings: ResolvedNode[] } { + const siblings = this.children(parent); - if (parent && !parent.parent) { + if (this.parentOf(parent, lookup) === null) { const [left, right] = this.splitByOrientation(siblings); return left.length <= right.length ? { column: -NODE_HORIZONTAL_SPACING, siblings: left } : { column: NODE_HORIZONTAL_SPACING, siblings: right }; } - const goesLeft = !!parent && this.getOrientation(parent); + const goesLeft = !!this.orientation(parent, lookup); const column = goesLeft ? -NODE_HORIZONTAL_SPACING : NODE_HORIZONTAL_SPACING; @@ -1062,22 +982,24 @@ export default class Nodes { return { column, siblings }; } - private splitByOrientation(siblings: Node[]): [Node[], Node[]] { - const left: Node[] = []; - const right: Node[] = []; + private splitByOrientation( + siblings: ResolvedNode[] + ): [ResolvedNode[], ResolvedNode[]] { + const left: ResolvedNode[] = []; + const right: ResolvedNode[] = []; for (const sibling of siblings) { - (this.getOrientation(sibling) ? left : right).push(sibling); + (this.orientation(sibling.id) ? left : right).push(sibling); } return [left, right]; } /** Below the lowest sibling, or just above the parent when there is none. */ - private stackBelow(anchorY: number, siblings: Node[]): number { - if (siblings.length > 0) { - const lowerNode = this.getLowerNode(siblings); - return (lowerNode?.coordinates?.y ?? 0) + NODE_VERTICAL_SIBLING_OFFSET; + private stackBelow(anchorY: number, siblings: ResolvedNode[]): number { + const lowerNode = Nodes.lowerNode(siblings); + if (lowerNode) { + return lowerNode.coordinates.y + NODE_VERTICAL_SIBLING_OFFSET; } return anchorY - NODE_VERTICAL_SPACING; @@ -1096,7 +1018,7 @@ export default class Nodes { ): MapSnapshot => { if (mapSnapshot.every(node => !!node.coordinates)) return mapSnapshot; - const layout = computeMapLayout(mapSnapshot); + const layout = computeMapLayout(mapSnapshot, this.map.draw.estimateExtent); return mapSnapshot.map(node => { const position = layout.get(node.id); @@ -1110,502 +1032,139 @@ export default class Nodes { /** * Recompute every node's coordinates from the tree structure and the node - * sizes, discarding manual positioning. One mmp history entry covers the - * whole rewrite; the undo the user actually sees comes from the Y.Doc - * transaction that the distribute event triggers. - */ - public distributeNodes = (notifyWithEvent = true) => { - const layout = computeMapLayout(this.toLayoutInput()); + * sizes, discarding manual positioning. The writes form one batch, so one + * undo reverts the whole rewrite. + */ + public distributeNodes = () => { + const records = this.scan(); + const layout = computeMapLayout( + [...records.values()].map(node => this.toLayoutInput(node)), + this.map.draw.estimateExtent + ); if (layout.size === 0) return; - for (const [id, coordinates] of layout) { - this.moveNodeTo(id, coordinates); - } - - // Redrawing the branches costs a full selection pass, so it happens once - // here rather than once per node as the single-node move path does. - this.redrawBranches(); - this.map.draw.update(); - this.map.history.save(); - - if (notifyWithEvent) { - this.map.events.call(Event.distribute); - } + this.writePositions(layout); }; - /** Move one node, leaving the branch redraw to the caller. */ - private moveNodeTo(id: string, coordinates: MapNodeCoordinates): void { - const node = this.nodes.get(id); - if (!node) return; - - node.coordinates = { x: coordinates.x, y: coordinates.y }; - node.dom?.setAttribute( - 'transform', - 'translate(' + [coordinates.x, coordinates.y] + ')' - ); - } - - private redrawBranches(): void { - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => { - // A root node has no parent and so no branch to draw. Returning - // null makes d3 drop the attribute, as the other redraw paths do. - const branch = this.map.draw.drawBranch(node); + /** + * Write the coordinates of the given nodes in one batch, and only those + * that differ from the stored ones, so one undo reverts them all. + * @param {Map} positions + */ + public writePositions(positions: Map) { + this.data.batch(() => { + for (const [id, { x, y }] of positions) { + const current = this.record(id)?.coordinates; + if (!current || (current.x === x && current.y === y)) continue; - return branch ? branch.toString() : null; + this.data.updateNode(id, 'coordinates', { x, y }); } - ); + }); } - private toLayoutInput(): LayoutInputNode[] { - return Array.from(this.nodes.values()).map(node => ({ + /** + * The bounding box of the node as it is drawn, or as it will be drawn + * before the renderer has measured it. + * @param {ResolvedNode} node + */ + public boundsOf = (node: ResolvedNode): Bounds => + nodeBounds({ + coordinates: node.coordinates, + dimensions: this.map.draw.dimensionsOf(node), + }); + + private toLayoutInput(node: ResolvedNode): LayoutInputNode { + return { id: node.id, - parent: node.parent ? node.parent.id : '', + parent: node.parent, isRoot: node.isRoot, name: node.name, font: node.font, coordinates: node.coordinates, - dimensions: node.dimensions, - })); + dimensions: this.map.draw.dimensionsOf(node), + }; } /** - * Return the lower node of a list of nodes. - * @param {Node[]} nodes - * @returns {Node} lowerNode + * Return the lowest node of a list of nodes. + * @param {ResolvedNode[]} nodes + * @returns {ResolvedNode | undefined} lowerNode */ - private getLowerNode(nodes: Node[]): Node | undefined { - if (nodes.length === 0) { - return; - } - - return nodes.reduce((lowest, current) => { - const lowestY = lowest.coordinates?.y ?? 0; - const currentY = current.coordinates?.y ?? 0; + private static lowerNode(nodes: ResolvedNode[]): ResolvedNode | undefined { + if (nodes.length === 0) return; - return currentY > lowestY ? current : lowest; - }, nodes[0]); + return nodes.reduce( + (lowest, current) => + current.coordinates.y > lowest.coordinates.y ? current : lowest, + nodes[0] + ); } - /** - * Update the node name with a new value. - * @param {Node} node - * @param {string} name - * @returns {boolean} - */ - private updateNodeName = (node: Node, name: string): boolean => { - if (name && typeof name !== 'string') { - Log.error('The name must be a string', 'type'); - } - - if (node.name != name) { - node.getNameDOM().innerHTML = DOMPurify.sanitize(name); - - this.map.draw.updateNodeShapes(node); - - node.name = name; - return true; - } else { - return false; - } - }; - - /** - * Update the node coordinates with a new value. - * The main method for moving nodes is located inside the drag module. - * This method acts as a more simpler way of just moving one node. - * @param {Node} node - * @param {MapNodeCoordinates} coordinates - * @returns {boolean} - */ - private updateNodeCoordinatesWithoutDescendants = ( - initialNode: Node, - coordinates: MapNodeCoordinates - ): boolean => { - // no moving of descendants here - const fixedCoordinates = coordinates; - - coordinates = Utils.mergeObjects( - initialNode.coordinates, - fixedCoordinates, - true - ) as MapNodeCoordinates; - - if (!( - coordinates.x === initialNode.coordinates.x && - coordinates.y === initialNode.coordinates.y - )) { - initialNode.coordinates = Utils.cloneObject( - coordinates - ) as MapNodeCoordinates; - initialNode.dom.setAttribute( - 'transform', - 'translate(' + [coordinates.x, coordinates.y] + ')' - ); - - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => { - const branch = this.map.draw.drawBranch(node); - return branch ? branch.toString() : null; - } - ); - - return true; - } else { - return false; - } - }; - - /** - * Update the node background color with a new value. - * @param {Node} node - * @param {string} color - * @returns {boolean} - */ - private updateNodeBackgroundColor = (node: Node, color: string): boolean => { - if (color && typeof color !== 'string') { - Log.error('The background color must be a string', 'type'); - } - - const sanitizedColor = DOMPurify.sanitize(color); - - if (node.colors.background !== color) { - const background = node.getBackgroundDOM(); - - background.style.fill = sanitizedColor; - - if (background.style.stroke !== '') { - const darker = d3.color(sanitizedColor)?.darker(0.5); - if (darker) { - background.style.stroke = darker.toString(); - } - } - - node.colors.background = sanitizedColor; - return true; - } else { - return false; - } - }; - - /** - * Update the node text color with a new value. - * @param {Node} node - * @param {string} color - * @returns {boolean} - */ - private updateNodeNameColor = (node: Node, color: string): boolean => { - if (color && typeof color !== 'string') { - Log.error('The text color must be a string', 'type'); - } - - const sanitizedColor = DOMPurify.sanitize(color); - - if (node.colors.name !== color) { - node.getNameDOM().style.color = sanitizedColor; - - node.colors.name = sanitizedColor; - this.map.draw.updateProtectionIcon(node); - return true; - } else { - return false; - } - }; - - /** - * Update the node branch color with a new value. - * @param {Node} node - * @param {string} color - * @returns {boolean} - */ - private updateNodeBranchColor = (node: Node, color: string): boolean => { - if (color && typeof color !== 'string') { - Log.error('The branch color must be a string', 'type'); - } - - const sanitizedColor = DOMPurify.sanitize(color); - - if (node.parent) { - if (node.colors.name !== color) { - const branch = document.getElementById(node.id + '_branch'); - - if (branch) { - branch.style.fill = branch.style.stroke = sanitizedColor; - } - - node.colors.branch = sanitizedColor; - return true; - } else { - return false; - } - } else if (node.colors.branch === sanitizedColor) { - // A remote colors sync sends the branch color with the other colors, - // unchanged, so a root accepts its own value without an error. - return false; - } else { - Log.error('A root node has no branches'); - } - }; - - /** - * Update the node font size with a new value. - * @param {Node} node - * @param {number} size - * @returns {boolean} - */ - private updateNodeFontSize = (node: Node, size: number): boolean => { - if (size && typeof size !== 'number') { - Log.error('The font size must be a number', 'type'); - } - - if (node.font.size != size) { - node.getNameDOM().style.fontSize = size + 'px'; - - this.map.draw.updateNodeShapes(node); - - node.font.size = size; - return true; - } else { - return false; - } - }; - - /** - * Update the node image size with a new value. - * @param {Node} node - * @param {number} size - * @returns {boolean} - */ - private updateNodeImageSize = (node: Node, size: number): boolean => { - if (size && typeof size !== 'number') { - Log.error('The image size must be a number', 'type'); - } - - if (node.image.src !== '') { - if (node.image.size !== size) { - // An image that failed to load has no element; keep its value and - // its new size so the Y.Doc still holds both. - const image = node.dom.querySelector('image'); - if (!image) { - node.image.size = size; - return true; - } - const box = image.getBBox(), - height = size, - width = (box.width * height) / box.height, - y = -(height + node.dimensions.height / 2 + 5), - x = -width / 2; - - image.setAttribute('height', height.toString()); - image.setAttribute('width', width.toString()); - image.setAttribute('y', y.toString()); - image.setAttribute('x', x.toString()); - - node.image.size = height; - return true; - } else { - return false; - } - } else { - Log.error('The node does not have an image'); - return false; - } - }; - - /** - * Update the node image src with a new value. - * @param {Node} node - * @param {string} src - * @returns {boolean} - */ - private updateNodeImageSrc = (node: Node, src: string): boolean => { - if (src && typeof src !== 'string') { - Log.error('The image path must be a string', 'type'); - } - - if (node.image.src !== src) { - node.image.src = src; - - this.map.draw.setImage(node); - return true; - } else { - return false; - } - }; - - /** - * Update the node link href with a new value. - * @param {Node} node - * @param {string} href - * @returns {boolean} - */ - private updateNodeLinkHref = (node: Node, href: string): boolean => { - if (href && typeof href !== 'string') { - Log.error('The link href must be a string', 'type'); - } - - if (node.link.href !== href) { - node.link.href = href; - - this.map.draw.setLink(node); - return true; - } else { - return false; - } - }; - - /** - * Update the node hidden value - * @param {Node} node - * @param {boolean} hidden - * @returns {boolean} - */ - private updateNodeHidden = (node: Node, hidden: boolean): boolean => { - if (hidden && typeof hidden !== 'boolean') { - Log.error('The hidden value must be boolean', 'type'); - } - - if (node.hidden !== hidden) { - node.hidden = hidden; - return true; - } else { - return false; - } - }; - - /** - * Update the node font style. - * @param {Node} node - * @param {string} style - * @returns {boolean} - */ - private updateNodeFontStyle = (node: Node, style: string): boolean => { - if (style && typeof style !== 'string') { - Log.error('The font style must be a string', 'type'); - } - - if (node.font.style !== style) { - node.getNameDOM().style.fontStyle = DOMPurify.sanitize(style); - - node.font.style = style; - return true; - } else { - return false; - } - }; - - /** - * Update the node font weight. - * @param {Node} node - * @param {string} weight - * @returns {boolean} - */ - private updateNodeFontWeight = (node: Node, weight: string): boolean => { - if (weight && typeof weight !== 'string') { - Log.error('The font weight must be a string', 'type'); - } - - if (node.font.weight !== weight) { - node.getNameDOM().style.fontWeight = DOMPurify.sanitize(weight); - - this.map.draw.updateNodeShapes(node); - - node.font.weight = weight; - return true; - } else { - return false; - } - }; - - /** - * Update the protection flag of the node and redraw its lock badge. - * @param {Node} node - * @param {boolean} flag - * @returns {boolean} - */ - private updateNodeProtected = (node: Node, flag: boolean): boolean => { - if (typeof flag !== 'boolean') { - Log.error('The protected value must be a boolean', 'type'); - } - - if (node.protected === flag) return false; - - node.protected = flag; - this.map.draw.updateProtectionIcon(node); - return true; - }; - /** * Move the node selection on the level of the selected node (true: up). - * @param {Node} selected + * @param {ResolvedNode} selected * @param {boolean} direction */ - private moveSelectionOnLevel(selected: Node, direction: boolean) { - const parent = selected.parent; + private moveSelectionOnLevel(selected: ResolvedNode, direction: boolean) { + const parent = this.parentOf(selected.id); + if (parent === null) return; - if (parent) { - let siblings = this.getSiblings(selected).filter((node: Node) => { - return direction === node.coordinates.y < selected.coordinates.y; - }); + let siblings = this.siblings(selected.id).filter(node => { + return direction === node.coordinates.y < selected.coordinates.y; + }); - if (!parent.parent) { - siblings = siblings.filter((node: Node) => { - return this.getOrientation(node) === this.getOrientation(selected); - }); - } + if (this.parentOf(parent) === null) { + const side = this.orientation(selected.id); + siblings = siblings.filter(node => this.orientation(node.id) === side); + } - if (siblings.length > 0) { - let closerNode: Node = siblings[0], - tmp = Math.abs(siblings[0].coordinates.y - selected.coordinates.y); + if (siblings.length === 0) return; - for (const node of siblings) { - const distance = Math.abs( - node.coordinates.y - selected.coordinates.y - ); + let closerNode = siblings[0], + tmp = Math.abs(siblings[0].coordinates.y - selected.coordinates.y); - if (distance < tmp) { - tmp = distance; - closerNode = node; - } - } + for (const node of siblings) { + const distance = Math.abs(node.coordinates.y - selected.coordinates.y); - this.selectNode(closerNode.id); + if (distance < tmp) { + tmp = distance; + closerNode = node; } } + + this.selectNode(closerNode.id); } /** * Move the node selection in a child node or in the parent node (true: left) - * @param {Node} selected + * @param {ResolvedNode} selected * @param {boolean} direction */ - private moveSelectionOnBranch(selected: Node, direction: boolean) { - const orientation = this.getOrientation(selected); - const parent = selected.parent; + private moveSelectionOnBranch(selected: ResolvedNode, direction: boolean) { + const orientation = this.orientation(selected.id); + const parent = this.parentOf(selected.id); const movesToParent = (!orientation && direction) || (orientation && !direction); // A root has no parent and no orientation, so it always moves to a child // on the requested side. if (movesToParent && parent) { - this.selectNode(parent.id); + this.selectNode(parent); return; } - let children = this.getChildren(selected); + let children = this.children(selected.id); if (orientation === undefined) { // The selected node is a root - children = children.filter((node: Node) => { - return this.getOrientation(node) === direction; + children = children.filter(node => { + return this.orientation(node.id) === direction; }); } - const lowerNode = this.getLowerNode(children); + const lowerNode = Nodes.lowerNode(children); - if (children.length > 0 && lowerNode) { + if (lowerNode) { this.selectNode(lowerNode.id); } } diff --git a/packages/mmp/src/map/handlers/paste-coordinates.spec.ts b/packages/mmp/src/map/handlers/paste-coordinates.spec.ts index 9ca9e31f..c473123d 100644 --- a/packages/mmp/src/map/handlers/paste-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/paste-coordinates.spec.ts @@ -1,10 +1,9 @@ -import CopyPaste from './copy-paste.js'; -import Nodes from './nodes.js'; -import MmpMap from '../map.js'; -import Node, { NodeProperties } from '../models/node.js'; +import type { RecordLookup } from './nodes.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; import type { ExportNodeProperties, MapNodeCoordinates, + MapSnapshot, } from '@teammapper/shared'; /** @@ -14,16 +13,12 @@ import type { * its new parent, and the children of that pasted root keep the sides they had. */ -const ROOT = new Node({ - id: 'root', - parent: null, - k: 1, - isRoot: true, - coordinates: { x: 0, y: 0 }, -}); +const ROOT = nodeRecord({ id: 'root', isRoot: true }); -function makeNode(properties: Partial & { id: string }): Node { - return new Node({ k: 1, parent: ROOT, ...properties }); +function makeNode( + properties: Partial & { id: string } +): ExportNodeProperties { + return nodeRecord({ parent: 'root', ...properties }); } function copied( @@ -40,39 +35,33 @@ interface CopyPasteInternals { copiedTreeRootX: number; calculatePastedCoordinates( nodeProperties: ExportNodeProperties, - newParentNode: Node + newParent: string, + lookup: RecordLookup ): MapNodeCoordinates; } -/** A CopyPaste on a map holding ROOT and the live nodes the test places. */ -function makeHandler(liveNodes: Node[]): { - handler: CopyPasteInternals; - nodes: Nodes; -} { - const map = { rootId: ROOT.id } as unknown as MmpMap; - const nodes = new Nodes(map); - map.nodes = nodes; - [ROOT, ...liveNodes].forEach(node => nodes.setNode(node.id, node)); - // No zoom transform applies in these tests, so this is the identity. - nodes.fixCoordinates = (coordinates: MapNodeCoordinates) => coordinates; - - const handler = new CopyPaste(map) as unknown as CopyPasteInternals; - - return { handler, nodes }; +/** A CopyPaste on a map holding ROOT and the nodes the test places. */ +function makeHandler(liveNodes: MapSnapshot) { + const stub = stubMap([ROOT, ...liveNodes]); + const handler = stub.map.copyPaste as unknown as CopyPasteInternals; + + return { handler, ...stub }; } function placementOf( pasted: ExportNodeProperties, oldParent: ExportNodeProperties, - newParent: Node, - liveNodes: Node[] = [newParent], - oldTreeRootX = ROOT.coordinates.x + newParent: ExportNodeProperties, + liveNodes: MapSnapshot = [newParent], + oldTreeRootX = 0 ): MapNodeCoordinates { - const { handler } = makeHandler(liveNodes); + const { handler, nodes } = makeHandler( + newParent.id === 'root' ? [] : liveNodes + ); handler.copiedNodes = [oldParent, pasted]; handler.copiedTreeRootX = oldTreeRootX; - return handler.calculatePastedCoordinates(pasted, newParent); + return handler.calculatePastedCoordinates(pasted, newParent.id, nodes.record); } describe('calculatePastedCoordinates', () => { @@ -115,27 +104,23 @@ describe('calculatePastedCoordinates', () => { }); describe('with a second tree right of the main tree', () => { - const secondRoot = new Node({ + const secondRoot = nodeRecord({ id: 'second', - parent: null, - k: 1, coordinates: { x: 1000, y: 0 }, }); - const oldParentNode = new Node({ + const oldParentNode = nodeRecord({ id: 'old', - parent: secondRoot, - k: 1, + parent: 'second', coordinates: { x: 800, y: 0 }, }); - const childNode = new Node({ + const childNode = nodeRecord({ id: 'child', - parent: oldParentNode, - k: 1, + parent: 'old', coordinates: { x: 700, y: 50 }, }); const oldParent = copied('old', 'second', { x: 800, y: 0 }); const pasted = copied('child', 'old', { x: 700, y: 50 }); - const secondTreeRootX = secondRoot.coordinates.x; + const secondTreeRootX = 1000; it('reads the old side against the root of the old tree', () => { const newParent = makeNode({ @@ -156,10 +141,9 @@ describe('calculatePastedCoordinates', () => { it('reads the new side against the root of the new tree', () => { // Left of the second root, and right of the main root. - const newParent = new Node({ + const newParent = nodeRecord({ id: 'new', - parent: secondRoot, - k: 1, + parent: 'second', coordinates: { x: 800, y: 100 }, }); @@ -179,19 +163,20 @@ describe('calculatePastedCoordinates', () => { id: 'new', coordinates: { x: -400, y: 100 }, }); - const { handler, nodes } = makeHandler([ + const { handler, data, nodes } = makeHandler([ newParent, secondRoot, oldParentNode, childNode, ]); - handler.copy(oldParentNode.id); - nodes.clear(); - [ROOT, newParent].forEach(node => nodes.setNode(node.id, node)); + handler.copy('old'); + data.removeNode('second'); expect(handler.copiedTreeRootX).toBe(secondTreeRootX); - expect(handler.calculatePastedCoordinates(pasted, newParent)).toEqual({ + expect( + handler.calculatePastedCoordinates(pasted, 'new', nodes.record) + ).toEqual({ x: -500, y: 150, }); diff --git a/packages/mmp/src/map/handlers/root-selection.spec.ts b/packages/mmp/src/map/handlers/root-selection.spec.ts index f97c67be..1c8d3d69 100644 --- a/packages/mmp/src/map/handlers/root-selection.spec.ts +++ b/packages/mmp/src/map/handlers/root-selection.spec.ts @@ -1,205 +1,134 @@ -import * as d3 from 'd3'; -import Nodes from './nodes.js'; -import History from './history.js'; -import MmpMap from '../map.js'; -import { Event } from './events.js'; -import Options, { - DefaultNodeValues, - DefaultRootNodeValues, -} from '../options.js'; -import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; - -const SVG_NS = 'http://www.w3.org/2000/svg'; - -/** The parts of a node's DOM that selection reads: the background and name. */ -function nodeDom(fill: string): SVGGElement { - const group = document.createElementNS(SVG_NS, 'g'); - const background = document.createElementNS(SVG_NS, 'path'); - background.style.fill = fill; - const foreignObject = document.createElementNS(SVG_NS, 'foreignObject'); - foreignObject.appendChild(document.createElement('div')); - group.append(background, foreignObject); - return group; -} - -/** - * A map stub around the real node handler and history. Its draw gives each - * node a DOM filled with the node's background colour. - */ -function makeMap() { - const events = { call: jest.fn() }; - const update = jest.fn(() => { - for (const node of map.nodes.getNodes()) { - node.dom = nodeDom(node.colors.background); - } - }); - const map = { - rootId: '', - options: { - defaultNode: DefaultNodeValues, - rootNode: DefaultRootNodeValues, - }, - draw: { clear: jest.fn(), update }, - zoom: { center: jest.fn() }, - events, - export: { asJSON: () => [] }, - } as unknown as MmpMap; - map.nodes = new Nodes(map); - map.history = new History(map); - return { map, events }; -} - -function node( - id: string, - parent: string, - overrides: Partial = {} -): ExportNodeProperties { - return { - ...DefaultNodeValues, - colors: { ...DefaultNodeValues.colors }, - id, - parent, - k: 1, - ...overrides, - } as ExportNodeProperties; -} +import Options, { DefaultRootNodeValues } from '../options.js'; +import type MmpMap from '../map.js'; +import { firedEvents, nodeRecord, ring, stubMap } from '../../test/stub-map.js'; +import type { MapSnapshot } from '@teammapper/shared'; -function snapshot(rootId: string, background = '#f0f6f5'): MapSnapshot { +function mapNodes(rootId: string, background = '#f0f6f5'): MapSnapshot { return [ - node(rootId, '', { + nodeRecord({ + id: rootId, ...DefaultRootNodeValues, colors: { ...DefaultRootNodeValues.colors, background }, isRoot: true, coordinates: { x: 0, y: 0 }, }), - node('child', rootId, { coordinates: { x: 200, y: 0 } }), + nodeRecord({ id: 'child', parent: rootId, coordinates: { x: 200, y: 0 } }), ]; } -/** The ring selection draws on a background filled with `fill`. */ -function ring(fill: string): string | undefined { - return d3.color(fill)?.darker(0.5).toString(); -} - -/** The event names mmp fired, in order. */ -function firedEvents(events: { call: jest.Mock }): string[] { - return events.call.mock.calls.map(call => call[0]); +function rootId(map: MmpMap): string { + const root = map.nodes.mainRoot(); + if (!root) throw new Error('the map has no main root'); + return root.id; } // A map load selects the main root: it draws the ring on the root and tells -// listeners through `nodeSelect`, even when the load itself fires no event. +// listeners through `nodeSelect`. describe('a map load', () => { it('draws the ring on the root and selects it', () => { - const { map } = makeMap(); + const { map } = stubMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root')); - const root = map.nodes.getRoot(); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); - expect(map.nodes.getSelectedNode()).toBe(root); + expect(map.draw.ringOf('root')).toBe(ring('#f0f6f5')); + expect(map.nodes.getSelectedNode()?.id).toBe('root'); }); - it('fires nodeSelect for the root although it fires no create event', () => { - const { map, events } = makeMap(); + it('fires nodeSelect for the root, then mapChange', () => { + const { map, events } = stubMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root')); - expect(events.call).toHaveBeenCalledWith( - Event.nodeSelect, - expect.anything(), + expect(events.emit).toHaveBeenCalledWith( + 'nodeSelect', expect.objectContaining({ id: 'root' }) ); - expect(firedEvents(events)).not.toContain(Event.create); + expect(firedEvents(events)).toEqual(['nodeSelect', 'mapChange']); }); it('rings the new root DOM when the same map loads twice', () => { - const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); - events.call.mockClear(); + const { map, events } = stubMap(); + map.loader.load(mapNodes('root')); + events.emit.mockClear(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root')); - const root = map.nodes.getRoot(); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(map.draw.ringOf('root')).toBe(ring('#f0f6f5')); + expect(firedEvents(events)).toEqual(['nodeSelect', 'mapChange']); }); it('selects the new root when a map with another root loads', () => { - const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); - events.call.mockClear(); + const { map, events } = stubMap(); + map.loader.load(mapNodes('root')); + events.emit.mockClear(); - map.history.new(snapshot('other-root'), false); + map.loader.load(mapNodes('other-root')); - const root = map.nodes.getRoot(); - expect(root.id).toBe('other-root'); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); - expect(events.call.mock.calls[0][2].id).toBe('other-root'); + expect(rootId(map)).toBe('other-root'); + expect(map.draw.ringOf('other-root')).toBe(ring('#f0f6f5')); + expect(firedEvents(events)).toEqual(['nodeSelect', 'mapChange']); + expect(events.emit.mock.calls[0][1].id).toBe('other-root'); }); - it('rings the root it creates when no snapshot is given', () => { - const { map, events } = makeMap(); + it('rings the root it creates when no nodes are given', () => { + const { map, events } = stubMap(); - map.history.new(undefined, false); + map.loader.load(); - const root = map.nodes.getRoot(); - expect(root.getBackgroundDOM().style.stroke).toBe( + const root = rootId(map); + expect(map.draw.ringOf(root)).toBe( ring(DefaultRootNodeValues.colors.background) ); - expect(events.call).toHaveBeenCalledWith( - Event.nodeSelect, - root.dom, - expect.objectContaining({ id: root.id }) + expect(events.emit).toHaveBeenCalledWith( + 'nodeSelect', + expect.objectContaining({ id: root }) ); }); it('selects a root without a background colour', () => { - const { map, events } = makeMap(); + const { map, events } = stubMap(); - map.history.new(snapshot('root', ''), false); + map.loader.load(mapNodes('root', '')); expect(map.nodes.getSelectedNode()?.id).toBe('root'); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(firedEvents(events)).toEqual(['nodeSelect', 'mapChange']); }); }); describe('an edit mode change after a map load', () => { - it('draws the ring on the new DOM of the selected root', () => { - const { map } = makeMap(); - map.history.new(snapshot('root'), false); + it('draws the ring on the selected root again', () => { + const { map } = stubMap(); + map.loader.load(mapNodes('root')); new Options({}, map).update('edit', false); - const root = map.nodes.getRoot(); - expect(map.nodes.getSelectedNode()).toBe(root); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + expect(map.nodes.getSelectedNode()?.id).toBe('root'); + expect(map.draw.ringOf('root')).toBe(ring('#f0f6f5')); }); }); describe('removing a node after a map load', () => { - it('draws the ring on the new DOM of the selected root', () => { - const { map } = makeMap(); - map.history.new(snapshot('root'), false); + it('keeps the ring on the selected root', () => { + const { map } = stubMap(); + map.loader.load(mapNodes('root')); - map.nodes.removeNode('child', false); + map.nodes.removeNode('child'); - const root = map.nodes.getRoot(); - expect(map.nodes.getSelectedNode()).toBe(root); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + expect(map.nodes.getSelectedNode()?.id).toBe('root'); + expect(map.draw.ringOf('root')).toBe(ring('#f0f6f5')); }); }); describe('selectRootNode', () => { it('fires nodeSelect once when called twice', () => { - const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); + const { map, events } = stubMap(); + map.loader.load(mapNodes('root')); map.nodes.deselectNode(); - events.call.mockClear(); + events.emit.mockClear(); map.nodes.selectRootNode(); map.nodes.selectRootNode(); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(firedEvents(events)).toEqual(['nodeSelect']); }); }); diff --git a/packages/mmp/src/map/handlers/selection-ring.spec.ts b/packages/mmp/src/map/handlers/selection-ring.spec.ts new file mode 100644 index 00000000..a40fc65d --- /dev/null +++ b/packages/mmp/src/map/handlers/selection-ring.spec.ts @@ -0,0 +1,49 @@ +import { nodeRecord, ring, stubMap } from '../../test/stub-map.js'; + +/** The color a client draws around the node it selected itself. */ +const HIGHLIGHT = '#c0c0c0'; + +/** + * A map with a selected child that a highlight rings, as the frontend's + * awareness does right after the selection. + */ +function highlightedChild() { + const { map } = stubMap([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ + id: 'child', + parent: 'root', + coordinates: { x: 200, y: 0 }, + colors: { name: '#666666', background: '#f5f5f5', branch: '#ffc107' }, + }), + ]); + map.nodes.selectNode('child'); + map.nodes.highlightNodeWithColor('child', HIGHLIGHT); + return map; +} + +describe('the ring of the selected node', () => { + it('keeps a highlight when the node moves', () => { + const map = highlightedChild(); + + map.nodes.updateNode('coordinates', { x: 100, y: -50 }, 'child'); + + expect(map.draw.ringOf('child')).toBe(HIGHLIGHT); + }); + + it('keeps a highlight when the node gets a new name', () => { + const map = highlightedChild(); + + map.nodes.updateNode('name', 'Draggable Node', 'child'); + + expect(map.draw.ringOf('child')).toBe(HIGHLIGHT); + }); + + it('darkens along with a new background', () => { + const map = highlightedChild(); + + map.nodes.updateNode('backgroundColor', '#ff0000', 'child'); + + expect(map.draw.ringOf('child')).toBe(ring('#ff0000')); + }); +}); diff --git a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts index 6147ade0..4c7a9411 100644 --- a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts +++ b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts @@ -1,14 +1,12 @@ -import CopyPaste from './copy-paste.js'; -import Nodes from './nodes.js'; -import MmpMap from '../map.js'; -import Node, { NodeProperties } from '../models/node.js'; -import { DefaultNodeValues } from '../options.js'; -import { Event } from './events.js'; import type { Bounds } from './node-geometry.js'; import { NEW_TREE_GAP, treeBounds } from './tree-placement.js'; +import type { ResolvedNode } from '../data/node-record.js'; +import { fakeDraw } from '../../test/fake-draw.js'; +import { nodeRecord, stubMap } from '../../test/stub-map.js'; import type { ExportNodeProperties, - MapNodeCoordinates, + MapNodeDimensions, + MapSnapshot, } from '@teammapper/shared'; /** @@ -21,109 +19,80 @@ interface CopyPasteInternals { copiedNodes: ExportNodeProperties[]; } -interface NodesInternals { - selectedNode: Node | null; -} - -function makeNode(properties: Partial & { id: string }): Node { - return new Node({ - k: 1, - parent: null, - colors: { ...DefaultNodeValues.colors }, - ...properties, - }); -} - /** - * A map holding the main tree (root, branch) and a second tree whose root - * has a left-hand child with a grandchild and a right-hand child. + * The main tree (root, branch) and a second tree whose root has a left-hand + * child with a grandchild and a right-hand child. */ -function makeMap(view: Bounds | null = null) { - const events = { call: jest.fn() }; - // `view` is the visible area the zoom stub reports. The default null stands - // for jsdom's svg, which has no size. - const zoom = { visibleArea: jest.fn(() => view), panIntoView: jest.fn() }; - const map = { - rootId: 'root', - options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn(), clear: jest.fn() }, - history: { save: jest.fn() }, - events, - zoom, - } as unknown as MmpMap; - - const nodes = new Nodes(map); - map.nodes = nodes; - // No zoom transform applies in these tests, so `fixCoordinates` returns its - // input. - nodes.fixCoordinates = (coordinates: MapNodeCoordinates) => coordinates; - // No node has a DOM in these tests, so selecting one records the call only, - // and a redraw draws no ring. - const selectNode = jest.fn(); - nodes.selectNode = selectNode; - nodes.redrawSelectionRing = jest.fn(); - - const root = makeNode({ id: 'root', isRoot: true }); - const branch = makeNode({ - id: 'branch', - parent: root, - coordinates: { x: 200, y: 0 }, - }); - const second = makeNode({ id: 'second', coordinates: { x: 1000, y: 0 } }); - const left = makeNode({ +const SNAPSHOT: MapSnapshot = [ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'branch', parent: 'root', coordinates: { x: 200, y: 0 } }), + nodeRecord({ id: 'second', coordinates: { x: 1000, y: 0 } }), + nodeRecord({ id: 'left', - parent: second, + parent: 'second', coordinates: { x: 800, y: -120 }, - }); - const grandchild = makeNode({ + }), + nodeRecord({ id: 'grandchild', - parent: left, + parent: 'left', coordinates: { x: 600, y: -240 }, - }); - const right = makeNode({ + }), + nodeRecord({ id: 'right', - parent: second, + parent: 'second', coordinates: { x: 1200, y: -120 }, - }); - - const tree = { root, branch, second, left, grandchild, right }; - Object.values(tree).forEach(node => nodes.setNode(node.id, node)); + }), +]; - const clipboard = new CopyPaste(map); +/** + * A map holding SNAPSHOT plus `extra`. `view` is the visible area the zoom + * stub reports; the default null stands for jsdom's svg, which has no size. + */ +function makeMap(view: Bounds | null = null, extra: MapSnapshot = []) { + const sizes = new Map(); + const zoom = { + center: jest.fn(), + visibleArea: jest.fn(() => view), + panIntoView: jest.fn(), + }; + const stub = stubMap([...SNAPSHOT, ...extra], { + draw: fakeDraw(node => sizes.get(node.id) ?? { width: 0, height: 0 }), + zoom, + }); + const pastes: string[][] = []; + stub.data.subscribe(change => { + if (change.added.length > 0) pastes.push(change.added); + }); - return { nodes, clipboard, tree, events, zoom, selectNode }; + return { ...stub, clipboard: stub.map.copyPaste, zoom, sizes, pastes }; } -function ids(nodes: Node[]): string[] { - return nodes.map(node => node.id).sort(); +function ids(snapshot: readonly { id: string }[]): string[] { + return snapshot.map(node => node.id).sort(); } -/** The nodes the last paste created, read from its paste event. */ -function pastedNodes(nodes: Nodes, events: { call: jest.Mock }): Node[] { - const call = events.call.mock.calls.find( - ([event]) => event === Event.nodePaste - ); - const pasted = (call?.[2] ?? []) as ExportNodeProperties[]; - - return pasted.map(properties => nodes.getNode(properties.id) as Node); +/** The nodes the last paste created, in the order the paste built them. */ +function pastedNodes(context: ReturnType): ResolvedNode[] { + const last = context.pastes[context.pastes.length - 1] ?? []; + return last.flatMap(id => context.nodes.record(id) ?? []); } describe('removeNode', () => { it('removes a second root together with every descendant', () => { - const { nodes } = makeMap(); + const { nodes, data } = makeMap(); nodes.removeNode('second'); - expect(ids(nodes.getNodes())).toEqual(['branch', 'root']); + expect(ids(data.nodes())).toEqual(['branch', 'root']); }); it('refuses to remove the main root while other trees exist', () => { - const { nodes } = makeMap(); + const { nodes, data } = makeMap(); expect(() => nodes.removeNode('root')).toThrow( 'The root node can not be deleted' ); - expect(nodes.getNodes()).toHaveLength(6); + expect(data.nodes()).toHaveLength(6); }); }); @@ -134,12 +103,7 @@ describe('copy and cut', () => { clipboard.copy('second'); const copied = (clipboard as unknown as CopyPasteInternals).copiedNodes; - expect(copied.map(node => node.id).sort()).toEqual([ - 'grandchild', - 'left', - 'right', - 'second', - ]); + expect(ids(copied)).toEqual(['grandchild', 'left', 'right', 'second']); }); it('refuses to copy the main root and leaves the clipboard unchanged', () => { @@ -153,6 +117,16 @@ describe('copy and cut', () => { expect(copied[0].id).toBe('second'); }); + it('keeps copies the map data does not share', () => { + const { clipboard, data } = makeMap(); + clipboard.copy('second'); + + data.updateNode('second', 'name', 'changed'); + + const copied = (clipboard as unknown as CopyPasteInternals).copiedNodes; + expect(copied[0].name).toBe(''); + }); + it('refuses to cut the main root and keeps it on the map', () => { const { clipboard, nodes } = makeMap(); @@ -164,61 +138,92 @@ describe('copy and cut', () => { }); it('cuts a second root with its tree', () => { - const { clipboard, nodes } = makeMap(); + const { clipboard, data } = makeMap(); clipboard.cut('second'); - expect(ids(nodes.getNodes())).toEqual(['branch', 'root']); + expect(ids(data.nodes())).toEqual(['branch', 'root']); }); }); describe('paste', () => { it('attaches a copied tree under the selected main root', () => { - const { clipboard, nodes, tree, events } = makeMap(); - clipboard.copy('second'); - (nodes as unknown as NodesInternals).selectedNode = tree.root; + const context = makeMap(); + context.clipboard.copy('second'); + context.nodes.selectNode('root'); - clipboard.paste(); + context.clipboard.paste(); - const [pastedRoot, ...rest] = pastedNodes(nodes, events); - expect(pastedRoot.parent).toBe(tree.root); + const [pastedRoot, ...rest] = pastedNodes(context); + expect(pastedRoot.parent).toBe('root'); expect(rest).toHaveLength(3); - expect(nodes.getDescendants(pastedRoot)).toHaveLength(3); + expect(context.nodes.descendants(pastedRoot.id)).toHaveLength(3); }); it('writes isRoot false on every pasted node', () => { - const { clipboard, nodes, tree, events } = makeMap(); - clipboard.copy('left'); - const copied = (clipboard as unknown as CopyPasteInternals).copiedNodes; + const context = makeMap(); + context.clipboard.copy('left'); + const copied = (context.clipboard as unknown as CopyPasteInternals) + .copiedNodes; copied.forEach(node => (node.isRoot = true)); - clipboard.paste(tree.branch.id); + context.clipboard.paste('branch'); - const pasted = pastedNodes(nodes, events); + const pasted = pastedNodes(context); expect(pasted).toHaveLength(2); expect(pasted.every(node => node.isRoot === false)).toBe(true); }); + it('writes the paste in a batch, so it takes an undo step of its own', () => { + const context = makeMap(); + context.clipboard.copy('left'); + const batch = jest.spyOn(context.data, 'batch'); + const addNodes = jest.spyOn(context.data, 'addNodes'); + + context.clipboard.paste('branch'); + + expect(addNodes).toHaveBeenCalledTimes(1); + expect(batch.mock.invocationCallOrder[0]).toBeLessThan( + addNodes.mock.invocationCallOrder[0] + ); + expect(context.pastes).toHaveLength(1); + }); + + it('pastes each copied node once when the copied nodes form a cycle', () => { + const context = makeMap(); + context.clipboard.copy('left'); + const copied = (context.clipboard as unknown as CopyPasteInternals) + .copiedNodes; + copied[0].parent = 'grandchild'; + + context.clipboard.paste('branch'); + + expect(pastedNodes(context)).toHaveLength(2); + }); + it('pastes nothing with nothing selected', () => { - const { clipboard, nodes } = makeMap(); + const { clipboard, nodes, data } = makeMap(); clipboard.copy('second'); + nodes.deselectNode(); clipboard.paste(); - expect(nodes.getNodes()).toHaveLength(6); + expect(data.nodes()).toHaveLength(6); }); }); describe('pasteTree', () => { function pasteSecondTree() { const context = makeMap(); + context.nodes.deselectNode(); context.clipboard.copy('second'); const expectedRoot = context.nodes.newTreeCoordinates(); + const selectNode = jest.spyOn(context.nodes, 'selectNode'); context.clipboard.pasteTree(); - const pasted = pastedNodes(context.nodes, context.events); - return { ...context, pasted, expectedRoot }; + const pasted = pastedNodes(context); + return { ...context, pasted, expectedRoot, selectNode }; } it('pastes the copied nodes as an independent tree', () => { @@ -226,8 +231,8 @@ describe('pasteTree', () => { const [pastedRoot] = pasted; expect(pasted).toHaveLength(4); - expect(pastedRoot.parent).toBeNull(); - expect(nodes.getDescendants(pastedRoot)).toHaveLength(3); + expect(nodes.parentOf(pastedRoot.id)).toBeNull(); + expect(nodes.descendants(pastedRoot.id)).toHaveLength(3); }); it("writes isRoot false on every pasted node and branch color '' on the pasted root", () => { @@ -262,25 +267,24 @@ describe('pasteTree', () => { }); it('keeps the offsets of a copied non-root subtree to its copied node', () => { - const context = makeMap(); - const inner = makeNode({ - id: 'inner', - parent: context.tree.right, - coordinates: { x: 1100, y: -240 }, - }); - const innermost = makeNode({ - id: 'innermost', - parent: inner, - coordinates: { x: 1050, y: -360 }, - }); - context.nodes.setNode(inner.id, inner); - context.nodes.setNode(innermost.id, innermost); + const context = makeMap(null, [ + nodeRecord({ + id: 'inner', + parent: 'right', + coordinates: { x: 1100, y: -240 }, + }), + nodeRecord({ + id: 'innermost', + parent: 'inner', + coordinates: { x: 1050, y: -360 }, + }), + ]); context.clipboard.copy('right'); const expectedRoot = context.nodes.newTreeCoordinates(); context.clipboard.pasteTree(); - const offsets = pastedNodes(context.nodes, context.events).map(node => [ + const offsets = pastedNodes(context).map(node => [ node.coordinates.x - expectedRoot.x, node.coordinates.y - expectedRoot.y, ]); @@ -294,14 +298,13 @@ describe('pasteTree', () => { it('pastes a tree whatever node is selected', () => { const context = makeMap(); context.clipboard.copy('second'); - (context.nodes as unknown as NodesInternals).selectedNode = - context.tree.branch; + context.nodes.selectNode('branch'); context.clipboard.pasteTree(); - const [pastedRoot] = pastedNodes(context.nodes, context.events); - expect(pastedRoot.parent).toBeNull(); - expect(context.nodes.getChildren(context.tree.branch)).toEqual([]); + const [pastedRoot] = pastedNodes(context); + expect(context.nodes.parentOf(pastedRoot.id)).toBeNull(); + expect(context.nodes.children('branch')).toEqual([]); }); it('refuses to paste an empty clipboard', () => { @@ -317,47 +320,51 @@ describe('pasteTree', () => { // No node has a size in these tests, so the bounding box of the pasted // nodes equals the copied footprint moved to the pasted root. - const [pastedTree] = treeBounds(pasted, () => pasted[0]); + const [pastedTree] = treeBounds(pasted, () => pasted[0], nodes.boundsOf); expect(selectNode).not.toHaveBeenCalled(); expect(nodes.getSelectedNode()).toBeNull(); expect(zoom.panIntoView).toHaveBeenCalledWith(pastedTree); }); it('adds a second tree on a second paste with nothing selected', () => { - const { clipboard, nodes, events } = makeMap(); - clipboard.copy('second'); + const context = makeMap(); + context.nodes.deselectNode(); + context.clipboard.copy('second'); - clipboard.pasteTree(); - clipboard.pasteTree(); + context.clipboard.pasteTree(); + context.clipboard.pasteTree(); - const pasteCalls = events.call.mock.calls.filter( - ([event]) => event === Event.nodePaste - ); - const roots = pasteCalls.map(([, , pasted]) => { - const [first] = pasted as ExportNodeProperties[]; - return nodes.getNode(first.id); - }); + const roots = context.pastes.map(([first]) => first); expect(roots).toHaveLength(2); expect(roots[0]).not.toBe(roots[1]); - roots.forEach(root => expect(root?.parent).toBeNull()); - expect(nodes.getNodes()).toHaveLength(14); + roots.forEach(root => expect(context.nodes.parentOf(root)).toBeNull()); + expect(context.data.nodes()).toHaveLength(14); }); it('keeps the whole pasted tree clear of the other trees', () => { // The middle of the viewport, (1000, -100), lies on the second tree. const context = makeMap({ minX: 600, maxX: 1400, minY: -400, maxY: 200 }); const size = { width: 120, height: 40 }; - context.nodes.getNodes().forEach(node => (node.dimensions = { ...size })); + context.data + .nodes() + .forEach(node => context.sizes.set(node.id, { ...size })); context.clipboard.copy('second'); context.clipboard.pasteTree(); - const pasted = pastedNodes(context.nodes, context.events); - pasted.forEach(node => (node.dimensions = { ...size })); - const [pastedTree] = treeBounds(pasted, () => pasted[0]); + const pasted = pastedNodes(context); + const pastedIds = new Set(pasted.map(node => node.id)); + pasted.forEach(node => context.sizes.set(node.id, { ...size })); + const [pastedTree] = treeBounds( + pasted, + () => pasted[0], + context.nodes.boundsOf + ); + const records = context.nodes.scan(); const others = treeBounds( - context.nodes.getNodes().filter(node => !pasted.includes(node)), - node => context.nodes.getTreeRoot(node) + [...records.values()].filter(node => !pastedIds.has(node.id)), + node => records.get(context.nodes.treeRoot(node.id)) ?? node, + context.nodes.boundsOf ); expect(others).toHaveLength(2); others.forEach(tree => { diff --git a/packages/mmp/src/map/handlers/tree-placement.spec.ts b/packages/mmp/src/map/handlers/tree-placement.spec.ts index 35fa76ea..0471734c 100644 --- a/packages/mmp/src/map/handlers/tree-placement.spec.ts +++ b/packages/mmp/src/map/handlers/tree-placement.spec.ts @@ -99,11 +99,15 @@ describe('treeBounds', () => { [rootB, rootB], ]); - const bounds = treeBounds([rootA, childA, rootB], node => { - const root = roots.get(node); - if (!root) throw new Error('unknown node'); - return root; - }); + const bounds = treeBounds( + [rootA, childA, rootB], + node => { + const root = roots.get(node); + if (!root) throw new Error('unknown node'); + return root; + }, + nodeBounds + ); expect(bounds).toEqual([ { minX: -240, maxX: 50, minY: -140, maxY: 20 }, diff --git a/packages/mmp/src/map/handlers/tree-placement.ts b/packages/mmp/src/map/handlers/tree-placement.ts index 8a359e9b..fabf9913 100644 --- a/packages/mmp/src/map/handlers/tree-placement.ts +++ b/packages/mmp/src/map/handlers/tree-placement.ts @@ -87,15 +87,16 @@ function overlaps(a: Bounds, b: Bounds): boolean { * One bounding box per tree, over every node of the tree, hidden nodes * included. */ -export function treeBounds( +export function treeBounds( nodes: T[], - treeRootOf: (node: T) => T + treeRootOf: (node: T) => T, + boundsOf: (node: T) => Bounds ): Bounds[] { const byRoot = new Map(); for (const node of nodes) { const root = treeRootOf(node); - const bounds = nodeBounds(node); + const bounds = boundsOf(node); const known = byRoot.get(root); byRoot.set(root, known ? unionBounds(known, bounds) : bounds); } diff --git a/packages/mmp/src/map/handlers/view-state.spec.ts b/packages/mmp/src/map/handlers/view-state.spec.ts new file mode 100644 index 00000000..8e86e747 --- /dev/null +++ b/packages/mmp/src/map/handlers/view-state.spec.ts @@ -0,0 +1,100 @@ +import * as d3 from 'd3'; +import { create } from '../../index.js'; +import MmpMap from '../map.js'; +import InMemoryMapData from '../data/in-memory-map-data.js'; +import { stubSvgLengths } from '../../test/svg-lengths.js'; + +/** + * The view state holds the nodes whose child nodes this person hid, apart + * from the map data. These specs drive a real map through `MmpInstance`. + */ + +beforeAll(stubSvgLengths); + +/** root -> a -> b -> c, with the root selected. */ +function makeChain() { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const data = new InMemoryMapData(); + const map: MmpMap = create('map', ref, undefined, data); + map.instance.new(); + + const root = map.instance.exportRootProperties()?.id; + if (!root) throw new Error('the map has no main root'); + const a = map.instance.addNode({ name: 'a' }, root); + if (!a) throw new Error('addNode added no node'); + const b = map.instance.addNode({ name: 'b' }, a.id); + if (!b) throw new Error('addNode added no node'); + const c = map.instance.addNode({ name: 'c' }, b.id); + if (!c) throw new Error('addNode added no node'); + + return { map, data, root, a, b, c }; +} + +/** Select the node and hide its child nodes. */ +function hideChildrenOf(map: MmpMap, id: string) { + map.instance.selectNode(id); + map.instance.toggleBranchVisibility(); +} + +function visibility(id: string): string | undefined { + return d3 + .selectAll('g.node') + .filter(datum => datum === id) + .node()?.style.visibility; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('ViewState', () => { + it('hides every node below a node whose child nodes it hides', () => { + const { map, root, a, b, c } = makeChain(); + + hideChildrenOf(map, a.id); + + expect([root, a.id, b.id, c.id].map(id => map.nodes.isHidden(id))).toEqual([ + false, + false, + true, + true, + ]); + expect(visibility(root)).toBe('visible'); + expect(visibility(c.id)).toBe('hidden'); + }); + + it('terminates on an ancestor cycle', () => { + const { map, data, a, b, c } = makeChain(); + hideChildrenOf(map, b.id); + + data.addNodes([{ ...a, parent: b.id }]); + + // The walk up from a reaches b, whose parent a closes the cycle. + expect(map.nodes.isHidden(a.id)).toBe(true); + expect(map.nodes.isHidden(c.id)).toBe(true); + expect(map.nodes.isHidden(b.id)).toBe(false); + }); + + it('forgets a removed node, so the node shows its child nodes on its return', () => { + const { map, data, a, b, c } = makeChain(); + hideChildrenOf(map, b.id); + + data.removeNode(b.id); + data.addNodes([b, c]); + + expect(map.instance.childNodesHidden(b.id)).toBe(false); + expect(visibility(c.id)).toBe('visible'); + expect(map.nodes.isHidden(a.id)).toBe(false); + }); + + it('keeps the view state across a load of the same nodes', () => { + const { map, a, c } = makeChain(); + hideChildrenOf(map, a.id); + + map.instance.new(map.instance.exportAsJSON()); + + expect(map.instance.childNodesHidden(a.id)).toBe(true); + expect(visibility(c.id)).toBe('hidden'); + }); +}); diff --git a/packages/mmp/src/map/handlers/view-state.ts b/packages/mmp/src/map/handlers/view-state.ts new file mode 100644 index 00000000..d920ea6c --- /dev/null +++ b/packages/mmp/src/map/handlers/view-state.ts @@ -0,0 +1,41 @@ +/** + * Hold the ids of the nodes whose child nodes this person hid. `Nodes` owns + * the selection and `Draw` owns the rings around selected nodes. ViewState + * writes nothing into the map data, so no peer, no server and no undo step + * learns which child nodes are hidden, and a reload shows them again. + * `Nodes.onChange` deletes the ids of the nodes a change removes. + */ +export default class ViewState { + private nodesWithHiddenChildren = new Set(); + + /** + * True when this person hid the child nodes of the node. + * @param {string} id + */ + public hidesChildren(id: string): boolean { + return this.nodesWithHiddenChildren.has(id); + } + + /** True when this person hid no child nodes. */ + public isEmpty(): boolean { + return this.nodesWithHiddenChildren.size === 0; + } + + /** + * Hide the child nodes of the node, or show them when they are hidden. + * @param {string} id + */ + public toggle(id: string) { + if (!this.nodesWithHiddenChildren.delete(id)) { + this.nodesWithHiddenChildren.add(id); + } + } + + /** + * Delete the ids of removed nodes. + * @param {string[]} ids + */ + public forget(ids: string[]) { + ids.forEach(id => this.nodesWithHiddenChildren.delete(id)); + } +} diff --git a/packages/mmp/src/map/handlers/zoom.spec.ts b/packages/mmp/src/map/handlers/zoom.spec.ts index 24e82594..384ffe23 100644 --- a/packages/mmp/src/map/handlers/zoom.spec.ts +++ b/packages/mmp/src/map/handlers/zoom.spec.ts @@ -21,7 +21,7 @@ function makeMap() { const root = { coordinates: { x: 100, y: 50 } }; const map = { dom: { svg: d3.select(svgElement), g: d3.select(gElement) }, - nodes: { getRoot: () => root }, + nodes: { mainRoot: () => root }, } as unknown as MmpMap; const zoom = new Zoom(map); diff --git a/packages/mmp/src/map/handlers/zoom.ts b/packages/mmp/src/map/handlers/zoom.ts index a09b5492..960fc933 100644 --- a/packages/mmp/src/map/handlers/zoom.ts +++ b/packages/mmp/src/map/handlers/zoom.ts @@ -90,9 +90,9 @@ export default class Zoom { Log.error('The duration must be a number', 'type'); } - const root = this.map.nodes.getRoot(), - x = root.coordinates.x, - y = root.coordinates.y; + const root = this.map.nodes.mainRoot(); + if (!root) return; + const { x, y } = root.coordinates; // A transition applies on a later animation frame, and an interrupt, such // as the one d3-zoom sends on mousedown, cancels it. A zero duration diff --git a/packages/mmp/src/map/map.ts b/packages/mmp/src/map/map.ts index b3406287..a97622ab 100644 --- a/packages/mmp/src/map/map.ts +++ b/packages/mmp/src/map/map.ts @@ -1,17 +1,19 @@ import * as d3 from 'd3'; -import Events from './handlers/events.js'; +import Events, { MmpEventCallback } from './handlers/events.js'; import Zoom from './handlers/zoom.js'; import Draw from './handlers/draw.js'; import Options, { OptionParameters } from './options.js'; -import History from './handlers/history.js'; +import MapLoader from './handlers/map-loader.js'; import Drag from './handlers/drag.js'; import Nodes from './handlers/nodes.js'; import Export from './handlers/export.js'; import CopyPaste from './handlers/copy-paste.js'; -import Node from './models/node.js'; +import ViewState from './handlers/view-state.js'; +import type { MapData } from './data/map-data.js'; import type { ExportNodeProperties, MapSnapshot, + MmpEventType, NodeProperty, NodePropertyValue, UserNodeProperties, @@ -23,10 +25,11 @@ import type { export default class MmpMap { public id: string; public dom: DomElements; - public rootId: string; + /** The nodes of the map. mmp reads and writes them here and keeps none. */ + public readonly data: MapData; public options: Options; - public history: History; + public loader: MapLoader; public events: Events; public zoom: Zoom; public draw: Draw; @@ -34,28 +37,40 @@ export default class MmpMap { public nodes: Nodes; public export: Export; public copyPaste: CopyPaste; + public viewState: ViewState; public instance!: MmpInstance; + private readonly unsubscribeData: () => void; + /** - * Create all handler instances, set some map behaviors and return a mmp instance. + * Create all handler instances, set some map behaviors, draw the nodes the + * map data holds and return a mmp instance. The map selects the main root + * and fires `nodeSelect`, and emits no `mapChange` for this first draw. * @param {string} id + * @param {HTMLElement} ref * @param {OptionParameters} options - * @returns {MmpInstance} mmpInstance + * @param {MapData} data */ - constructor(id: string, ref: HTMLElement, options?: OptionParameters) { + constructor( + id: string, + ref: HTMLElement, + options: OptionParameters | undefined, + data: MapData + ) { this.id = id; + this.data = data; this.events = new Events(); this.options = new Options(options, this); this.zoom = new Zoom(this); - this.history = new History(this); + this.viewState = new ViewState(); + this.loader = new MapLoader(this); this.drag = new Drag(this); this.draw = new Draw(this, ref); this.nodes = new Nodes(this); this.export = new Export(this); this.copyPaste = new CopyPaste(this); - this.rootId = ''; this.dom = this.draw.create(); @@ -69,15 +84,21 @@ export default class MmpMap { this.dom.svg.call(this.zoom.getZoomBehavior()); } - this.history.save(); - this.createMmpInstance(); + + this.unsubscribeData = data.subscribe(this.nodes.onChange); + this.nodes.drawReplaced(); } /** - * Remove permanently mmp instance. + * Remove the map for good: its svg, every subscription and the resize + * listener on the window. */ - private remove = () => { + private destroy = () => { + d3.select(window).on('resize.' + this.id, null); + this.unsubscribeData(); + this.events.unsubscribeAll(); + this.draw.destroy(); this.dom.svg.remove(); const instanceRecord = this.instance as unknown as Record; @@ -93,8 +114,7 @@ export default class MmpMap { */ private createMmpInstance(): MmpInstance { return (this.instance = { - addNode: this.nodes.addNodeUnlessProtected, - addNodes: this.nodes.addNodes, + addNode: this.nodes.addNode, addTree: this.nodes.addTree, center: this.zoom.center, copyNode: this.copyPaste.copy, @@ -104,12 +124,13 @@ export default class MmpMap { getSelectedNode: this.nodes.getSelectedNode, editNode: this.nodes.editNode, toggleBranchVisibility: this.nodes.toggleBranchVisibility, + childNodesHidden: this.nodes.childNodesHidden, existNode: this.nodes.existNode, exportAsImage: this.export.asImage, exportAsJSON: this.export.asJSON, - exportRootProperties: this.nodes.exportRootProperties, + exportRootProperties: () => this.nodes.mainRoot(), highlightNode: this.nodes.highlightNodeWithColor, - new: this.history.new, + new: this.loader.load, nodeChildren: this.nodes.nodeChildren, on: this.events.on, pasteNode: this.copyPaste.paste, @@ -117,10 +138,9 @@ export default class MmpMap { protectBranch: this.nodes.protectBranch, protectingNode: this.nodes.protectingNode, releaseBranch: this.nodes.releaseBranch, - remove: this.remove, + destroy: this.destroy, removeNode: this.nodes.removeNode, selectNode: this.nodes.selectNode, - unsubscribeAll: this.events.unsubscribeAll, updateNode: this.nodes.updateNode, zoomIn: this.zoom.zoomIn, zoomOut: this.zoom.zoomOut, @@ -128,46 +148,48 @@ export default class MmpMap { } } +/** + * The functions the mmp library offers. Every node it returns is a copy the + * caller may change; the map data keeps its own. + */ export interface MmpInstance { addNode: ( userProperties?: UserNodeProperties, - notifyWithEvent?: boolean, - updateHistory?: boolean, parentId?: string | null, overwriteId?: string - ) => Node | null; - addNodes: (nodes: ExportNodeProperties[], updateHistory?: boolean) => void; - addTree: () => Node; + ) => ExportNodeProperties | null; + addTree: () => ExportNodeProperties | null; center: (type?: 'zoom' | 'position', duration?: number) => void; copyNode: (id?: string) => void; cutNode: (id?: string) => boolean; applyCoordinatesToMapSnapshot: (mapSnapshot: MapSnapshot) => MapSnapshot; - distributeNodes: (notifyWithEvent?: boolean) => void; - getSelectedNode: () => Node | null; + distributeNodes: () => void; + getSelectedNode: () => ExportNodeProperties | null; editNode: () => void; toggleBranchVisibility: () => void; + childNodesHidden: (id?: string) => boolean; existNode: (id?: string) => boolean; exportAsImage: (callback: (url: string) => void, type?: string) => void; exportAsJSON: () => MapSnapshot; - exportRootProperties: () => ExportNodeProperties; - highlightNode: (id: string, color: string, notifyWithEvent?: boolean) => void; - new: (snapshot?: MapSnapshot, notifyWithEvent?: boolean) => void; + exportRootProperties: () => ExportNodeProperties | null; + highlightNode: (id: string, color: string) => void; + new: (nodes?: MapSnapshot) => void; nodeChildren: (id?: string) => ExportNodeProperties[]; - on: (event: string, callback: (...args: unknown[]) => void) => void; + on: ( + event: K, + callback: MmpEventCallback + ) => () => void; pasteNode: (id?: string) => void; pasteTree: () => void; protectBranch: (id?: string) => void; protectingNode: (id?: string) => string | null; releaseBranch: (id?: string) => void; - remove: () => void; - removeNode: (id?: string, notifyWithEvent?: boolean) => void; + destroy: () => void; + removeNode: (id?: string) => void; selectNode: (id?: string) => ExportNodeProperties | null; - unsubscribeAll: () => void; updateNode: ( property: NodeProperty | string, value: NodePropertyValue | unknown, - notifyWithEvent?: boolean, - updateHistory?: boolean, id?: string ) => void; zoomIn: (duration?: number) => void; diff --git a/packages/mmp/src/map/mmp-instance.spec.ts b/packages/mmp/src/map/mmp-instance.spec.ts new file mode 100644 index 00000000..406fc092 --- /dev/null +++ b/packages/mmp/src/map/mmp-instance.spec.ts @@ -0,0 +1,722 @@ +import * as d3 from 'd3'; +import { create } from '../index.js'; +import MmpMap from './map.js'; +import InMemoryMapData from './data/in-memory-map-data.js'; +import type { MapDataChange } from './data/map-data.js'; +import { nodeRecord } from '../test/stub-map.js'; +import { stubSvgLengths } from '../test/svg-lengths.js'; +import type { + ExportNodeProperties, + MapSnapshot, + MmpEventType, + NodeProperty, + OldMmpNode, +} from '@teammapper/shared'; + +/** + * Every event. The `satisfies` clause fails the typecheck when + * `MmpEventType` gains an event this record lacks. + */ +const ALL_EVENTS = Object.keys({ + nodeSelect: true, + nodeDeselect: true, + nodeProtected: true, + mapChange: true, +} satisfies Record) as MmpEventType[]; + +/** + * The frontend reaches the library through `MmpInstance` alone, so these specs + * drive a real map through it: every node property, the protection and the + * events. Each map reads and writes an `InMemoryMapData`. + */ + +const IMAGE_REFERENCE = 'image:5f0c4b1e-3a8d-4c6e-9f2a-7b1d2e3f4a5b'; + +beforeAll(stubSvgLengths); + +function makeMapOver(data: InMemoryMapData): MmpMap { + const ref = document.createElement('div'); + document.body.appendChild(ref); + return create('map', ref, undefined, data); +} + +function makeMap(): MmpMap { + const map = makeMapOver(new InMemoryMapData()); + map.instance.new(); + return map; +} + +/** The main root with one child, which the map selects. */ +function makeMapWithChild() { + const map = makeMap(); + const child = map.instance.addNode({ name: 'child' }); + if (!child) throw new Error('addNode added no child'); + map.instance.selectNode(child.id); + return { map, child: child.id }; +} + +function rootOf(map: MmpMap): string { + const root = map.instance.exportRootProperties(); + if (!root) throw new Error('the map has no main root'); + return root.id; +} + +function exported(map: MmpMap, id: string): ExportNodeProperties { + const node = map.instance.exportAsJSON().find(n => n.id === id); + if (!node) throw new Error('no node ' + id); + return node; +} + +/** The drawn element bound to the node with the id. */ +function drawnElement(selector: string, id: string) { + return d3 + .selectAll(selector) + .filter(datum => datum === id) + .node(); +} + +function nodeDom(id: string): SVGGElement { + const dom = drawnElement('g.node', id); + if (!dom) throw new Error('no dom for ' + id); + return dom; +} + +function nameDom(id: string): HTMLDivElement { + const div = nodeDom(id).querySelector('foreignObject > div'); + if (!(div instanceof HTMLDivElement)) throw new Error('no name for ' + id); + return div; +} + +function drawnIds(): string[] { + return d3.selectAll('g.node').data(); +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +interface PropertyCase { + property: NodeProperty; + value: unknown; + read: (node: ExportNodeProperties) => unknown; + rendered?: (id: string) => unknown; +} + +const PROPERTY_CASES: PropertyCase[] = [ + { + property: 'name', + value: 'Renamed', + read: n => n.name, + rendered: id => nameDom(id).innerHTML, + }, + { + property: 'coordinates', + value: { x: 320, y: 140 }, + read: n => n.coordinates, + rendered: id => nodeDom(id).getAttribute('transform'), + }, + { + property: 'imageSrc', + value: IMAGE_REFERENCE, + read: n => n.image?.src, + }, + { + property: 'linkHref', + value: 'https://example.com/', + read: n => n.link?.href, + rendered: id => nodeDom(id).querySelector('a')?.getAttribute('href'), + }, + { + property: 'backgroundColor', + value: '#ff0000', + read: n => n.colors?.background, + rendered: id => nodeDom(id).querySelector('path')?.style.fill, + }, + { + property: 'branchColor', + value: '#00ff00', + read: n => n.colors?.branch, + rendered: id => + drawnElement('path.branch', id)?.style.stroke, + }, + { + property: 'nameColor', + value: '#0000ff', + read: n => n.colors?.name, + rendered: id => nameDom(id).style.color, + }, + { + property: 'fontWeight', + value: 'bold', + read: n => n.font?.weight, + rendered: id => nameDom(id).style.fontWeight, + }, + { + property: 'fontStyle', + value: 'italic', + read: n => n.font?.style, + rendered: id => nameDom(id).style.fontStyle, + }, + { + property: 'fontSize', + value: 24, + read: n => n.font?.size, + rendered: id => nameDom(id).style.fontSize, + }, + { + property: 'protected', + value: true, + read: n => n.protected, + rendered: id => nodeDom(id).querySelector('text.protected-icon') !== null, + }, +]; + +/** What the DOM shows once a property took its case value. */ +const RENDERED: Partial> = { + name: 'Renamed', + coordinates: 'translate(320,140)', + linkHref: 'https://example.com/', + backgroundColor: '#ff0000', + branchColor: '#00ff00', + nameColor: 'rgb(0, 0, 255)', + fontWeight: 'bold', + fontStyle: 'italic', + fontSize: '24px', + protected: true, +}; + +describe('updateNode', () => { + describe.each(PROPERTY_CASES)('$property', ({ property, value, read }) => { + it('writes the value to the map data', () => { + const { map, child } = makeMapWithChild(); + + map.instance.updateNode(property, value, child); + + expect(read(exported(map, child))).toEqual(value); + }); + + it('announces the change with mapChange', () => { + const { map, child } = makeMapWithChild(); + const listener = jest.fn(); + map.instance.on('mapChange', listener); + + map.instance.updateNode(property, value, child); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('writes nothing when the value stays the same', () => { + const { map, child } = makeMapWithChild(); + map.instance.updateNode(property, value, child); + const listener = jest.fn(); + map.instance.on('mapChange', listener); + + map.instance.updateNode(property, value, child); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe.each(PROPERTY_CASES.filter(c => c.rendered))( + '$property', + ({ property, value, rendered }) => { + it('draws the value', () => { + const { map, child } = makeMapWithChild(); + + map.instance.updateNode(property, value, child); + + expect(rendered?.(child)).toEqual(RENDERED[property]); + }); + } + ); + + it('sets the image size of a node with an image', () => { + const { map, child } = makeMapWithChild(); + map.instance.updateNode('imageSrc', IMAGE_REFERENCE, child); + + map.instance.updateNode('imageSize', 40, child); + + expect(exported(map, child).image?.size).toBe(40); + }); + + it('refuses an image size on a node without an image', () => { + const { map, child } = makeMapWithChild(); + + expect(() => map.instance.updateNode('imageSize', 40, child)).toThrow(); + }); + + it('targets the selected node without an id', () => { + const { map, child } = makeMapWithChild(); + + map.instance.updateNode('name', 'Selected'); + + expect(exported(map, child).name).toBe('Selected'); + }); + + it('refuses a branch color on a root node', () => { + const map = makeMap(); + + expect(() => + map.instance.updateNode('branchColor', '#00ff00', rootOf(map)) + ).toThrow(); + }); + + it('refuses a property it does not know', () => { + const { map, child } = makeMapWithChild(); + + expect(() => map.instance.updateNode('shadow', '1px', child)).toThrow(); + }); + + it.each<[NodeProperty, unknown]>([ + ['backgroundColor', 'red'], + ['backgroundColor', 'expression(alert(1))'], + ['nameColor', '#fff;background:url(x)'], + ['branchColor', 42], + ['linkHref', 'javascript:alert(1)'], + ['fontSize', 'big'], + ['fontWeight', 'x'.repeat(1000)], + ['name', 42], + ['protected', 1], + ['coordinates', { x: 'left', y: 0 }], + ])('refuses %s %p and keeps the map data', (property, value) => { + const { map, child } = makeMapWithChild(); + const before = exported(map, child); + + expect(() => map.instance.updateNode(property, value, child)).toThrow(); + expect(exported(map, child)).toEqual(before); + }); + + it('clears a color, a link and an image with an empty value', () => { + const { map, child } = makeMapWithChild(); + map.instance.updateNode('linkHref', 'https://example.com', child); + map.instance.updateNode('imageSrc', IMAGE_REFERENCE, child); + + map.instance.updateNode('backgroundColor', '', child); + map.instance.updateNode('linkHref', '', child); + map.instance.updateNode('imageSrc', '', child); + + const node = exported(map, child); + expect(node.colors?.background).toBe(''); + expect(node.link?.href).toBe(''); + expect(node.image?.src).toBe(''); + }); + + it('writes a recolor and the recolor back', () => { + const data = new InMemoryMapData(); + const map = makeMapOver(data); + map.instance.new(); + const child = map.instance.addNode({ name: 'child' }, rootOf(map)); + if (!child) throw new Error('addNode added no child'); + const original = data.node(child.id)?.colors?.background; + + map.instance.updateNode('backgroundColor', '#ff0000', child.id); + expect(data.node(child.id)?.colors?.background).toBe('#ff0000'); + + map.instance.updateNode('backgroundColor', original, child.id); + expect(data.node(child.id)?.colors?.background).toBe(original); + }); +}); + +describe('protection', () => { + /** root -> parent -> child, with parent protected. */ + function makeProtectedMap() { + const map = makeMap(); + const parent = map.instance.addNode({ name: 'parent' }); + if (!parent) throw new Error('addNode added no parent'); + const child = map.instance.addNode({ name: 'child' }, parent.id); + if (!child) throw new Error('addNode added no child'); + map.instance.protectBranch(parent.id); + return { map, parent: parent.id, child: child.id }; + } + + it.each(PROPERTY_CASES.filter(c => c.property !== 'protected'))( + 'refuses a $property change below it', + ({ property, value, read }) => { + const { map, child } = makeProtectedMap(); + const before = read(exported(map, child)); + const refused: ExportNodeProperties[] = []; + map.instance.on('nodeProtected', node => refused.push(node)); + + map.instance.updateNode(property, value, child); + + expect(read(exported(map, child))).toEqual(before); + expect(refused.map(node => node.id)).toEqual([child]); + } + ); + + it('refuses a child and a removal', () => { + const { map, parent, child } = makeProtectedMap(); + const refused = jest.fn(); + map.instance.on('nodeProtected', refused); + + expect(map.instance.addNode({ name: 'x' }, parent)).toBeNull(); + map.instance.removeNode(child); + + expect(map.instance.existNode(child)).toBe(true); + expect(map.instance.nodeChildren(parent)).toHaveLength(1); + expect(refused).toHaveBeenCalledTimes(2); + }); + + it('names the protecting node and releases the branch', () => { + const { map, parent, child } = makeProtectedMap(); + + expect(map.instance.protectingNode(child)).toBe(parent); + map.instance.releaseBranch(child); + + expect(map.instance.protectingNode(child)).toBeNull(); + }); +}); + +describe('events', () => { + it('announces a selection and the deselect a removal causes', () => { + const map = makeMap(); + const selected: string[] = []; + const deselected: string[] = []; + const changes = jest.fn(); + map.instance.on('nodeSelect', node => selected.push(node.id)); + map.instance.on('nodeDeselect', node => deselected.push(node.id)); + map.instance.on('mapChange', changes); + const root = rootOf(map); + + const node = map.instance.addNode({ name: 'a' }); + if (!node) throw new Error('addNode added no node'); + map.instance.updateNode('backgroundColor', '#ff0000', node.id); + map.instance.selectNode(node.id); + map.instance.removeNode(node.id); + + expect(selected).toEqual([node.id]); + expect(deselected).toEqual([root, node.id]); + expect(changes).toHaveBeenCalledTimes(3); + }); + + it('fires no event when it hides child nodes', () => { + const { map, child } = makeMapWithChild(); + const root = rootOf(map); + map.instance.selectNode(root); + const shared = jest.fn(); + ALL_EVENTS.forEach(event => map.instance.on(event, shared)); + + map.instance.toggleBranchVisibility(); + + expect(map.instance.childNodesHidden()).toBe(true); + expect(map.instance.childNodesHidden(child)).toBe(false); + expect(nodeDom(child).style.visibility).toBe('hidden'); + expect(shared).not.toHaveBeenCalled(); + }); + + it('refuses an event it does not know', () => { + const map = makeMap(); + + expect(() => + map.instance.on('nodeHover' as 'nodeSelect', jest.fn()) + ).toThrow(); + }); +}); + +describe('create', () => { + /** root -> a -> b, root -> c. */ + const SNAPSHOT: MapSnapshot = [ + nodeRecord({ id: 'root', isRoot: true, name: 'Root' }), + nodeRecord({ id: 'a', parent: 'root', coordinates: { x: 200, y: 0 } }), + nodeRecord({ id: 'b', parent: 'a', coordinates: { x: 400, y: 0 } }), + nodeRecord({ id: 'c', parent: 'root', coordinates: { x: -200, y: 0 } }), + ]; + + it('draws every node of a filled map data and selects the main root', () => { + const map = makeMapOver(new InMemoryMapData(SNAPSHOT)); + + expect(drawnIds()).toEqual(['root', 'a', 'b', 'c']); + expect(d3.selectAll('path.branch').size()).toBe(3); + expect(map.instance.getSelectedNode()?.id).toBe('root'); + }); + + it('draws nothing and selects nothing for an empty map data', () => { + const map = makeMapOver(new InMemoryMapData()); + + expect(drawnIds()).toEqual([]); + expect(map.instance.getSelectedNode()).toBeNull(); + expect(map.instance.exportRootProperties()).toBeNull(); + }); +}); + +describe('changes written to the map data', () => { + function makePeerMap() { + const data = new InMemoryMapData([ + nodeRecord({ id: 'root', isRoot: true, name: 'Root' }), + nodeRecord({ id: 'a', parent: 'root', coordinates: { x: 200, y: 0 } }), + nodeRecord({ id: 'b', parent: 'a', coordinates: { x: 400, y: 0 } }), + ]); + const map = makeMapOver(data); + return { data, map }; + } + + it('draws a peer write and keeps the selection', () => { + const { data, map } = makePeerMap(); + map.instance.selectNode('a'); + const deselect = jest.fn(); + map.instance.on('nodeDeselect', deselect); + + data.addNodes([ + nodeRecord({ id: 'd', parent: 'a', coordinates: { x: 400, y: 80 } }), + ]); + data.updateNode('b', 'name', 'Peer'); + + expect(drawnIds()).toContain('d'); + expect(nameDom('b').innerHTML).toBe('Peer'); + expect(map.instance.getSelectedNode()?.id).toBe('a'); + expect(deselect).not.toHaveBeenCalled(); + }); + + it('deselects a selected node a peer removes', () => { + const { data, map } = makePeerMap(); + map.instance.selectNode('b'); + const deselected: string[] = []; + map.instance.on('nodeDeselect', node => deselected.push(node.id)); + + data.removeNode('a'); + + expect(deselected).toEqual(['b']); + expect(map.instance.getSelectedNode()).toBeNull(); + expect(drawnIds()).toEqual(['root']); + expect(d3.selectAll('path.branch').size()).toBe(0); + }); + + it('selects the main root of a replaced map', () => { + const { data, map } = makePeerMap(); + map.instance.selectNode('b'); + const deselect = jest.fn(); + map.instance.on('nodeDeselect', deselect); + + data.replaceMap([ + nodeRecord({ id: 'other', isRoot: true, name: 'Other' }), + nodeRecord({ id: 'x', parent: 'other' }), + ]); + + expect(drawnIds()).toEqual(['other', 'x']); + expect(map.instance.getSelectedNode()?.id).toBe('other'); + expect(deselect).not.toHaveBeenCalled(); + }); + + it('keeps the selection and the view when a peer renames the main root', () => { + const { data, map } = makePeerMap(); + map.instance.selectNode('a'); + const svg = map.dom.svg.node(); + if (!svg) throw new Error('no svg'); + map.dom.svg.call(map.zoom.getZoomBehavior().translateTo, 300, 200); + const transform = d3.zoomTransform(svg).toString(); + const changes: MapDataChange[] = []; + data.subscribe(change => changes.push(change)); + + data.updateNode('root', 'name', 'Renamed'); + + expect(changes[0].replaced).toBe(false); + expect(nameDom('root').innerHTML).toBe('Renamed'); + expect(map.instance.getSelectedNode()?.id).toBe('a'); + expect(d3.zoomTransform(svg).toString()).toBe(transform); + }); + + it('draws a recolor reading the records of that node and its ancestors only', () => { + const siblings = Array.from({ length: 20 }, (_, i) => + nodeRecord({ id: 's' + i, parent: 'root', coordinates: { x: 200, y: i } }) + ); + const data = new InMemoryMapData([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ id: 'a', parent: 'root', coordinates: { x: 200, y: 0 } }), + ...siblings, + ]); + makeMapOver(data); + const read = jest.spyOn(data, 'node'); + const scan = jest.spyOn(data, 'nodes'); + + data.updateNode('a', 'backgroundColor', '#ff0000'); + + expect(scan).not.toHaveBeenCalled(); + expect(new Set(read.mock.calls.map(([id]) => id))).toEqual( + new Set(['a', 'root']) + ); + expect(read.mock.calls.length).toBeLessThanOrEqual(4); + }); +}); + +describe('returned nodes', () => { + it('leaves the map data unchanged when the caller changes a copy', () => { + const { map, child } = makeMapWithChild(); + const before = exported(map, child); + + const copies = [ + map.instance.selectNode(), + map.instance.getSelectedNode(), + map.instance.exportRootProperties(), + ...map.instance.nodeChildren(rootOf(map)), + ...map.instance.exportAsJSON(), + ]; + for (const copy of copies) { + if (!copy) continue; + copy.name = 'changed'; + if (copy.coordinates) copy.coordinates.x = 999; + if (copy.colors) copy.colors.background = '#000000'; + } + + expect(exported(map, child)).toEqual(before); + expect(exported(map, rootOf(map)).name).not.toBe('changed'); + }); +}); + +describe('editing a name', () => { + it('commits the name of A when B takes the selection', () => { + const { map, child } = makeMapWithChild(); + const root = rootOf(map); + map.instance.editNode(); + nameDom(child).innerHTML = 'Typed'; + + map.instance.selectNode(root); + + expect(exported(map, child).name).toBe('Typed'); + expect(map.instance.getSelectedNode()?.id).toBe(root); + }); +}); + +describe('batched writes', () => { + function countChanges(data: InMemoryMapData) { + const listener = jest.fn(); + data.subscribe(listener); + return listener; + } + + function makeTree() { + const data = new InMemoryMapData([ + nodeRecord({ id: 'root', isRoot: true }), + nodeRecord({ + id: 'a', + parent: 'root', + coordinates: { x: 200, y: 0 }, + protected: true, + }), + // Off the layout, so distribute moves it. + nodeRecord({ id: 'b', parent: 'a', coordinates: { x: 999, y: 999 } }), + nodeRecord({ id: 'c', parent: 'root', coordinates: { x: -200, y: 0 } }), + ]); + return { data, map: makeMapOver(data) }; + } + + it('protects a branch in one change', () => { + const { data, map } = makeTree(); + const listener = countChanges(data); + + map.instance.protectBranch('root'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(data.node('root')?.protected).toBe(true); + expect(data.node('a')?.protected).toBe(false); + }); + + it('distributes the nodes in one change', () => { + const { data, map } = makeTree(); + const listener = countChanges(data); + + map.instance.distributeNodes(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('pastes a branch in one change', () => { + const { data, map } = makeTree(); + map.instance.copyNode('a'); + const listener = countChanges(data); + + map.instance.pasteNode('c'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(data.nodes()).toHaveLength(6); + }); +}); + +describe('destroy', () => { + it('removes the svg, every subscription and the resize listener', () => { + const data = new InMemoryMapData(); + const map = makeMapOver(data); + map.instance.new(); + const ref = map.dom.container.node(); + const listener = jest.fn(); + map.instance.on('nodeSelect', listener); + const onChange = jest.spyOn(map.nodes, 'drawReplaced'); + + map.instance.destroy(); + data.replaceMap([nodeRecord({ id: 'root', isRoot: true })]); + + expect(ref?.querySelector('svg')).toBeNull(); + expect(d3.select(window).on('resize.map')).toBeUndefined(); + expect(onChange).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('new with nodes', () => { + it('keeps the view state and leaves the passed nodes unchanged', () => { + const { map, child } = makeMapWithChild(); + const root = rootOf(map); + map.instance.selectNode(root); + map.instance.toggleBranchVisibility(); + const nodes = map.instance.exportAsJSON(); + const copy = JSON.parse(JSON.stringify(nodes)); + + map.instance.new(deepFreeze(nodes)); + + expect(nodes).toEqual(copy); + expect(map.instance.childNodesHidden(root)).toBe(true); + expect(nodeDom(child).style.visibility).toBe('hidden'); + }); + + it('exports nodes without view state', () => { + const { map } = makeMapWithChild(); + map.instance.selectNode(rootOf(map)); + map.instance.toggleBranchVisibility(); + + for (const node of map.instance.exportAsJSON()) { + expect(node).not.toHaveProperty('hidden'); + expect(node).not.toHaveProperty('hasHiddenChildNodes'); + } + }); + + it('leaves a legacy map unchanged', () => { + const map = makeMap(); + const legacy: OldMmpNode[] = [ + { + key: 'node0', + value: { + name: 'Legacy', + x: 0, + y: 0, + k: 1, + 'background-color': '#ffffff', + 'text-color': '#000000', + }, + }, + ]; + const copy = JSON.parse(JSON.stringify(legacy)); + + map.instance.new(deepFreeze(legacy) as unknown as MapSnapshot); + + expect(legacy).toEqual(copy); + expect(map.instance.exportAsJSON().map(n => n.name)).toEqual(['Legacy']); + }); + + it('refuses a map whose node carries an invalid color', () => { + const map = makeMap(); + const nodes = map.instance + .exportAsJSON() + .map(node => ({ ...node, colors: { ...node.colors, name: 'red' } })); + + expect(() => map.instance.new(nodes)).toThrow( + 'The exported map is not correct' + ); + }); +}); diff --git a/packages/mmp/src/map/models/node.ts b/packages/mmp/src/map/models/node.ts deleted file mode 100644 index 93975dd7..00000000 --- a/packages/mmp/src/map/models/node.ts +++ /dev/null @@ -1,154 +0,0 @@ -import * as d3 from 'd3'; -import type { - MapNodeCoordinates, - MapNodeDimensions, - MapNodeColors, - MapNodeFont, - MapNodeImage, - MapNodeLink, - Resolved, - UserNodeProperties, -} from '@teammapper/shared'; - -/** - * Style values of a node in the map. The shared `MapNode*` counterparts leave - * every field optional because the wire format may omit it. A Node the map - * holds always has a resolved value for each of them. - */ -export type NodeColors = Resolved; -export type NodeFont = Resolved; -export type NodeImage = Resolved; -export type NodeLink = Resolved; - -/** - * Model of the nodes. - */ -export default class Node implements NodeProperties { - public id: string; - public parent: Node | null; - public k: number; - - public name: string; - public dimensions: MapNodeDimensions; - public coordinates: MapNodeCoordinates; - public image: NodeImage; - public colors: NodeColors; - public font: NodeFont; - public link: NodeLink; - public protected: boolean; - public dom!: SVGGElement; - public isRoot: boolean; - public hidden: boolean; - public hasHiddenChildNodes: boolean; - - /** - * Initialize the node properties, the dimensions and the k coefficient. - * @param {NodeProperties} properties - */ - constructor(properties: NodeProperties) { - this.id = properties.id; - this.parent = properties.parent; - this.name = properties.name || ''; - this.coordinates = properties.coordinates || { x: 0, y: 0 }; - this.colors = { - name: properties.colors?.name || '', - background: properties.colors?.background || '', - branch: properties.colors?.branch || '', - link: properties.colors?.link || '', - }; - this.image = { - src: properties.image?.src || '', - size: properties.image?.size || 0, - }; - this.font = { - size: properties.font?.size || 12, - style: properties.font?.style || 'normal', - weight: properties.font?.weight || 'normal', - }; - this.link = { href: properties.link?.href || '' }; - this.protected = Boolean(properties.protected); - this.isRoot = Boolean(properties.isRoot); - this.hidden = Boolean(properties.hidden); - this.hasHiddenChildNodes = Boolean(properties.hasHiddenChildNodes); - - this.dimensions = { - width: 0, - height: 0, - }; - this.k = properties.k || d3.randomUniform(-20, 20)(); - } - - /** - * Return the level of the node. - * @returns {number} level - */ - public getLevel(): number { - let level = 1, - parent = this.parent; - - while (parent) { - level++; - parent = parent.parent; - } - - return level; - } - - /** - * Return the div element of the node name. - * @returns {HTMLDivElement} div - */ - public getNameDOM(): HTMLDivElement { - return this.dom.querySelector('foreignObject > div') as HTMLDivElement; - } - - /** - * Return the SVG path of the node background. - * @returns {SVGPathElement} path - */ - public getBackgroundDOM(): SVGPathElement { - return this.dom.querySelector('path') as SVGPathElement; - } - - /** - * Return the SVG image of the node image. - * @returns {SVGImageElement} image - */ - public getImageDOM(): SVGImageElement { - return this.dom.querySelector('image') as SVGImageElement; - } - - /** - * Return the SVG a of the node link. - * @returns {SVGIAElement} a - */ - public getLinkDOM(): SVGAElement { - return this.dom.querySelector( - 'a > text.link-text' - ) as SVGAElement; - } - - /** - * Returns the SVG text of the hidden child icon. - * @returns {SVGITextElement} text - */ - public getHiddenChildIconDOM(): SVGTextElement { - return this.dom.querySelector( - 'text.hidden-icon' - ) as SVGTextElement; - } - - /** - * Returns the SVG text of the protection lock badge. - * @returns {SVGTextElement | null} text - */ - public getProtectionIconDOM(): SVGTextElement | null { - return this.dom.querySelector('text.protected-icon'); - } -} - -export interface NodeProperties extends UserNodeProperties { - id: string; - parent: Node | null; - k?: number; -} diff --git a/packages/mmp/src/map/options.ts b/packages/mmp/src/map/options.ts index 2d974ab4..cea77185 100644 --- a/packages/mmp/src/map/options.ts +++ b/packages/mmp/src/map/options.ts @@ -1,4 +1,9 @@ -import { NodeColors, NodeFont, NodeImage, NodeLink } from './models/node.js'; +import type { + NodeColors, + NodeFont, + NodeImage, + NodeLink, +} from './data/node-record.js'; import type { ImageReference, MapNodeCoordinates, @@ -90,8 +95,8 @@ export default class Options implements OptionParameters { this.drag = flag; - this.map.draw.clear(); - this.map.draw.update(); + // The drag behavior attaches when a node's DOM is created. + this.map.draw.drawAll(); this.map.nodes.redrawSelectionRing(); } @@ -106,8 +111,7 @@ export default class Options implements OptionParameters { this.edit = flag; - this.map.draw.clear(); - this.map.draw.update(); + this.map.draw.drawAll(); this.map.nodes.redrawSelectionRing(); } } @@ -136,7 +140,6 @@ export const DefaultNodeValues: DefaultNodeProperties = { style: 'normal', weight: 'normal', }, - hidden: false, isRoot: false, }; @@ -165,7 +168,6 @@ export const DefaultRootNodeValues: DefaultNodeProperties = { weight: 'normal', }, isRoot: true, - hidden: false, }; export interface DefaultNodeProperties { @@ -176,7 +178,6 @@ export interface DefaultNodeProperties { colors: NodeColors; font: NodeFont; isRoot: boolean; - hidden: boolean; } /** diff --git a/packages/mmp/src/test/fake-draw.ts b/packages/mmp/src/test/fake-draw.ts new file mode 100644 index 00000000..e3249353 --- /dev/null +++ b/packages/mmp/src/test/fake-draw.ts @@ -0,0 +1,53 @@ +import type { + MapNodeCoordinates, + MapNodeDimensions, + MapNodeFont, +} from '@teammapper/shared'; +import Draw from '../map/handlers/draw.js'; +import { estimateNodeExtent } from '../map/handlers/node-geometry.js'; +import type { ResolvedNode } from '../map/data/node-record.js'; + +/** + * Stands in for the renderer in specs that drive the node handler without a + * DOM. It keeps the rings and the drag preview the way the renderer does and + * draws nothing. A node has the size `sizeOf` returns, and no size by + * default. + */ +export function fakeDraw( + sizeOf: (node: ResolvedNode) => MapNodeDimensions = () => ({ + width: 0, + height: 0, + }) +) { + const rings = new Map(); + const preview = new Map(); + + return { + drawAll: jest.fn(() => rings.clear()), + redrawAll: jest.fn(), + drawNodes: jest.fn(), + removeNodes: jest.fn((): string[] => []), + renderPositions: jest.fn(), + enableNodeNameEditing: jest.fn(), + blurName: jest.fn(), + isEditing: () => false, + ringColor: (node: ResolvedNode) => Draw.prototype.ringColor(node), + ringOf: (id: string) => rings.get(id) ?? null, + setRing: (id: string, color: string | null) => { + if (color) rings.set(id, color); + else rings.delete(id); + }, + previewOf: (id: string) => preview.get(id), + setPreview: (id: string, position: MapNodeCoordinates) => { + preview.set(id, { ...position }); + }, + takePreview: () => { + const positions = new Map(preview); + preview.clear(); + return positions; + }, + dimensionsOf: sizeOf, + estimateExtent: (name: string, font: MapNodeFont) => + estimateNodeExtent(name, font.size ?? 16), + }; +} diff --git a/packages/mmp/src/test/stub-map.ts b/packages/mmp/src/test/stub-map.ts new file mode 100644 index 00000000..74de72a9 --- /dev/null +++ b/packages/mmp/src/test/stub-map.ts @@ -0,0 +1,99 @@ +import * as d3 from 'd3'; +import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; +import InMemoryMapData from '../map/data/in-memory-map-data.js'; +import CopyPaste from '../map/handlers/copy-paste.js'; +import Drag from '../map/handlers/drag.js'; +import MapLoader from '../map/handlers/map-loader.js'; +import Nodes from '../map/handlers/nodes.js'; +import ViewState from '../map/handlers/view-state.js'; +import MmpMap from '../map/map.js'; +import { DefaultNodeValues, DefaultRootNodeValues } from '../map/options.js'; +import { fakeDraw } from './fake-draw.js'; + +/** + * A full node record with the default styling, `overrides` applied. Every + * node starts at the origin, unprotected, with k 1 and no parent. + */ +export function nodeRecord( + overrides: Partial & { id: string } +): ExportNodeProperties { + return { + parent: null, + k: 1, + name: '', + coordinates: { x: 0, y: 0 }, + image: { ...DefaultNodeValues.image }, + colors: { ...DefaultNodeValues.colors }, + font: { ...DefaultNodeValues.font }, + link: { ...DefaultNodeValues.link }, + protected: false, + isRoot: false, + ...overrides, + }; +} + +/** + * A map without a DOM around the real node handler, view state, loader, + * drag and clipboard, over an `InMemoryMapData` holding `snapshot`. The + * renderer is `fakeDraw`, the zoom pans and centers nothing, and every event + * goes to the `events.emit` mock. `overrides` replaces any of these + * stand-ins. The map draws the snapshot as a replaced map, which selects the + * main root, and then clears the `events.emit` mock. + */ +export function stubMap( + snapshot: MapSnapshot = [], + overrides: Record = {} +) { + const data = new InMemoryMapData(snapshot); + const parts = { + id: 'test-map', + data, + options: { + defaultNode: DefaultNodeValues, + rootNode: DefaultRootNodeValues, + fontFamily: 'Arial', + }, + draw: fakeDraw(), + events: { emit: jest.fn() }, + zoom: { + center: jest.fn(), + panIntoView: jest.fn(), + visibleArea: () => null, + }, + dom: { svg: { node: () => null } }, + ...overrides, + }; + const map = parts as unknown as MmpMap; + map.viewState = new ViewState(); + map.nodes = new Nodes(map); + map.loader = new MapLoader(map); + map.copyPaste = new CopyPaste(map); + map.drag = new Drag(map); + + map.data.subscribe(map.nodes.onChange); + map.nodes.drawReplaced(); + parts.events.emit.mockClear(); + + return { map, nodes: map.nodes, ...parts }; +} + +/** The calls of the `events.emit` mock for `event`. */ +export function emitted(emit: jest.Mock, event: string): unknown[] { + return emit.mock.calls + .filter(([name]) => name === event) + .map(([, payload]) => payload); +} + +/** The event names mmp fired, in order. */ +export function firedEvents(events: { emit: jest.Mock }): string[] { + return events.emit.mock.calls.map(call => call[0]); +} + +/** + * The ring selection draws on a background filled with `fill`: the fill + * darkened by 0.5. Computed here apart from `Draw.ringColor`, so a change + * there fails the ring specs. + */ +export function ring(fill: string): string | undefined { + return d3.color(fill)?.darker(0.5).toString(); +} diff --git a/packages/mmp/src/test/svg-lengths.ts b/packages/mmp/src/test/svg-lengths.ts new file mode 100644 index 00000000..be065a11 --- /dev/null +++ b/packages/mmp/src/test/svg-lengths.ts @@ -0,0 +1,17 @@ +/** + * Give every svg a width of 800 and a height of 600 for d3-zoom. jsdom + * implements no SVG lengths, and d3-zoom reads the extent of an svg without a + * view box from them. A map centers while it is created, before a spec could + * set a view box. + */ +export function stubSvgLengths() { + for (const [side, value] of [ + ['width', 800], + ['height', 600], + ] as const) { + Object.defineProperty(SVGSVGElement.prototype, side, { + configurable: true, + get: () => ({ baseVal: { value } }), + }); + } +} diff --git a/packages/mmp/src/utils/utils.ts b/packages/mmp/src/utils/utils.ts index 3e211e6b..1124ac8a 100644 --- a/packages/mmp/src/utils/utils.ts +++ b/packages/mmp/src/utils/utils.ts @@ -30,21 +30,6 @@ export default class Utils { } } - /** - * Convert an Object to an array. - * @param {object} object - * @returns {Array} - */ - static fromObjectToArray(object: Record): T[] { - const array: T[] = []; - - for (const p in object) { - array.push(object[p]); - } - - return array; - } - /** * Merge two objects. * @param {object} object1 diff --git a/packages/mmp/tsconfig.build.json b/packages/mmp/tsconfig.build.json index 37b05cda..abef6941 100644 --- a/packages/mmp/tsconfig.build.json +++ b/packages/mmp/tsconfig.build.json @@ -5,5 +5,5 @@ "noEmit": false, "outDir": "./dist" }, - "exclude": ["node_modules", "dist", "src/**/*.spec.ts"] + "exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/test"] } diff --git a/packages/shared/src/cache/normalizeMapData.spec.ts b/packages/shared/src/cache/normalizeMapData.spec.ts index 2e885294..08ebfe49 100644 --- a/packages/shared/src/cache/normalizeMapData.spec.ts +++ b/packages/shared/src/cache/normalizeMapData.spec.ts @@ -8,7 +8,7 @@ describe('normalizeMapData', () => { expect(normalized.options).toEqual({}); }); - it('normalizes legacy node missing folding properties to default false', () => { + it('normalizes a legacy node and gives it no hidden keys', () => { const legacyNode = { id: 'n1', parent: null, @@ -18,24 +18,30 @@ describe('normalizeMapData', () => { }; const node = normalizeMapNode(legacyNode); - expect(node.hidden).toBe(false); - expect(node.hasHiddenChildNodes).toBe(false); + expect(node).not.toHaveProperty('hidden'); + expect(node).not.toHaveProperty('hasHiddenChildNodes'); expect(node.name).toBe('Legacy Node'); expect(node.coordinates).toEqual({ x: 50, y: 50 }); }); - it('preserves branch folding flags if present in cached data', () => { - const foldedNode = { + it('drops the hidden keys of cached data', () => { + const cachedNode = { id: 'n2', parent: 'n1', - name: 'Folded Node', + name: 'Cached Node', hidden: true, hasHiddenChildNodes: true, }; - const node = normalizeMapNode(foldedNode); - expect(node.hidden).toBe(true); - expect(node.hasHiddenChildNodes).toBe(true); + const node = normalizeMapNode(cachedNode); + expect(node).not.toHaveProperty('hidden'); + expect(node).not.toHaveProperty('hasHiddenChildNodes'); + }); + + it('gives the default node no hidden keys', () => { + const node = normalizeMapNode(null); + expect(node).not.toHaveProperty('hidden'); + expect(node).not.toHaveProperty('hasHiddenChildNodes'); }); it('neutralizes prototype pollution properties', () => { @@ -54,7 +60,7 @@ describe('normalizeMapData', () => { uuid: 'map-uuid-1', data: [ { id: 'root', parent: null, name: 'Root' }, - { id: 'child-1', parent: 'root', name: 'Child 1', hidden: true }, + { id: 'child-1', parent: 'root', name: 'Child 1' }, ], options: { fontMaxSize: 24, @@ -66,7 +72,7 @@ describe('normalizeMapData', () => { expect(map.uuid).toBe('map-uuid-1'); expect(map.data.length).toBe(2); expect(map.data[0].isRoot).toBe(true); - expect(map.data[1].hidden).toBe(true); + expect(map.data[1].name).toBe('Child 1'); expect(map.options.fontMaxSize).toBe(24); expect(map.deleteAfterDays).toBe(30); }); diff --git a/packages/shared/src/cache/normalizeMapData.ts b/packages/shared/src/cache/normalizeMapData.ts index 5dafdb7b..7266f118 100644 --- a/packages/shared/src/cache/normalizeMapData.ts +++ b/packages/shared/src/cache/normalizeMapData.ts @@ -27,8 +27,6 @@ export const normalizeMapNode = (raw: unknown): MapNode => { link: { href: '' }, protected: false, k: 0, - hidden: false, - hasHiddenChildNodes: false, }; } @@ -121,8 +119,6 @@ export const normalizeMapNode = (raw: unknown): MapNode => { link, protected: Boolean(r.protected), k: typeof r.k === 'number' && !isNaN(r.k) ? r.k : 0, - hidden: Boolean(r.hidden), - hasHiddenChildNodes: Boolean(r.hasHiddenChildNodes), }; }; diff --git a/packages/shared/src/models/index.ts b/packages/shared/src/models/index.ts index faa95e4b..0a5b5136 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -58,8 +58,6 @@ export interface UserNodeProperties { /** Protects this node and its descendants against local edits. */ protected?: boolean; isRoot?: boolean; - hidden?: boolean; - hasHiddenChildNodes?: boolean; } export interface MapNode extends UserNodeProperties { @@ -183,22 +181,11 @@ export type NodeProperty = | 'fontWeight' | 'fontStyle' | 'fontSize' - | 'nameColor' - | 'hidden'; + | 'nameColor'; export type NodePropertyValue = string | number | boolean | MapNodeCoordinates | null | undefined; -export interface NodeUpdateEvent { - nodeProperties: ExportNodeProperties; - previousValue: unknown; - changedProperty: NodeProperty | string; -} - -export interface MapCreateEvent { - previousMapData?: MapSnapshot; -} - export interface CachedMap { lastModified: number; createdAt: number; @@ -251,15 +238,11 @@ export interface OldMmpNode { } export interface MmpEventPayloadMap { - create: MapCreateEvent; nodeSelect: ExportNodeProperties; nodeDeselect: ExportNodeProperties; - nodeUpdate: NodeUpdateEvent; - nodeCreate: ExportNodeProperties; - nodePaste: ExportNodeProperties[]; - nodeRemove: ExportNodeProperties; - distribute: void; nodeProtected: ExportNodeProperties; + /** The map data changed, by a local, a peer's or an undo write. */ + mapChange: void; } export type MmpEventType = keyof MmpEventPayloadMap; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 2e984817..479ae2da 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -11,9 +11,11 @@ import { MAX_MERMAID_DESCRIPTION_LENGTH, SUPPORTED_LANGUAGES, } from '../constants'; +import type { NodeProperty } from '../models'; +// An empty string means no color, as on the branch of a root node. export const CssColorSchema = v.nullable( - v.pipe(v.string(), v.regex(/^#[0-9a-fA-F]{3,8}$/)) + v.union([v.literal(''), v.pipe(v.string(), v.regex(/^#[0-9a-fA-F]{3,8}$/))]) ); export const ColorSchema = v.partial( @@ -47,12 +49,16 @@ export const ImageSchema = v.partial( export const LinkSchema = v.partial( v.object({ + // An empty string means no link. href: v.nullable( - v.pipe( - v.string(), - v.regex(/^https?:\/\//i), - v.maxLength(MAX_LINK_HREF_LENGTH) - ) + v.union([ + v.literal(''), + v.pipe( + v.string(), + v.regex(/^https?:\/\//i), + v.maxLength(MAX_LINK_HREF_LENGTH) + ), + ]) ), }) ); @@ -73,10 +79,24 @@ export const NodeSchema = v.object({ parent: v.nullable(v.string()), isRoot: v.boolean(), protected: v.optional(v.boolean(), false), - hidden: v.optional(v.boolean(), false), - hasHiddenChildNodes: v.optional(v.boolean(), false), }); +/** The value each node property accepts in a single node update. */ +export const NodePropertySchemas = { + name: NodeBasicsSchema.entries.name, + protected: v.boolean(), + coordinates: CoordinatesSchema, + imageSrc: ImageSchema.entries.src, + imageSize: ImageSchema.entries.size, + linkHref: LinkSchema.entries.href, + backgroundColor: CssColorSchema, + branchColor: CssColorSchema, + fontWeight: FontSchema.entries.weight, + fontStyle: FontSchema.entries.style, + fontSize: FontSchema.entries.size, + nameColor: CssColorSchema, +} satisfies Record; + export const MapOptionsSchema = v.partial( v.object({ fontMaxSize: v.number(), diff --git a/packages/shared/src/schemas/schemas.spec.ts b/packages/shared/src/schemas/schemas.spec.ts index adb41d87..be961a9e 100644 --- a/packages/shared/src/schemas/schemas.spec.ts +++ b/packages/shared/src/schemas/schemas.spec.ts @@ -12,9 +12,47 @@ import { NodeBasicsSchema, NodeSchema, sanitizeIssues, + NodePropertySchemas, } from './index'; +import type { NodeProperty } from '../models'; describe('Shared Validation Schemas', () => { + describe('NodePropertySchemas', () => { + it.each<[NodeProperty, unknown]>([ + ['name', 'A name'], + ['protected', true], + ['coordinates', { x: 1, y: 2 }], + ['imageSrc', 'image:5f0c4b1e-3a8d-4c6e-9f2a-7b1d2e3f4a5b'], + ['imageSize', 60], + ['linkHref', 'https://example.com'], + ['backgroundColor', '#f9f9f9'], + ['branchColor', ''], + ['fontWeight', 'bold'], + ['fontStyle', 'italic'], + ['fontSize', 16], + ['nameColor', null], + ])('accepts %s %p', (property, value) => { + expect(v.safeParse(NodePropertySchemas[property], value).success).toBe( + true + ); + }); + + it.each<[NodeProperty, unknown]>([ + ['name', 42], + ['protected', 'yes'], + ['coordinates', { x: 'left', y: 0 }], + ['imageSize', '60'], + ['linkHref', 'javascript:alert(1)'], + ['backgroundColor', 'red'], + ['fontWeight', 'x'.repeat(1000)], + ['fontSize', 'big'], + ])('refuses %s %p', (property, value) => { + expect(v.safeParse(NodePropertySchemas[property], value).success).toBe( + false + ); + }); + }); + describe('Sub-schemas', () => { it('validates ColorSchema with valid hex, null, or empty object', () => { expect(v.safeParse(ColorSchema, {}).success).toBe(true); @@ -40,6 +78,10 @@ describe('Shared Validation Schemas', () => { expect(v.safeParse(ColorSchema, { name: 123 }).success).toBe(false); }); + it('accepts an empty color, which means no color', () => { + expect(v.safeParse(ColorSchema, { branch: '' }).success).toBe(true); + }); + it('validates CoordinatesSchema', () => { expect(v.safeParse(CoordinatesSchema, { x: 10, y: 20 }).success).toBe( true @@ -92,6 +134,7 @@ describe('Shared Validation Schemas', () => { it('validates LinkSchema', () => { expect(v.safeParse(LinkSchema, {}).success).toBe(true); expect(v.safeParse(LinkSchema, { href: null }).success).toBe(true); + expect(v.safeParse(LinkSchema, { href: '' }).success).toBe(true); expect( v.safeParse(LinkSchema, { href: 'https://example.com' }).success ).toBe(true); @@ -167,29 +210,15 @@ describe('Shared Validation Schemas', () => { }, protected: true, k: 1.5, - hidden: true, - hasHiddenChildNodes: true, }; - it('successfully parses a canonical valid node with folding properties', () => { - const result = v.safeParse(NodeSchema, validNode); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output.hidden).toBe(true); - expect(result.output.hasHiddenChildNodes).toBe(true); - } - }); - - it('defaults hidden and hasHiddenChildNodes to false when omitted', () => { - const withoutFolding = { ...validNode }; - delete (withoutFolding as { hidden?: boolean }).hidden; - delete (withoutFolding as { hasHiddenChildNodes?: boolean }) - .hasHiddenChildNodes; - const result = v.safeParse(NodeSchema, withoutFolding); + it('accepts and drops the hidden keys of older files', () => { + const legacy = { ...validNode, hidden: true, hasHiddenChildNodes: true }; + const result = v.safeParse(NodeSchema, legacy); expect(result.success).toBe(true); if (result.success) { - expect(result.output.hidden).toBe(false); - expect(result.output.hasHiddenChildNodes).toBe(false); + expect(result.output).not.toHaveProperty('hidden'); + expect(result.output).not.toHaveProperty('hasHiddenChildNodes'); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db5019bd..ab70dd3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,9 +25,9 @@ overrides: js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5.0.0' js-yaml@>=3.0.0 <3.15.1: '>=3.15.1 <4.0.0' '@babel/core@>=7.0.0 <7.29.6': '>=7.29.6 <8.0.0' - piscina@>=5.0.0-alpha.0 <5.1.5: '>=5.1.5 <6.0.0' + piscina@>=5.0.0-alpha.0 <5.3.2: '>=5.3.2 <6.0.0' vite@>=7.0.0 <7.3.5: '>=7.3.5 <8.0.0' - dompurify@>=3.0.0 <3.4.9: '>=3.4.9 <4.0.0' + dompurify@>=3.0.0 <3.4.16: '>=3.4.16 <4.0.0' websocket-driver@<0.7.5: '>=0.7.5 <0.8.0' brace-expansion@<1.1.18: '>=1.1.18 <2.0.0' brace-expansion@>=2.0.0 <2.1.4: '>=2.1.4 <3.0.0' @@ -73,8 +73,8 @@ importers: packages/mermaid-mindmap-parser: dependencies: dompurify: - specifier: 3.4.15 - version: 3.4.15 + specifier: 3.4.16 + version: 3.4.16 devDependencies: jison: specifier: 0.4.18 @@ -83,8 +83,8 @@ importers: specifier: ~5.9.3 version: 5.9.3 vite: - specifier: ^8.3.0 - version: 8.3.0(@types/node@25.8.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.97.3)(terser@5.47.1) + specifier: ^8.3.1 + version: 8.3.1(@types/node@25.8.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.97.3)(terser@5.47.1) packages/mmp: dependencies: @@ -95,11 +95,14 @@ importers: specifier: 7.9.0 version: 7.9.0 dompurify: - specifier: 3.4.15 - version: 3.4.15 + specifier: 3.4.16 + version: 3.4.16 uuid: specifier: 13.0.2 version: 13.0.2 + valibot: + specifier: ^1.5.0 + version: 1.5.0(typescript@5.9.3) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -115,13 +118,13 @@ importers: version: 10.10.0(jiti@2.7.0) jest: specifier: ^30.5.1 - version: 30.5.1(@types/node@24.13.5) + version: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) jest-environment-jsdom: specifier: ^30.5.1 version: 30.5.1 ts-jest: specifier: 29.4.12 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1)(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -152,10 +155,10 @@ importers: version: 10.10.0(jiti@2.7.0) jest: specifier: ^30.5.1 - version: 30.5.1(@types/node@24.13.5) + version: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) ts-jest: specifier: 29.4.12 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1(@types/node@24.13.5))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -360,7 +363,7 @@ importers: dependencies: '@angular-devkit/build-angular': specifier: 21.2.24 - version: 21.2.24(@angular/compiler-cli@21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.5)(chokidar@5.0.0)(jest-environment-jsdom@30.5.1)(jest@30.5.1(@types/node@24.13.5))(jiti@2.7.0)(lightningcss@1.33.0)(typescript@5.9.3) + version: 21.2.24(@angular/compiler-cli@21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.5)(chokidar@5.0.0)(jest-environment-jsdom@30.5.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(jiti@2.7.0)(lightningcss@1.33.0)(typescript@5.9.3) '@angular/animations': specifier: 21.2.23 version: 21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)) @@ -428,8 +431,8 @@ importers: specifier: ^16.0.1 version: 16.0.1 dompurify: - specifier: 3.4.15 - version: 3.4.15 + specifier: 3.4.16 + version: 3.4.16 jspdf: specifier: ^4.2.1 version: 4.2.1 @@ -466,7 +469,7 @@ importers: devDependencies: '@angular-builders/jest': specifier: ^21.0.4 - version: 21.0.4(19dc25c2d515bca80ffe7c7c09218a06) + version: 21.0.4(4a6ca0f3f1ce97539a2bd6ad33352055) '@angular-eslint/builder': specifier: 21.4.0 version: 21.4.0(@angular/cli@21.2.24(@types/node@24.13.5)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) @@ -508,7 +511,7 @@ importers: version: 17.12.0 jest: specifier: ^30.5.1 - version: 30.5.1(@types/node@24.13.5) + version: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) jest-canvas-mock: specifier: ^2.5.8 version: 2.5.8 @@ -517,7 +520,7 @@ importers: version: 30.5.1 jest-preset-angular: specifier: ^16.2.0 - version: 16.2.0(55086db4d53a7107a740af4fefbff4f1) + version: 16.2.0(8606464ea13cad4ec9af3c761aa352a5) prettier: specifier: ^3.9.6 version: 3.9.7 @@ -2847,8 +2850,8 @@ packages: '@oxc-project/types@0.113.0': resolution: {integrity: sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==} - '@oxc-project/types@0.149.0': - resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -3086,8 +3089,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@rolldown/binding-android-arm-eabi@1.2.8': - resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] @@ -3098,8 +3101,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.2.8': - resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -3110,8 +3113,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.2.8': - resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -3122,8 +3125,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.8': - resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -3134,8 +3137,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.2.8': - resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -3146,8 +3149,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': - resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3159,8 +3162,8 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.2.8': - resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3173,22 +3176,22 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-arm64-musl@1.2.8': - resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.8': - resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.8': - resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -3201,8 +3204,8 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.8': - resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3215,8 +3218,8 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-musl@1.2.8': - resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3228,8 +3231,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.2.8': - resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3245,8 +3248,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.2.8': - resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3257,8 +3260,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.8': - resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5292,8 +5295,8 @@ packages: resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} engines: {node: '>=20.19.0'} - dompurify@3.4.15: - resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7608,8 +7611,8 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - piscina@5.2.0: - resolution: {integrity: sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==} + piscina@5.3.2: + resolution: {integrity: sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==} engines: {node: '>=20.x'} pkce-challenge@5.0.1: @@ -7976,8 +7979,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.2.8: - resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -9016,8 +9019,8 @@ packages: yaml: optional: true - vite@8.3.0: - resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -9472,17 +9475,17 @@ snapshots: - chokidar - typescript - '@angular-builders/jest@21.0.4(19dc25c2d515bca80ffe7c7c09218a06)': + '@angular-builders/jest@21.0.4(4a6ca0f3f1ce97539a2bd6ad33352055)': dependencies: '@angular-builders/common': 5.0.4(@types/node@24.13.5)(chokidar@5.0.0)(typescript@5.9.3) '@angular-devkit/architect': 0.2102.22(chokidar@5.0.0) - '@angular-devkit/build-angular': 21.2.24(@angular/compiler-cli@21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.5)(chokidar@5.0.0)(jest-environment-jsdom@30.5.1)(jest@30.5.1(@types/node@24.13.5))(jiti@2.7.0)(lightningcss@1.33.0)(typescript@5.9.3) + '@angular-devkit/build-angular': 21.2.24(@angular/compiler-cli@21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.5)(chokidar@5.0.0)(jest-environment-jsdom@30.5.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(jiti@2.7.0)(lightningcss@1.33.0)(typescript@5.9.3) '@angular-devkit/core': 21.2.22(chokidar@5.0.0) '@angular/compiler-cli': 21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3) '@angular/core': 21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3) '@angular/platform-browser-dynamic': 21.2.23(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))) - jest: 30.5.1(@types/node@24.13.5) - jest-preset-angular: 16.2.0(55086db4d53a7107a740af4fefbff4f1) + jest: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) + jest-preset-angular: 16.2.0(8606464ea13cad4ec9af3c761aa352a5) lodash: 4.18.1 rxjs: 7.8.2 transitivePeerDependencies: @@ -9513,7 +9516,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@21.2.24(@angular/compiler-cli@21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.5)(chokidar@5.0.0)(jest-environment-jsdom@30.5.1)(jest@30.5.1(@types/node@24.13.5))(jiti@2.7.0)(lightningcss@1.33.0)(typescript@5.9.3)': + '@angular-devkit/build-angular@21.2.24(@angular/compiler-cli@21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3))(@angular/compiler@21.2.23)(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.5)(chokidar@5.0.0)(jest-environment-jsdom@30.5.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(jiti@2.7.0)(lightningcss@1.33.0)(typescript@5.9.3)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.24(chokidar@5.0.0) @@ -9551,7 +9554,7 @@ snapshots: open: 11.0.0 ora: 9.3.0 picomatch: 4.0.4 - piscina: 5.2.0 + piscina: 5.3.2 postcss: 8.5.23 postcss-loader: 8.2.0(postcss@8.5.23)(typescript@5.9.3)(webpack@5.105.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23)) resolve-url-loader: 5.0.0 @@ -9575,7 +9578,7 @@ snapshots: '@angular/core': 21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3) '@angular/platform-browser': 21.2.23(@angular/animations@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@21.2.23(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3)) esbuild: 0.28.1 - jest: 30.5.1(@types/node@24.13.5) + jest: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) jest-environment-jsdom: 30.5.1 transitivePeerDependencies: - '@angular/compiler' @@ -9873,7 +9876,7 @@ snapshots: mrmime: 2.0.1 parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 - piscina: 5.2.0 + piscina: 5.3.2 rolldown: 1.0.0-rc.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) sass: 1.97.3 semver: 7.7.4 @@ -12216,7 +12219,7 @@ snapshots: '@oxc-project/types@0.113.0': {} - '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.151.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -12450,73 +12453,73 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@rolldown/binding-android-arm-eabi@1.2.8': + '@rolldown/binding-android-arm-eabi@1.2.11': optional: true '@rolldown/binding-android-arm64@1.0.0-rc.4': optional: true - '@rolldown/binding-android-arm64@1.2.8': + '@rolldown/binding-android-arm64@1.2.11': optional: true '@rolldown/binding-darwin-arm64@1.0.0-rc.4': optional: true - '@rolldown/binding-darwin-arm64@1.2.8': + '@rolldown/binding-darwin-arm64@1.2.11': optional: true '@rolldown/binding-darwin-x64@1.0.0-rc.4': optional: true - '@rolldown/binding-darwin-x64@1.2.8': + '@rolldown/binding-darwin-x64@1.2.11': optional: true '@rolldown/binding-freebsd-x64@1.0.0-rc.4': optional: true - '@rolldown/binding-freebsd-x64@1.2.8': + '@rolldown/binding-freebsd-x64@1.2.11': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.8': + '@rolldown/binding-linux-arm64-gnu@1.2.11': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.8': + '@rolldown/binding-linux-arm64-musl@1.2.11': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.8': + '@rolldown/binding-linux-ppc64-gnu@1.2.11': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.8': + '@rolldown/binding-linux-s390x-gnu@1.2.11': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.8': + '@rolldown/binding-linux-x64-gnu@1.2.11': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': optional: true - '@rolldown/binding-linux-x64-musl@1.2.8': + '@rolldown/binding-linux-x64-musl@1.2.11': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': optional: true - '@rolldown/binding-openharmony-arm64@1.2.8': + '@rolldown/binding-openharmony-arm64@1.2.11': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-rc.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -12530,13 +12533,13 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.8': + '@rolldown/binding-win32-arm64-msvc@1.2.11': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.8': + '@rolldown/binding-win32-x64-msvc@1.2.11': optional: true '@rolldown/pluginutils@1.0.0-rc.4': {} @@ -14706,7 +14709,7 @@ snapshots: dependencies: domelementtype: 3.0.0 - dompurify@3.4.15: + dompurify@3.4.16: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16079,25 +16082,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.5.1(@types/node@24.13.5): - dependencies: - '@jest/core': 30.5.1(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) - '@jest/test-result': 30.5.1 - '@jest/types': 30.5.1 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) - jest-util: 30.5.1 - jest-validate: 30.5.1 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)): dependencies: '@jest/core': 30.5.1(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) @@ -16268,7 +16252,7 @@ snapshots: '@types/node': 24.13.5 jest-util: 30.5.1 - jest-preset-angular@16.2.0(55086db4d53a7107a740af4fefbff4f1): + jest-preset-angular@16.2.0(8606464ea13cad4ec9af3c761aa352a5): dependencies: '@angular/compiler-cli': 21.2.23(@angular/compiler@21.2.23)(typescript@5.9.3) '@angular/core': 21.2.23(@angular/compiler@21.2.23)(rxjs@7.8.2)(zone.js@0.16.3) @@ -16277,11 +16261,11 @@ snapshots: '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0) bs-logger: 0.2.6 esbuild-wasm: 0.28.1 - jest: 30.5.1(@types/node@24.13.5) + jest: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) jest-util: 30.4.1 jsdom: 26.1.0 pretty-format: 30.4.1 - ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.5.1(@types/node@24.13.5))(typescript@5.9.3) + ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(typescript@5.9.3) typescript: 5.9.3 optionalDependencies: esbuild: 0.28.1 @@ -16446,32 +16430,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.5.1: - dependencies: - '@jest/core': 30.5.1(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) - '@jest/types': 30.5.1 - import-local: 3.2.0 - jest-cli: 30.5.1(@types/node@24.13.5) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - - jest@30.5.1(@types/node@24.13.5): - dependencies: - '@jest/core': 30.5.1(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) - '@jest/types': 30.5.1 - import-local: 3.2.0 - jest-cli: 30.5.1(@types/node@24.13.5) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)): dependencies: '@jest/core': 30.5.1(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) @@ -16601,7 +16559,7 @@ snapshots: optionalDependencies: canvg: 3.0.11 core-js: 3.49.0 - dompurify: 3.4.15 + dompurify: 3.4.16 html2canvas: 1.4.1 jwa@2.0.1: @@ -17542,7 +17500,7 @@ snapshots: pirates@4.0.7: {} - piscina@5.2.0: + piscina@5.3.2: optionalDependencies: '@napi-rs/nice': 1.1.1 @@ -17936,26 +17894,26 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - rolldown@1.2.8: + rolldown@1.2.11: dependencies: - '@oxc-project/types': 0.149.0 + '@oxc-project/types': 0.151.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.8 - '@rolldown/binding-android-arm64': 1.2.8 - '@rolldown/binding-darwin-arm64': 1.2.8 - '@rolldown/binding-darwin-x64': 1.2.8 - '@rolldown/binding-freebsd-x64': 1.2.8 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 - '@rolldown/binding-linux-arm64-gnu': 1.2.8 - '@rolldown/binding-linux-arm64-musl': 1.2.8 - '@rolldown/binding-linux-ppc64-gnu': 1.2.8 - '@rolldown/binding-linux-s390x-gnu': 1.2.8 - '@rolldown/binding-linux-x64-gnu': 1.2.8 - '@rolldown/binding-linux-x64-musl': 1.2.8 - '@rolldown/binding-openharmony-arm64': 1.2.8 - '@rolldown/binding-win32-arm64-msvc': 1.2.8 - '@rolldown/binding-win32-x64-msvc': 1.2.8 + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 rollup@4.61.1: dependencies: @@ -18715,12 +18673,12 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.5.1(@types/node@24.13.5))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.5.1(@types/node@24.13.5) + jest: 30.5.1(@types/node@24.13.5)(ts-node@10.9.2(@types/node@24.13.5)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -18756,46 +18714,6 @@ snapshots: babel-jest: 30.5.1(@babel/core@7.29.7) jest-util: 30.5.1 - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1(@types/node@24.13.5))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 30.5.1(@types/node@24.13.5) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.8.5 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.7 - '@jest/transform': 30.5.1 - '@jest/types': 30.5.1 - babel-jest: 30.5.1(@babel/core@7.29.7) - jest-util: 30.5.1 - - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1)(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 30.5.1 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.8.5 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.7 - '@jest/transform': 30.5.1 - '@jest/types': 30.5.1 - babel-jest: 30.5.1(@babel/core@7.29.7) - jest-util: 30.5.1 - ts-morph@27.0.2: dependencies: '@ts-morph/common': 0.28.1 @@ -19135,12 +19053,12 @@ snapshots: sass: 1.97.3 terser: 5.46.0 - vite@8.3.0(@types/node@25.8.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.97.3)(terser@5.47.1): + vite@8.3.1(@types/node@25.8.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.97.3)(terser@5.47.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.28 - rolldown: 1.2.8 + rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.8.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 693a8423..b81f6741 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,12 @@ packages: - 'teammapper-frontend' # 7-day quarantine on newly published versions (mitigates fast-revoked malicious releases). minimumReleaseAge: 10080 +# Exact versions exempt from the quarantine. Each entry fixes a critical +# advisory whose patched release is younger than seven days. Remove an entry +# once its version has aged out. +minimumReleaseAgeExclude: + # GHSA-67c8-pqhq-4rmx (critical, RCE). See the piscina override below. + - 'piscina@5.3.2' strictDepBuilds: true allowBuilds: '@compodoc/compodoc': false @@ -57,9 +63,11 @@ overrides: 'js-yaml@>=4.0.0 <4.3.1': '>=4.3.1 <5.0.0' 'js-yaml@>=3.0.0 <3.15.1': '>=3.15.1 <4.0.0' '@babel/core@>=7.0.0 <7.29.6': '>=7.29.6 <8.0.0' - 'piscina@>=5.0.0-alpha.0 <5.1.5': '>=5.1.5 <6.0.0' + # GHSA-67c8-pqhq-4rmx (critical) raised the floor from 5.1.5 to 5.3.2. + 'piscina@>=5.0.0-alpha.0 <5.3.2': '>=5.3.2 <6.0.0' 'vite@>=7.0.0 <7.3.5': '>=7.3.5 <8.0.0' - 'dompurify@>=3.0.0 <3.4.9': '>=3.4.9 <4.0.0' + # GHSA-p98j-92pf-mc4p and earlier. Parent: jspdf. + 'dompurify@>=3.0.0 <3.4.16': '>=3.4.16 <4.0.0' # GHSA-xv26-6w52-cph6 (critical, DoS). Parent: webpack-dev-server > sockjs. 'websocket-driver@<0.7.5': '>=0.7.5 <0.8.0' # GHSA-rgw5-rvv9-x895 / -mh99-v99m-4gvg / -3jxr-9vmj-r5cp (ReDoS). Reached through diff --git a/teammapper-backend/src/auth/person-id.middleware.spec.ts b/teammapper-backend/src/auth/person-id.middleware.spec.ts new file mode 100644 index 00000000..ef1157e9 --- /dev/null +++ b/teammapper-backend/src/auth/person-id.middleware.spec.ts @@ -0,0 +1,100 @@ +import jwt from 'jsonwebtoken' +import { PersonIdMiddleware } from './person-id.middleware' +import { Request } from '../map/types' + +const SECRET = 'person-id-spec-secret' +const PID = 'person-id' + +const sign = (payload: object, secret = SECRET) => + jwt.sign(payload, secret, { algorithm: 'HS256' }) + +/** A token with `alg: none`, which carries no signature at all. */ +const unsigned = (payload: object) => { + const encode = (part: object) => + Buffer.from(JSON.stringify(part)).toString('base64url') + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.` +} + +/** Runs the middleware on a request carrying the given cookie and returns the pid it set. */ +const resolvePid = (cookie: unknown): string | undefined => { + const req = { cookies: { person_id: cookie }, pid: 'stale' } as Request + const next = jest.fn() + new PersonIdMiddleware().use(req, new Response(), next) + expect(next).toHaveBeenCalledTimes(1) + return req.pid +} + +describe('PersonIdMiddleware', () => { + let previousSecret: string | undefined + + beforeEach(() => { + previousSecret = process.env.JWT_SECRET + process.env.JWT_SECRET = SECRET + // The middleware warns on every rejected cookie, which these tests send on purpose. + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + process.env.JWT_SECRET = previousSecret + jest.restoreAllMocks() + }) + + it('sets the pid from a validly signed cookie', () => { + expect(resolvePid(sign({ pid: PID }))).toBe(PID) + }) + + it('leaves the pid unset without a cookie', () => { + expect(resolvePid(undefined)).toBeUndefined() + }) + + it('leaves the pid unset when the secret is not set', () => { + const token = sign({ pid: PID }) + delete process.env.JWT_SECRET + expect(resolvePid(token)).toBeUndefined() + }) + + it('leaves the pid unset when the secret is empty', () => { + const token = sign({ pid: PID }) + process.env.JWT_SECRET = '' + expect(resolvePid(token)).toBeUndefined() + }) + + it.each(['null', 'undefined', '', PID])( + 'leaves the pid unset for the unsigned value %p', + (value) => { + expect(resolvePid(value)).toBeUndefined() + } + ) + + // cookie-parser turns a `j:` prefixed cookie into the parsed JSON value. + it.each([null, { pid: PID }, [PID]])( + 'leaves the pid unset for the JSON cookie %p', + (value) => { + expect(resolvePid(value)).toBeUndefined() + } + ) + + it('rejects a token signed with another secret', () => { + expect(resolvePid(sign({ pid: PID }, 'not-the-secret'))).toBeUndefined() + }) + + it('rejects an unsigned token with alg none', () => { + expect(resolvePid(unsigned({ pid: PID }))).toBeUndefined() + }) + + it('rejects a valid signature over a swapped payload', () => { + const [header, , signature] = sign({ pid: 'other' }).split('.') + const payload = Buffer.from(JSON.stringify({ pid: PID })).toString( + 'base64url' + ) + expect(resolvePid(`${header}.${payload}.${signature}`)).toBeUndefined() + }) + + it.each([ + ['null', { pid: null }], + ['missing', {}], + ['empty', { pid: '' }], + ])('yields a falsy pid for a signed token whose pid is %s', (_, payload) => { + expect(resolvePid(sign(payload))).toBeFalsy() + }) +}) diff --git a/teammapper-backend/src/jobs/seedMapData.job.ts b/teammapper-backend/src/jobs/seedMapData.job.ts index 2a90eeb8..24f8e14c 100644 --- a/teammapper-backend/src/jobs/seedMapData.job.ts +++ b/teammapper-backend/src/jobs/seedMapData.job.ts @@ -34,8 +34,6 @@ const createNode = ( parent: parentId, isRoot: isRoot, link: {}, - hidden: false, - hasHiddenChildNodes: false, } } diff --git a/teammapper-backend/src/map/services/yjs-persistence.service.spec.ts b/teammapper-backend/src/map/services/yjs-persistence.service.spec.ts index 2b1479a9..802522f7 100644 --- a/teammapper-backend/src/map/services/yjs-persistence.service.spec.ts +++ b/teammapper-backend/src/map/services/yjs-persistence.service.spec.ts @@ -154,6 +154,22 @@ describe('YjsPersistenceService', () => { doc.destroy() }) + it('persists the nodes and skips an entry that is no Y.Map', async () => { + const { map, rootNode } = await createMapWithRootNode() + const doc = await hydrateFromDb(map) + const childId = addChildToDoc(doc, rootNode.id) + doc.getMap('nodes').set('text', 'no node') + + await service.persistDoc(map.id, doc) + + const dbNodes = await nodesRepo.find({ where: { nodeMapId: map.id } }) + expect(dbNodes.map((n) => n.id).sort()).toEqual( + [rootNode.id, childId].sort() + ) + + doc.destroy() + }) + it('keeps every node row when no root reaches a node of the Y.Doc', async () => { const { map, rootNode } = await createMapWithRootNode() const doc = await hydrateFromDb(map) diff --git a/teammapper-backend/src/map/services/yjs-persistence.service.ts b/teammapper-backend/src/map/services/yjs-persistence.service.ts index 1d0117c4..b874c89c 100644 --- a/teammapper-backend/src/map/services/yjs-persistence.service.ts +++ b/teammapper-backend/src/map/services/yjs-persistence.service.ts @@ -47,7 +47,7 @@ export class YjsPersistenceService implements OnModuleDestroy { try { await queryRunner.startTransaction() - const nodesMap = doc.getMap('nodes') as Y.Map> + const nodesMap = doc.getMap('nodes') const optionsMap = doc.getMap('mapOptions') as Y.Map const now = new Date() @@ -166,15 +166,17 @@ export class YjsPersistenceService implements OnModuleDestroy { } // Extracts nodes from Y.Doc, ensuring root is first with stable orderNumbers. - // Throws when no root reaches any node, because deleteRemovedNodes would - // otherwise delete every node row of the map. + // Skips every entry that is no Y.Map, since any client with write access can + // store one. Throws when no root reaches any node, because deleteRemovedNodes + // would otherwise delete every node row of the map. private extractNodesFromYDoc( - nodesMap: Y.Map>, + nodesMap: Y.Map, mapId: string, now: Date ): Partial[] { const nodes: Partial[] = [] nodesMap.forEach((yNode) => { + if (!(yNode instanceof Y.Map)) return nodes.push({ ...yMapToMmpNode(yNode, mapId), lastModified: now }) }) const ordered = orderNodesFromRoot(nodes) diff --git a/teammapper-backend/src/map/utils/clientServerMapping.spec.ts b/teammapper-backend/src/map/utils/clientServerMapping.spec.ts index 47e6d993..5f85741b 100644 --- a/teammapper-backend/src/map/utils/clientServerMapping.spec.ts +++ b/teammapper-backend/src/map/utils/clientServerMapping.spec.ts @@ -18,8 +18,6 @@ const buildClientNode = ( protected: false, isRoot: false, parent: 'parent-uuid', - hidden: false, - hasHiddenChildNodes: false, ...overrides, }) diff --git a/teammapper-backend/src/map/utils/clientServerMapping.ts b/teammapper-backend/src/map/utils/clientServerMapping.ts index 5d5f49d2..4b032305 100644 --- a/teammapper-backend/src/map/utils/clientServerMapping.ts +++ b/teammapper-backend/src/map/utils/clientServerMapping.ts @@ -39,8 +39,6 @@ const mapMmpNodeToClient = (serverNode: MmpNode): IMmpClientNode => ({ name: serverNode.name || '', parent: serverNode.nodeParentId, isRoot: serverNode.root || false, - hidden: false, - hasHiddenChildNodes: false, }) const mapMmpMapToClient = ( @@ -81,7 +79,9 @@ const mapClientNodeToMmpNode = ( linkHref: clientNode.link?.href, protected: clientNode.protected, name: clientNode.name, - nodeParentId: clientNode.parent || undefined, // This is needed because a client root node defines its parent as an empty string, which is an invalid UUID format + // An older client or an old export may still send '' for a root, which is + // no valid UUID. + nodeParentId: clientNode.parent || undefined, root: clientNode.isRoot, nodeMapId: mapId, }) diff --git a/teammapper-backend/src/map/utils/yDocConversion.spec.ts b/teammapper-backend/src/map/utils/yDocConversion.spec.ts index 05884612..0e37074e 100644 --- a/teammapper-backend/src/map/utils/yDocConversion.spec.ts +++ b/teammapper-backend/src/map/utils/yDocConversion.spec.ts @@ -178,6 +178,17 @@ describe('yDocConversion', () => { doc.destroy() }) + it('sets nodeParentId to undefined for an empty string parent, as an older client writes it', () => { + const { yNode, doc } = populateAndGet( + createTestNode({ nodeParentId: null }) + ) + yNode.set('parent', '') + + expect(yMapToMmpNode(yNode, 'map-1').nodeParentId).toBeUndefined() + + doc.destroy() + }) + it('preserves non-null nodeParentId', () => { const { yNode, doc } = populateAndGet( createTestNode({ nodeParentId: 'parent-id', root: false }) diff --git a/teammapper-frontend/e2e/app.spec.ts b/teammapper-frontend/e2e/app.spec.ts index d12ec039..ce6119cd 100644 --- a/teammapper-frontend/e2e/app.spec.ts +++ b/teammapper-frontend/e2e/app.spec.ts @@ -39,7 +39,7 @@ test('shows the selection ring on the root after a reload', async ({ await expect(page.locator('#add-tree-button')).toBeEnabled(); const rootBackground = page - .locator('g[class$="_node"]') + .locator('g.node') .filter({ has: page.getByText('Root node', { exact: true }) }) .locator('path') .first(); diff --git a/teammapper-frontend/e2e/branch-colors.spec.ts b/teammapper-frontend/e2e/branch-colors.spec.ts index c40ebb50..ae7f5866 100644 --- a/teammapper-frontend/e2e/branch-colors.spec.ts +++ b/teammapper-frontend/e2e/branch-colors.spec.ts @@ -26,8 +26,7 @@ test('branch colors are inherited correctly from parent nodes', async ({ // Small delay to ensure rendering is complete await page.waitForTimeout(500); - // Get all branch paths (these have IDs ending with '_branch') - const branches = await page.locator('path[id$="_branch"]').all(); + const branches = await page.locator('path.branch').all(); // We should have at least 2 branches for the two first-level nodes expect(branches.length).toBeGreaterThanOrEqual(2); @@ -63,7 +62,7 @@ test('branch colors are inherited correctly from parent nodes', async ({ await page.waitForTimeout(500); // Get all branches again - const allBranches = await page.locator('path[id$="_branch"]').all(); + const allBranches = await page.locator('path.branch').all(); // We should now have 4 branches (2 first-level + 2 second-level) expect(allBranches.length).toBe(4); @@ -89,7 +88,7 @@ test('branch colors are inherited correctly from parent nodes', async ({ await expect(page.getByText('Another Child of First')).toBeVisible(); await page.waitForTimeout(500); - const finalBranches = await page.locator('path[id$="_branch"]').all(); + const finalBranches = await page.locator('path.branch').all(); expect(finalBranches.length).toBe(5); const anotherChildColor = await finalBranches[4].evaluate( diff --git a/teammapper-frontend/e2e/helpers.ts b/teammapper-frontend/e2e/helpers.ts index f25848ec..22e99a32 100644 --- a/teammapper-frontend/e2e/helpers.ts +++ b/teammapper-frontend/e2e/helpers.ts @@ -11,6 +11,18 @@ export async function createMap(page: Page) { await expect(page.locator('#add-tree-button')).toBeEnabled(); } +/** + * Adds a child to the node named `parent` through the floating add button + * and names it `name`. + */ +export async function addChild(page: Page, parent: string, name: string) { + await page.getByText(parent, { exact: true }).click(); + await page.locator('#floating-add-node').click(); + await page.keyboard.type(name); + await page.locator('.map').click(); + await expect(page.getByText(name, { exact: true })).toBeVisible(); +} + /** * Adds a tree through the toolbar and names its root `name`. Adding a tree * pans the view to the new root with a transition, and a click on the map diff --git a/teammapper-frontend/e2e/hide-child-nodes.spec.ts b/teammapper-frontend/e2e/hide-child-nodes.spec.ts new file mode 100644 index 00000000..579ab5b1 --- /dev/null +++ b/teammapper-frontend/e2e/hide-child-nodes.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { addChild, createMap } from './helpers'; + +// The toolbar refuses to hide the child nodes of a root, so the test hides the +// child nodes of a node below the main root. +test('hiding child nodes stays local to the client', async ({ + page, + browser, +}) => { + await createMap(page); + await addChild(page, 'Root node', 'Branch'); + await addChild(page, 'Branch', 'First child'); + + await page.getByText('Branch', { exact: true }).click(); + await page.locator('#hide-child-nodes-button').click(); + await expect(page.getByText('First child', { exact: true })).toBeHidden(); + + // The map URL carries the modification secret in its fragment, so the + // second client opens the map with edit rights. + const secondClientContext = await browser.newContext(); + try { + const secondClient = await secondClientContext.newPage(); + await secondClient.goto(page.url()); + await expect( + secondClient.getByText('First child', { exact: true }) + ).toBeVisible(); + + await addChild(secondClient, 'Branch', 'Second child'); + // The count proves the node reached the first client, which renders it + // hidden, so the hidden check cannot pass on a node that never arrived. + const secondChildInFirstClient = page.getByText('Second child', { + exact: true, + }); + await expect(secondChildInFirstClient).toHaveCount(1); + await expect(secondChildInFirstClient).toBeHidden(); + await expect( + secondClient.getByText('Second child', { exact: true }) + ).toBeVisible(); + } finally { + await secondClientContext.close(); + } +}); diff --git a/teammapper-frontend/e2e/import-export.spec.ts b/teammapper-frontend/e2e/import-export.spec.ts index f785fd4b..78e7a1dc 100644 --- a/teammapper-frontend/e2e/import-export.spec.ts +++ b/teammapper-frontend/e2e/import-export.spec.ts @@ -139,8 +139,7 @@ test('imports Mermaid mindmap with different branch colors', async ({ ); // Get the branch path elements (these are the curved lines connecting nodes) - // Branch paths have IDs ending with '_branch' - const branches = await page.locator('path[id$="_branch"]').all(); + const branches = await page.locator('path.branch').all(); // We should have 4 branches total (3 direct children + 1 grandchild) expect(branches.length).toBe(4); diff --git a/teammapper-frontend/e2e/multi-tree.spec.ts b/teammapper-frontend/e2e/multi-tree.spec.ts index a2797513..c64592fe 100644 --- a/teammapper-frontend/e2e/multi-tree.spec.ts +++ b/teammapper-frontend/e2e/multi-tree.spec.ts @@ -1,16 +1,7 @@ import { test, expect, Page } from '@playwright/test'; import type { ExportNodeProperties } from '@teammapper/shared'; import type { Readable } from 'stream'; -import { addTree, createMap } from './helpers'; - -/** Adds a child to the node named `parent` and names it `name`. */ -async function addChild(page: Page, parent: string, name: string) { - await page.getByText(parent, { exact: true }).click(); - await page.locator('#floating-add-node').click(); - await page.keyboard.type(name); - await page.locator('.map').click(); - await expect(page.getByText(name, { exact: true })).toBeVisible(); -} +import { addChild, addTree, createMap } from './helpers'; async function readStream(stream: Readable): Promise { const chunks: Buffer[] = []; @@ -53,8 +44,8 @@ test('creates a tree and adds two levels of children to it', async ({ const secondRoot = byName('Second tree'); expect(secondRoot).toBeDefined(); - // The JSON export writes an empty string as the parent of a root. - expect(secondRoot?.parent).toBe(''); + // The JSON export writes null as the parent of a root. + expect(secondRoot?.parent).toBeNull(); expect(secondRoot?.isRoot).toBe(false); expect(byName('Tree child')?.parent).toBe(secondRoot?.id); expect(byName('Tree grandchild')?.parent).toBe(byName('Tree child')?.id); @@ -80,7 +71,7 @@ test('pastes a copied tree as a second tree with nothing selected', async ({ const roots = nodes.filter(node => node.name === 'Copied tree'); const children = nodes.filter(node => node.name === 'Copied child'); - expect(roots.map(root => root.parent)).toEqual(['', '']); + expect(roots.map(root => root.parent)).toEqual([null, null]); expect(roots.map(root => root.isRoot)).toEqual([false, false]); expect(children.map(child => child.parent).sort()).toEqual( roots.map(root => root.id).sort() @@ -140,8 +131,8 @@ test('imports an old JSON export holding detached nodes as trees', async ({ byName(name)?.isRoot, ]) ).toEqual([ - ['', false], - ['', false], + [null, false], + [null, false], ]); expect(byName('Pasted under note')?.parent).toBe(byName('Legacy note')?.id); expect(byName('Note child')?.parent).toBe(byName('Legacy note')?.id); diff --git a/teammapper-frontend/e2e/navigation.spec.ts b/teammapper-frontend/e2e/navigation.spec.ts index 82f8613a..30591eff 100644 --- a/teammapper-frontend/e2e/navigation.spec.ts +++ b/teammapper-frontend/e2e/navigation.spec.ts @@ -25,7 +25,7 @@ test.describe('info dialog', () => { }); const dialog = (page: Page) => page.locator('mat-dialog-container'); - const nodes = (page: Page) => page.locator('.map [class$="_node"]'); + const nodes = (page: Page) => page.locator('.map g.node'); test('info button opens the dialog with the shortcut list', async ({ page, diff --git a/teammapper-frontend/e2e/node-editing.spec.ts b/teammapper-frontend/e2e/node-editing.spec.ts index 6fd17d41..c5f35c0a 100644 --- a/teammapper-frontend/e2e/node-editing.spec.ts +++ b/teammapper-frontend/e2e/node-editing.spec.ts @@ -50,6 +50,11 @@ test('toggles node font styles (bold and italic)', async ({ page }) => { }); test('adds a node and drags it - screenshot test', async ({ page }) => { + // mmp bends each new node's outline by a random k. A fixed Math.random + // gives every run the same outline, so the screenshots compare. + await page.addInitScript(() => { + Math.random = () => 0.25; + }); await page.goto('/'); await page.getByText('Create mind map').click(); await expect(page.getByText('Root node')).toBeVisible(); diff --git a/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-after-drag-webkit-linux.png b/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-after-drag-webkit-linux.png index 20078d95..28f1e698 100644 Binary files a/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-after-drag-webkit-linux.png and b/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-after-drag-webkit-linux.png differ diff --git a/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-before-drag-webkit-linux.png b/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-before-drag-webkit-linux.png index 8f394bed..9d6934ec 100644 Binary files a/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-before-drag-webkit-linux.png and b/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-before-drag-webkit-linux.png differ diff --git a/teammapper-frontend/e2e/node-sizes.spec.ts b/teammapper-frontend/e2e/node-sizes.spec.ts new file mode 100644 index 00000000..3b4df48c --- /dev/null +++ b/teammapper-frontend/e2e/node-sizes.spec.ts @@ -0,0 +1,95 @@ +import { expect, Page, test } from '@playwright/test'; +import { createMap } from './helpers'; + +/** + * A node is as wide as its name, measured in the font the app ships, so its + * width is the same in every browser and after every reload. + */ + +/** The node padding mmp adds around a name, `NODE_WIDTH_PADDING`. */ +const WIDTH_PADDING = 45; + +function node(page: Page, name: string) { + return page + .locator('g.node') + .filter({ has: page.getByText(name, { exact: true }) }); +} + +/** The drawn width of the node's background and of its name. */ +async function widths(page: Page, name: string) { + return node(page, name).evaluate(group => { + const background = group.querySelector('path.background'); + const text = group.querySelector('foreignObject.name > div'); + if (!(background instanceof SVGPathElement)) throw new Error('No path'); + if (!(text instanceof HTMLElement)) throw new Error('No name'); + return { + background: Math.round(background.getBBox().width), + name: text.offsetWidth, + }; + }); +} + +async function fontsLoaded(page: Page) { + await page.evaluate(async () => { + await document.fonts.ready; + }); +} + +test('sizes a node from its name in the shipped font', async ({ page }) => { + await createMap(page); + await fontsLoaded(page); + + const fontFamily = await page + .getByText('Root node', { exact: true }) + .evaluate(name => getComputedStyle(name).fontFamily); + expect(fontFamily).toContain('Fira Sans'); + await expect + .poll(() => page.evaluate(() => document.fonts.check("16px 'Fira Sans'"))) + .toBe(true); + + await expect + .poll(async () => { + const { background, name } = await widths(page, 'Root node'); + return background - name; + }) + .toBe(WIDTH_PADDING); +}); + +test('keeps node widths after a reload', async ({ page }) => { + await createMap(page); + await fontsLoaded(page); + await expect + .poll(async () => { + const { background, name } = await widths(page, 'Root node'); + return background - name; + }) + .toBe(WIDTH_PADDING); + const before = await widths(page, 'Root node'); + + await page.reload(); + await expect(page.locator('#add-tree-button')).toBeEnabled(); + await fontsLoaded(page); + + await expect.poll(() => widths(page, 'Root node')).toEqual(before); +}); + +test('grows a node while its name is edited', async ({ page }) => { + await createMap(page); + await page.getByText('Root node', { exact: true }).click(); + await page.locator('#floating-add-node').click(); + await page.keyboard.type('A'); + const short = await widths(page, 'A'); + + await page.keyboard.type(' much longer name'); + + // Still editing: the node grows before the name is committed. + await expect + .poll(async () => (await widths(page, 'A much longer name')).background) + .toBeGreaterThan(short.background); + await expect + .poll(async () => { + const { background, name } = await widths(page, 'A much longer name'); + return background - name; + }) + .toBe(WIDTH_PADDING); +}); diff --git a/teammapper-frontend/e2e/two-client-sync.spec.ts b/teammapper-frontend/e2e/two-client-sync.spec.ts new file mode 100644 index 00000000..281b7374 --- /dev/null +++ b/teammapper-frontend/e2e/two-client-sync.spec.ts @@ -0,0 +1,164 @@ +import { test, expect, Locator, Page } from '@playwright/test'; +import path from 'path'; +import { addChild, createMap } from './helpers'; + +const IMPORT_FIXTURE = path.join(__dirname, 'fake-data', 'test-map.json'); + +/** The third preset color of the color picker, #f44336. */ +const RED_PRESET_INDEX = 2; +const RED = 'rgb(244, 67, 54)'; +const DEFAULT_BACKGROUND = 'rgb(245, 245, 245)'; + +const PIXEL_TOLERANCE = 5; + +// A stuck action fails with its own call log instead of the test timeout. +test.use({ actionTimeout: 10_000 }); + +/** The drawn node group that shows `name`. */ +function nodeOf(page: Page, name: string): Locator { + return page.locator('g.node').filter({ hasText: name }); +} + +function backgroundOf(page: Page, name: string): Locator { + return nodeOf(page, name).locator('path.background'); +} + +/** Opens the map from the editor link in a second browser context. */ +async function openSecondClient( + page: Page, + newPage: () => Promise +): Promise { + const secondClient = await newPage(); + await secondClient.goto(page.url()); + await expect(secondClient.getByText('Root node')).toBeVisible(); + await expect(secondClient.locator('#add-tree-button')).toBeEnabled(); + return secondClient; +} + +/** Renames a node through its inline name editor. */ +async function renameNode(page: Page, from: string, to: string) { + await nodeOf(page, from).dblclick(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.type(to); + await page.locator('.map').click(); + await expect(nodeOf(page, to)).toBeVisible(); +} + +/** Drags the node by the offset with the mouse. */ +async function dragNode(page: Page, name: string, dx: number, dy: number) { + const box = await nodeOf(page, name).boundingBox(); + if (!box) throw new Error(`No bounding box for node ${name}`); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + dx, y + dy, { steps: 10 }); + await page.mouse.up(); +} + +/** + * The offset of the node from the root, in screen pixels. The offset stays + * equal across clients whose viewports pan differently. + */ +async function offsetFromRoot(page: Page, name: string) { + const node = await nodeOf(page, name).boundingBox(); + const root = await nodeOf(page, 'Root node').boundingBox(); + if (!node || !root) throw new Error(`No bounding box for ${name} or root`); + return { + x: node.x + node.width / 2 - (root.x + root.width / 2), + y: node.y + node.height / 2 - (root.y + root.height / 2), + }; +} + +/** Picks the red preset as the background color of the node. */ +async function colorNodeRed(page: Page, name: string) { + await nodeOf(page, name).click(); + await page.locator('input.background-color').click(); + await page + .locator('.color-picker .preset-color') + .nth(RED_PRESET_INDEX) + .click(); + // While the picker is open, the color panel lays a backdrop over the map. A + // click on it closes the picker. + await page + .locator('teammapper-colors-panel .background') + .click({ position: { x: 300, y: 150 } }); + await expect(page.locator('.color-picker')).toBeHidden(); +} + +test('two clients on one map see each other edit, move, color, undo, remove and import', async ({ + page: clientA, + browser, +}) => { + await createMap(clientA); + + const contextB = await browser.newContext(); + try { + const clientB = await openSecondClient(clientA, () => contextB.newPage()); + + // 1. A adds a child node and names it. + await addChild(clientA, 'Root node', 'Child from A'); + await expect(nodeOf(clientB, 'Child from A')).toBeVisible(); + + // 2. B renames the node. + await renameNode(clientB, 'Child from A', 'Renamed by B'); + await expect(nodeOf(clientA, 'Renamed by B')).toBeVisible(); + await expect(nodeOf(clientA, 'Child from A')).toHaveCount(0); + + // 3. A drags the node, and B shows it at the same place relative to the root. + const before = await offsetFromRoot(clientA, 'Renamed by B'); + await dragNode(clientA, 'Renamed by B', -150, 100); + await expect + .poll(async () => (await offsetFromRoot(clientA, 'Renamed by B')).x) + .toBeLessThan(before.x - 100); + const dragged = await offsetFromRoot(clientA, 'Renamed by B'); + await expect + .poll(async () => { + const seen = await offsetFromRoot(clientB, 'Renamed by B'); + return Math.max( + Math.abs(seen.x - dragged.x), + Math.abs(seen.y - dragged.y) + ); + }) + .toBeLessThan(PIXEL_TOLERANCE); + + // 4. A changes the background color, and B shows it. + await expect(backgroundOf(clientB, 'Renamed by B')).toHaveCSS( + 'fill', + DEFAULT_BACKGROUND + ); + await colorNodeRed(clientA, 'Renamed by B'); + await expect(backgroundOf(clientA, 'Renamed by B')).toHaveCSS('fill', RED); + await expect(backgroundOf(clientB, 'Renamed by B')).toHaveCSS('fill', RED); + + // 5. A undoes the color change and then redoes it, and B follows both. + await clientA.locator('#undo-button').click(); + await expect(backgroundOf(clientB, 'Renamed by B')).toHaveCSS( + 'fill', + DEFAULT_BACKGROUND + ); + await clientA.locator('#redo-button').click(); + await expect(backgroundOf(clientB, 'Renamed by B')).toHaveCSS('fill', RED); + + // 6. A removes the node, and B loses it. A undoes, and B gets it back. + await nodeOf(clientA, 'Renamed by B').click(); + await clientA.locator('#floating-remove-node').click(); + await expect(nodeOf(clientB, 'Renamed by B')).toHaveCount(0); + await clientA.locator('#undo-button').click(); + await expect(nodeOf(clientB, 'Renamed by B')).toBeVisible(); + + // 7. A imports a JSON map, and B shows its nodes and the import toast. + await clientA.locator('#menu-import').click(); + const fileChooser = clientA.waitForEvent('filechooser'); + await clientA.getByText('JSON').click(); + await (await fileChooser).setFiles(IMPORT_FIXTURE); + await expect( + clientB.getByText('Mindmap successfully imported!') + ).toBeVisible(); + await expect(clientB.getByText('test', { exact: true })).toBeVisible(); + await expect(clientB.getByText('Root node')).toBeVisible(); + await expect(nodeOf(clientB, 'Renamed by B')).toHaveCount(0); + } finally { + await contextB.close(); + } +}); diff --git a/teammapper-frontend/package.json b/teammapper-frontend/package.json index c4901205..f9ec2536 100644 --- a/teammapper-frontend/package.json +++ b/teammapper-frontend/package.json @@ -62,7 +62,7 @@ "@teammapper/mmp": "workspace:*", "@teammapper/shared": "workspace:*", "angular2-hotkeys": "^16.0.1", - "dompurify": "3.4.15", + "dompurify": "3.4.16", "jspdf": "^4.2.1", "localforage": "1.10.0", "ngx-color-picker": "^20.1.1", diff --git a/teammapper-frontend/src/app/core/services/import/import.service.spec.ts b/teammapper-frontend/src/app/core/services/import/import.service.spec.ts index ddb3f4ce..893e592d 100644 --- a/teammapper-frontend/src/app/core/services/import/import.service.spec.ts +++ b/teammapper-frontend/src/app/core/services/import/import.service.spec.ts @@ -67,7 +67,7 @@ describe('ImportService', () => { 'mindmap\n Main\n A\n\nmindmap\n Second\n B' ); - expect(byName(nodes, 'Second')?.parent).toBe(''); + expect(byName(nodes, 'Second')?.parent).toBeNull(); expect(byName(nodes, 'A')?.parent).toBe(byName(nodes, 'Main')?.id); expect(byName(nodes, 'B')?.parent).toBe(byName(nodes, 'Second')?.id); }); @@ -76,8 +76,8 @@ describe('ImportService', () => { const nodes = await importedNodes('Mindmap\n Main\n\nMINDMAP\n Second'); expect(nodes.map(n => [n.name, n.parent])).toEqual([ - ['Main', ''], - ['Second', ''], + ['Main', null], + ['Second', null], ]); }); diff --git a/teammapper-frontend/src/app/core/services/import/import.service.ts b/teammapper-frontend/src/app/core/services/import/import.service.ts index 18abfb1c..6da8598c 100644 --- a/teammapper-frontend/src/app/core/services/import/import.service.ts +++ b/teammapper-frontend/src/app/core/services/import/import.service.ts @@ -177,7 +177,7 @@ export class ImportService { } const parentNode = nodes.find(n => n.id === parentId); - const isDirectChildOfRoot = parentNode?.parent === ''; + const isDirectChildOfRoot = parentNode?.parent === null; if (isDirectChildOfRoot) { const { autoBranchColors, defaultNode } = settings.mapOptions; @@ -232,11 +232,10 @@ export class ImportService { return { id: nodeId, - parent: parentId, + parent: parentId || null, name: node.descr || node.nodeId || defaultNode.name, protected: false, isRoot, - hidden: false, font: { style: defaultNode.font.style, size: isRoot ? rootNode.font.size : defaultNode.font.size, diff --git a/teammapper-frontend/src/app/core/services/map-sync/map-sync-context.ts b/teammapper-frontend/src/app/core/services/map-sync/map-sync-context.ts index 7261b25b..8b91d6fb 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/map-sync-context.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/map-sync-context.ts @@ -1,4 +1,5 @@ -import { CachedMapEntry, ExportNodeProperties } from '@teammapper/shared'; +import { ExportNodeProperties } from '@teammapper/shared'; +import type { MapData } from '@teammapper/mmp'; import { ClientColorMapping } from './yjs-utils'; export const DEFAULT_COLOR = '#000000'; @@ -7,7 +8,6 @@ export const DEFAULT_SELF_COLOR = '#c0c0c0'; export type ConnectionStatus = 'connected' | 'disconnected' | null; export interface MapSyncContext { - getAttachedMap(): CachedMapEntry; getModificationSecret(): string; getColorMapping(): ClientColorMapping; getClientColor(): string; @@ -20,4 +20,8 @@ export interface MapSyncContext { setCanRedo(v: boolean): void; updateAttachedMap(): Promise; emitClientList(): void; + /** The connection synced: create the map over its map data. */ + createMap(data: MapData): void; + /** The server deleted the map; the page reloads right after. */ + mapDeleted(): void; } diff --git a/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.spec.ts index de000f11..51043473 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.spec.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { MapSyncService } from './map-sync.service'; +import { NEVER } from 'rxjs'; +import { MapSyncService, SYNCING_TOAST_DELAY_MS } from './map-sync.service'; import { MmpService } from '../mmp/mmp.service'; import { HttpService } from '../../http/http.service'; import { StorageService } from '../storage/storage.service'; @@ -7,12 +8,21 @@ import { SettingsService } from '../settings/settings.service'; import { UtilsService } from '../utils/utils.service'; import { ToastrService } from 'ngx-toastr'; import { createMockUtilsService } from '../../../../test/mocks/utils-service.mock'; -import { Observable } from 'rxjs'; -import { ExportNodeProperties, UserSettings } from '@teammapper/shared'; +import { + CachedMapEntry, + ExportNodeProperties, + UserSettings, +} from '@teammapper/shared'; +import type { MapData, MmpMap } from '@teammapper/mmp'; import { YjsSyncService } from './yjs-sync.service'; import { MapSyncContext } from './map-sync-context'; import { ImageHandlers, ImageUploadError } from '../mmp/node-images'; +const SYNCING_TOAST_ID = 7; + +// MmpService is mocked, so a created map only has to be passed through. +const CREATED_MAP = { id: 'created' } as unknown as MmpMap; + // Narrow accessor: only exposes the sync service handle, not its internals function getSync(service: MapSyncService): YjsSyncService { return (service as unknown as { syncService: YjsSyncService }).syncService; @@ -34,7 +44,6 @@ function createMockNode( colors: { branch: '#000000' }, font: { size: 14, style: 'normal', weight: 'normal' }, protected: false, - hidden: false, coordinates: undefined, image: undefined, link: undefined, @@ -55,13 +64,15 @@ describe('MapSyncService', () => { beforeEach(() => { mmpService = { new: jest.fn(), + create: jest.fn().mockResolvedValue(CREATED_MAP), + remove: jest.fn(), + markMapCreated: jest.fn(), selectNode: jest.fn(), getRootNode: jest.fn(), on: jest.fn(), updateNode: jest.fn(), updateAdditionalMapOptions: jest.fn(), existNode: jest.fn().mockReturnValue(true), - addNodesFromServer: jest.fn(), removeNode: jest.fn(), highlightNode: jest.fn(), exportAsJSON: jest.fn().mockReturnValue([]), @@ -82,10 +93,7 @@ describe('MapSyncService', () => { setEditMode: jest.fn(), } as unknown as jest.Mocked; - const subscribeMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); - mmpService.on.mockReturnValue({ - subscribe: subscribeMock, - } as unknown as Observable); + mmpService.on.mockReturnValue(NEVER); mmpService.getRootNode.mockReturnValue( createMockNode({ id: 'root', name: 'Root', isRoot: true }) @@ -112,6 +120,8 @@ describe('MapSyncService', () => { error: jest.fn(), success: jest.fn(), warning: jest.fn(), + info: jest.fn().mockReturnValue({ toastId: SYNCING_TOAST_ID }), + remove: jest.fn(), }, }, ], @@ -124,7 +134,11 @@ describe('MapSyncService', () => { service.ngOnDestroy(); }); - describe('map initialization', () => { + describe('opening a map', () => { + const ref = document.createElement('div'); + const options = { drag: true }; + const data = {} as MapData; + beforeEach(() => { jest.spyOn(service, 'getAttachedMap').mockReturnValue({ key: 'map-test-uuid', @@ -138,24 +152,115 @@ describe('MapSyncService', () => { options: { fontMaxSize: 18, fontMinSize: 10, fontIncrement: 2 }, }, }); + }); + + describe('before the first sync', () => { + beforeEach(() => { + jest + .spyOn(getSync(service), 'initMap') + .mockImplementation(() => undefined); + service.openMap(ref, options); + }); - jest - .spyOn(getSync(service), 'initMap') - .mockImplementation(() => undefined); + it('opens the connection to the attached map', () => { + expect(getSync(service).initMap).toHaveBeenCalledWith('test-uuid'); + }); + + it('creates no map and loads no snapshot from the server', () => { + expect(mmpService.create).not.toHaveBeenCalled(); + expect(mmpService.new).not.toHaveBeenCalled(); + }); }); - it('loads map data into mmpService on initMap', () => { - service.initMap(); + describe('on the first sync', () => { + beforeEach(() => { + jest + .spyOn(getSync(service), 'initMap') + .mockImplementation(() => undefined); + jest + .spyOn(getSync(service), 'attachMap') + .mockImplementation(() => undefined); + service.openMap(ref, options); + }); + + const sync = async () => { + getSyncContext(service).createMap(data); + await new Promise(resolve => setTimeout(resolve)); + }; + + it('creates the map over the map data, wires it up, then marks it created', async () => { + await sync(); + + const created = mmpService.create.mock.invocationCallOrder[0]; + const attached = (getSync(service).attachMap as jest.Mock).mock + .invocationCallOrder[0]; + const marked = mmpService.markMapCreated.mock.invocationCallOrder[0]; + expect(mmpService.create).toHaveBeenCalledWith( + 'map_1', + ref, + options, + data + ); + expect(created < attached && attached < marked).toBe(true); + }); + + it('removes the map a reset left behind while mmp built it', async () => { + getSyncContext(service).createMap(data); + service.reset(); + await new Promise(resolve => setTimeout(resolve)); - expect(mmpService.new).toHaveBeenCalledWith([ - expect.objectContaining({ - id: 'node-1', - name: 'Test Node', - }), - ]); + expect(mmpService.remove).toHaveBeenCalledWith(CREATED_MAP); + expect(getSync(service).attachMap).not.toHaveBeenCalled(); + expect(mmpService.markMapCreated).not.toHaveBeenCalled(); + }); + + it('removes only the late map when a newer open built its map first', async () => { + const lateMap = { id: 'late' } as unknown as MmpMap; + let finishLate: (map: MmpMap) => void = () => undefined; + mmpService.create.mockReturnValueOnce( + new Promise(resolve => (finishLate = resolve)) + ); + getSyncContext(service).createMap(data); + service.reset(); + service.openMap(ref, options); + await sync(); + + finishLate(lateMap); + await new Promise(resolve => setTimeout(resolve)); + + expect(mmpService.remove).toHaveBeenCalledTimes(1); + expect(mmpService.remove).toHaveBeenCalledWith(lateMap); + expect(getSync(service).attachMap).toHaveBeenCalledTimes(1); + expect(mmpService.markMapCreated).toHaveBeenCalledTimes(1); + }); + + it('wires nothing up when mmp created no map', async () => { + mmpService.create.mockResolvedValueOnce(null); + + await sync(); + + expect(mmpService.remove).not.toHaveBeenCalled(); + expect(getSync(service).attachMap).not.toHaveBeenCalled(); + expect(mmpService.markMapCreated).not.toHaveBeenCalled(); + }); + + it('logs a failed create and wires nothing up', async () => { + const error = new Error('create failed'); + mmpService.create.mockRejectedValueOnce(error); + const log = jest.spyOn(console, 'error').mockImplementation(() => { + return undefined; + }); + + await sync(); + + expect(log).toHaveBeenCalledWith('Failed to create the map:', error); + expect(getSync(service).attachMap).not.toHaveBeenCalled(); + expect(mmpService.markMapCreated).not.toHaveBeenCalled(); + log.mockRestore(); + }); }); - it('attaches the node the map load selected', () => { + it('attaches the node and the awareness selection the map made on create', async () => { const rootNode = createMockNode({ id: 'root', name: 'Root', @@ -166,11 +271,90 @@ describe('MapSyncService', () => { service .getAttachedNodeObservable() .subscribe(node => attached.push(node)); + service.openMap(ref, options); - service.initMap(); + getSyncContext(service).createMap(data); + await new Promise(resolve => setTimeout(resolve)); - expect(mmpService.selectNode).toHaveBeenCalledWith(); + const sync = getSync(service) as unknown as { selectedNodeId: string }; expect(attached[attached.length - 1]).toBe(rootNode); + expect(sync.selectedNodeId).toBe('root'); + expect(mmpService.selectNode.mock.invocationCallOrder[0]).toBeGreaterThan( + mmpService.create.mock.invocationCallOrder[0] + ); + }); + }); + + describe('syncing toast', () => { + const toastr = () => + TestBed.inject(ToastrService) as unknown as { + info: jest.Mock; + remove: jest.Mock; + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(getSync(service), 'initMap').mockImplementation(() => { + return undefined; + }); + jest + .spyOn(getSync(service), 'attachMap') + .mockImplementation(() => undefined); + jest.spyOn(service, 'getAttachedMap').mockReturnValue({ + key: 'map-test-uuid', + cachedMap: { uuid: 'test-uuid' }, + } as CachedMapEntry); + service.openMap(document.createElement('div')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('shows no toast before 500 ms pass', async () => { + await jest.advanceTimersByTimeAsync(SYNCING_TOAST_DELAY_MS - 1); + + expect(toastr().info).not.toHaveBeenCalled(); + }); + + it('shows a lasting toast when the connection has not synced after 500 ms', async () => { + await jest.advanceTimersByTimeAsync(SYNCING_TOAST_DELAY_MS); + + expect(toastr().info).toHaveBeenCalledWith('TOASTS.MAP_SYNCING', '', { + disableTimeOut: true, + tapToDismiss: false, + }); + }); + + it('shows no toast when the connection synced in time', async () => { + getSyncContext(service).createMap({} as MapData); + await jest.advanceTimersByTimeAsync(SYNCING_TOAST_DELAY_MS); + + expect(toastr().info).not.toHaveBeenCalled(); + }); + + it('removes the toast on the first sync', async () => { + await jest.advanceTimersByTimeAsync(SYNCING_TOAST_DELAY_MS); + + getSyncContext(service).createMap({} as MapData); + + expect(toastr().remove).toHaveBeenCalledWith(SYNCING_TOAST_ID); + }); + + it('removes the toast on reset', async () => { + await jest.advanceTimersByTimeAsync(SYNCING_TOAST_DELAY_MS); + + service.reset(); + + expect(toastr().remove).toHaveBeenCalledWith(SYNCING_TOAST_ID); + }); + + it('removes the toast when the server deletes the map', async () => { + await jest.advanceTimersByTimeAsync(SYNCING_TOAST_DELAY_MS); + + getSyncContext(service).mapDeleted(); + + expect(toastr().remove).toHaveBeenCalledWith(SYNCING_TOAST_ID); }); }); diff --git a/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts index e8b5755e..94547045 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.ts @@ -16,14 +16,19 @@ import { parseImageUploadResponse, } from '@teammapper/shared'; import { ImageUploadError } from '../mmp/node-images'; -import { MapProperties } from '@teammapper/mmp'; +import type { + MapData, + MapProperties, + MmpMap, + OptionParameters, +} from '@teammapper/mmp'; import { PrivateServerMap, ServerMap, ServerMapInfo } from './server-types'; import { API_URL, HttpService } from '../../http/http.service'; import { COLORS } from '../mmp/mmp-utils'; import { UtilsService } from '../utils/utils.service'; import { StorageService } from '../storage/storage.service'; import { SettingsService } from '../settings/settings.service'; -import { ToastrService } from 'ngx-toastr'; +import { ActiveToast, ToastrService } from 'ngx-toastr'; import { ClientColorMapping, ClientColorMappingValue, @@ -32,6 +37,25 @@ import { import { MapSyncContext, ConnectionStatus } from './map-sync-context'; import { YjsSyncService } from './yjs-sync.service'; +/** How long the connection may take to sync before the toast says so. */ +export const SYNCING_TOAST_DELAY_MS = 500; + +/** The element and the options openMap creates the map with. */ +interface MapTarget { + ref: HTMLElement; + options?: OptionParameters; +} + +/** + * Loads the attached map from the server and opens it, with `YjsSyncService` + * keeping it in sync. Opening a map runs in this order: + * 1. `openMap` calls `YjsSyncService.initMap`, which creates the Y.Doc and + * its `YjsMapData` and connects. No map exists yet. + * 2. On the first sync, `YjsSyncService` hands the `YjsMapData` back + * through the context's `createMap`, and this service creates mmp over it. + * 3. This service then calls `YjsSyncService.attachMap`, which wires + * listeners, presence and undo, and sets edit mode last. + */ @Injectable({ providedIn: 'root', }) @@ -61,6 +85,11 @@ export class MapSyncService implements OnDestroy { private readonly syncService: YjsSyncService; + // Where openMap creates the map once the connection syncs. + private mapTarget: MapTarget | null = null; + private syncingToastTimer: ReturnType | null = null; + private syncingToast: ActiveToast | null = null; + // Common fields private colorMapping: ClientColorMapping; private availableColors: string[]; @@ -128,15 +157,20 @@ export class MapSyncService implements OnDestroy { } public reset() { + this.mapTarget = null; + this.removeSyncingToast(); this.syncService.destroy(); this.colorMapping = {}; } - public initMap() { - const rawData = this.getAttachedMap().cachedMap.data; - const normalized = normalizeMapData({ data: rawData }); - this.mmpService.new(normalized.data as unknown as ExportNodeProperties[]); - this.attachedNodeSubject.next(this.mmpService.selectNode()); + /** + * Open the connection to the attached map. The map appears in `ref` once + * the connection syncs; until then the map area stays empty, and a toast + * tells the user after a short delay that the map is loading. + */ + public openMap(ref: HTMLElement, options?: OptionParameters) { + this.mapTarget = { ref, options }; + this.scheduleSyncingToast(); this.syncService.initMap(this.getAttachedMap().cachedMap.uuid); } @@ -170,10 +204,6 @@ export class MapSyncService implements OnDestroy { return attachedMap; } - public getConnectionStatus(): ConnectionStatus { - return this.connectionStatusSubject.getValue(); - } - public async updateAttachedMap(): Promise { const cachedMapEntry: CachedMapEntry = this.getAttachedMap(); @@ -198,16 +228,6 @@ export class MapSyncService implements OnDestroy { this.syncService.redo(); } - /** - * Protect or release the branch of the selected node in one transaction, - * so peers never see a child released before its parent is protected. - */ - public toggleBranchProtection(): void { - this.syncService.transactLocally(() => - this.mmpService.toggleBranchProtection() - ); - } - public updateMapOptions(options?: CachedMapOptions) { this.syncService.updateMapOptions(options); } @@ -249,7 +269,6 @@ export class MapSyncService implements OnDestroy { private createContext(): MapSyncContext { return { - getAttachedMap: () => this.getAttachedMap(), getModificationSecret: () => this.modificationSecret, getColorMapping: () => this.colorMapping, getClientColor: () => this.clientColor, @@ -268,9 +287,80 @@ export class MapSyncService implements OnDestroy { setCanRedo: (v: boolean) => this.canRedoSubject.next(v), updateAttachedMap: () => this.updateAttachedMap(), emitClientList: () => this.extractClientListForSubscriber(), + createMap: (data: MapData) => + void this.createMap(data).catch((error: unknown) => { + console.error('Failed to create the map:', error); + this.removeSyncingToast(); + }), + mapDeleted: () => this.removeSyncingToast(), }; } + // ─── Map creation ──────────────────────────────────────────── + + /** + * Create the map over the synced map data. The sync service wires it up + * and sets edit mode last, so the map exists when edit mode reaches it. + * A reset while mmp builds the map leaves nothing to wire up, so the call + * removes the map it built and keeps a map that a newer `openMap` built. + */ + private async createMap(data: MapData): Promise { + const target = this.mapTarget; + if (!target) return; + this.removeSyncingToast(); + + const map = await this.mmpService.create( + 'map_1', + target.ref, + target.options, + data + ); + if (map) this.adoptMap(map, target); + } + + /** + * Wire up the map, or remove it when a reset or a newer openMap ran while + * mmp built it. + */ + private adoptMap(map: MmpMap, target: MapTarget): void { + if (this.mapTarget !== target) return this.mmpService.remove(map); + this.syncService.attachMap(); + this.mmpService.markMapCreated(); + } + + /** + * Show the syncing toast when the connection has not synced after the + * delay. The delay keeps the toast from flashing on a fast connection. + */ + private scheduleSyncingToast(): void { + this.removeSyncingToast(); + const timer = setTimeout( + () => void this.showSyncingToast(timer), + SYNCING_TOAST_DELAY_MS + ); + this.syncingToastTimer = timer; + } + + /** The toast stays until the sync, a reset or a map deletion removes it. */ + private async showSyncingToast( + timer: ReturnType + ): Promise { + const message = await this.utilsService.translate('TOASTS.MAP_SYNCING'); + // The sync or a reset may have come while the translation loaded. + if (this.syncingToastTimer !== timer) return; + this.syncingToast = this.toastrService.info(message, '', { + disableTimeOut: true, + tapToDismiss: false, + }); + } + + private removeSyncingToast(): void { + if (this.syncingToastTimer !== null) clearTimeout(this.syncingToastTimer); + this.syncingToastTimer = null; + if (this.syncingToast) this.toastrService.remove(this.syncingToast.toastId); + this.syncingToast = null; + } + // ─── Node images ───────────────────────────────────────────── private attachedMapId(): string | null { diff --git a/teammapper-frontend/src/app/core/services/map-sync/server-types.ts b/teammapper-frontend/src/app/core/services/map-sync/server-types.ts index f0b75897..96c286d9 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/server-types.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/server-types.ts @@ -29,28 +29,4 @@ interface ServerMapInfo extends Omit { createdAt: string | null; } -const ReversePropertyMapping = { - name: 'name', - protected: 'protected', - coordinates: 'coordinates', - image: { - src: 'imageSrc', - size: 'imageSize', - }, - link: { - href: 'linkHref', - }, - colors: { - background: 'backgroundColor', - branch: 'branchColor', - name: 'nameColor', - }, - font: { - weight: 'fontWeight', - style: 'fontStyle', - size: 'fontSize', - }, - hidden: 'hidden', -} as const; - -export { ServerMap, ServerMapInfo, PrivateServerMap, ReversePropertyMapping }; +export { ServerMap, ServerMapInfo, PrivateServerMap }; diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-map-data.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-map-data.spec.ts new file mode 100644 index 00000000..51dcc429 --- /dev/null +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-map-data.spec.ts @@ -0,0 +1,473 @@ +import * as Y from 'yjs'; +import type { MapDataChange } from '@teammapper/mmp'; +import { ExportNodeProperties } from '@teammapper/shared'; +import { + LAST_MAP_ANNOUNCEMENT, + LOCAL_ORIGIN, + META, + YjsMapData, +} from './yjs-map-data'; +import { yMapToNodeProps } from './yjs-utils'; + +function node( + id: string, + parent: string | null = null, + isRoot = false +): ExportNodeProperties { + return { + id, + parent, + isRoot, + name: id, + k: 1, + protected: false, + coordinates: { x: 0, y: 0 }, + colors: { name: '#000000', background: '#ffffff', branch: '#333333' }, + font: { size: 14, style: 'normal', weight: 'normal' }, + image: { src: '', size: 0 }, + link: { href: '' }, + }; +} + +/** Send every update `from` holds and `to` lacks, as a peer's change. */ +function sync(from: Y.Doc, to: Y.Doc): void { + Y.applyUpdate( + to, + Y.encodeStateAsUpdate(from, Y.encodeStateVector(to)), + 'peer' + ); +} + +describe('YjsMapData', () => { + let doc: Y.Doc; + let undoManager: Y.UndoManager; + let data: YjsMapData; + let changes: MapDataChange[]; + + const nodesMap = () => doc.getMap('nodes') as Y.Map>; + const stored = (id: string) => { + const yNode = nodesMap().get(id); + return yNode ? yMapToNodeProps(yNode) : undefined; + }; + const lastChange = () => changes[changes.length - 1]; + + beforeEach(() => { + doc = new Y.Doc(); + data = new YjsMapData(doc, () => undoManager); + // The main root exists before the undo manager, as after a first sync. + data.addNodes([node('root', null, true)]); + undoManager = new Y.UndoManager(nodesMap(), { + trackedOrigins: new Set([LOCAL_ORIGIN]), + }); + changes = []; + data.subscribe(change => changes.push(change)); + }); + + afterEach(() => { + data.destroy(); + undoManager.destroy(); + doc.destroy(); + }); + + it('writes with the local origin, so the undo manager records it', () => { + const origins: unknown[] = []; + doc.on('afterTransaction', transaction => origins.push(transaction.origin)); + + data.addNodes([node('a', 'root')]); + + expect({ + origins, + undoSteps: undoManager.undoStack.length, + change: lastChange(), + }).toEqual({ + origins: [LOCAL_ORIGIN], + undoSteps: 1, + change: { replaced: false, added: ['a'], updated: [], removed: [] }, + }); + }); + + it('notifies a write before it returns', () => { + data.updateNode('root', 'name', 'Renamed'); + + expect(changes).toHaveLength(1); + }); + + it('reads the records, the main root and the order of the doc', () => { + data.addNodes([node('a', 'root'), node('b', 'root')]); + + expect({ + root: data.node('root')?.name, + missing: data.node('missing'), + ids: data.nodes().map(record => record.id), + mainRoot: data.mainRootId(), + }).toEqual({ + root: 'root', + missing: undefined, + ids: ['root', 'a', 'b'], + mainRoot: 'root', + }); + }); + + it('writes the whole top-level key of a nested property', () => { + data.updateNode('root', 'backgroundColor', '#ff0000'); + + expect({ colors: stored('root')?.colors, change: lastChange() }).toEqual({ + colors: { name: '#000000', background: '#ff0000', branch: '#333333' }, + change: { replaced: false, added: [], updated: ['root'], removed: [] }, + }); + }); + + it('does not report a rename of the main root as a replacement', () => { + data.updateNode('root', 'name', 'Renamed'); + + expect(lastChange().replaced).toBe(false); + }); + + it('writes and notifies nothing for an id it does not hold', () => { + data.updateNode('missing', 'name', 'x'); + data.removeNode('missing'); + + expect(changes).toEqual([]); + }); + + it("reads back a record shaped like mmp's new node as it was written", () => { + const added: ExportNodeProperties = { + ...node('a'), + k: -7.25, + font: { size: 14, style: 'italic', weight: 'bold' }, + }; + + data.addNodes([added]); + + expect(data.node('a')).toStrictEqual(added); + }); + + it('reads back an entry without k and font without them', () => { + const yNode = new Y.Map(); + nodesMap().set('bare', yNode); + yNode.set('parent', 'root'); + yNode.set('name', 'Bare'); + + expect(data.node('bare')).toStrictEqual({ + id: 'bare', + parent: 'root', + name: 'Bare', + }); + }); + + describe('every writer of a root', () => { + it.each([ + ["mmp's new node", 'mmp', () => data.addNodes([node('mmp')])], + [ + 'a node given an empty string', + 'empty', + () => data.addNodes([{ ...node('empty'), parent: '' }]), + ], + ])('stores null as the parent for %s', (_label, id, write) => { + write(); + + expect(nodesMap().get(id)?.get('parent')).toBeNull(); + expect(data.node(id)?.parent).toBeNull(); + }); + + it('reads the null parent of a root the backend hydrated', () => { + // The shape `populateYMapFromNode` in the backend writes for a root. + const yNode = new Y.Map(); + nodesMap().set('backend', yNode); + yNode.set('id', 'backend'); + yNode.set('parent', null); + yNode.set('orderNumber', 0); + + expect(data.node('backend')?.parent).toBeNull(); + }); + }); + + it('keeps no reference to the nodes it was given', () => { + const added = node('a', 'root'); + data.addNodes([added]); + + added.coordinates = { x: 99, y: 99 }; + + expect(stored('a')?.coordinates).toEqual({ x: 0, y: 0 }); + }); + + it('removes the descendants with the node', () => { + data.addNodes([node('a', 'root'), node('b', 'a'), node('c', 'b')]); + + data.removeNode('a'); + + expect({ + ids: Array.from(nodesMap().keys()), + removed: [...lastChange().removed].sort(), + }).toEqual({ ids: ['root'], removed: ['a', 'b', 'c'] }); + }); + + it('notifies a nested batch once, when the outer batch ends', () => { + data.batch(() => { + data.addNodes([node('a', 'root')]); + data.batch(() => data.updateNode('root', 'name', 'Renamed')); + }); + + expect(changes).toEqual([ + { replaced: false, added: ['a'], updated: ['root'], removed: [] }, + ]); + }); + + it('stops notifying after unsubscribe', () => { + const listener = jest.fn(); + const unsubscribe = data.subscribe(listener); + + unsubscribe(); + data.addNodes([node('a', 'root')]); + + expect(listener).not.toHaveBeenCalled(); + }); + + describe('a peer', () => { + let peer: Y.Doc; + + beforeEach(() => { + peer = new Y.Doc(); + sync(doc, peer); + }); + + afterEach(() => { + peer.destroy(); + }); + + it('reports a node the peer adds and records no undo step', () => { + const peerData = new YjsMapData(peer, () => null); + peerData.addNodes([node('p', 'root')]); + + sync(peer, doc); + + expect({ + change: lastChange(), + undoSteps: undoManager.undoStack.length, + }).toEqual({ + change: { replaced: false, added: ['p'], updated: [], removed: [] }, + undoSteps: 0, + }); + peerData.destroy(); + }); + + it('reports a nested key change as an update of that node', () => { + const peerRoot = (peer.getMap('nodes') as Y.Map>).get( + 'root' + ); + peerRoot?.set('colors', { background: '#00ff00' }); + + sync(peer, doc); + + expect(lastChange()).toEqual({ + replaced: false, + added: [], + updated: ['root'], + removed: [], + }); + }); + + it('reads past an entry the peer writes that is no Y.Map', () => { + peer.getMap('nodes').set('text', 'no node'); + peer.getMap('nodes').set('object', { isRoot: true }); + + sync(peer, doc); + + expect({ + text: data.node('text'), + ids: data.nodes().map(record => record.id), + mainRoot: data.mainRootId(), + changes, + }).toEqual({ + text: undefined, + ids: ['root'], + mainRoot: 'root', + changes: [], + }); + }); + + it('reports no change for an edit inside an entry that is no Y.Map', () => { + const list = new Y.Array(); + peer.getMap('nodes').set('list', list); + sync(peer, doc); + changes = []; + + list.push([1]); + sync(peer, doc); + + expect(changes).toEqual([]); + }); + + it('removes no entry that is no Y.Map', () => { + peer.getMap('nodes').set('text', 'no node'); + sync(peer, doc); + + data.removeNode('text'); + + expect({ + kept: doc.getMap('nodes').get('text'), + undoSteps: undoManager.undoStack.length, + }).toEqual({ kept: 'no node', undoSteps: 0 }); + }); + + it('reports an entry the peer turns into a node as added', () => { + peer.getMap('nodes').set('late', 'no node'); + sync(peer, doc); + + const peerData = new YjsMapData(peer, () => null); + peerData.addNodes([node('late', 'root')]); + sync(peer, doc); + + expect(lastChange()).toEqual({ + replaced: false, + added: ['late'], + updated: [], + removed: [], + }); + peerData.destroy(); + }); + + it('reports a node the peer overwrites with another value as removed', () => { + peer.getMap('nodes').set('root', 'no node'); + + sync(peer, doc); + + expect({ nodes: data.nodes(), change: lastChange() }).toEqual({ + nodes: [], + change: { replaced: false, added: [], updated: [], removed: ['root'] }, + }); + }); + }); + + describe('undo', () => { + it('notifies the change an undo makes', () => { + data.addNodes([node('a', 'root')]); + + undoManager.undo(); + + expect(lastChange()).toEqual({ + replaced: false, + added: [], + updated: [], + removed: ['a'], + }); + }); + + it('reverts a batch in one step', () => { + data.addNodes([node('a', 'root')]); + undoManager.stopCapturing(); + data.batch(() => { + data.updateNode('root', 'protected', true); + data.updateNode('a', 'protected', true); + }); + + undoManager.undo(); + + expect({ + root: stored('root')?.protected, + a: stored('a')?.protected, + }).toEqual({ root: false, a: false }); + }); + + it('keeps the edit after a batch that throws out of its undo step', () => { + data.addNodes([node('a', 'root')]); + undoManager.stopCapturing(); + expect(() => + data.batch(() => { + data.updateNode('root', 'name', 'Batched'); + throw new Error('batch failed'); + }) + ).toThrow('batch failed'); + data.updateNode('a', 'name', 'Edited'); + + undoManager.undo(); + + expect({ + root: stored('root')?.name, + a: stored('a')?.name, + }).toEqual({ root: 'Batched', a: 'a' }); + }); + + it('reverts a batch without the edit before it', () => { + data.addNodes([node('a', 'root')]); + undoManager.stopCapturing(); + data.updateNode('a', 'name', 'Edited'); + data.batch(() => { + data.updateNode('root', 'coordinates', { x: 0, y: 0 }); + data.updateNode('a', 'coordinates', { x: 200, y: 0 }); + }); + + undoManager.undo(); + + expect({ + name: stored('a')?.name, + coordinates: stored('a')?.coordinates, + }).toEqual({ name: 'Edited', coordinates: { x: 0, y: 0 } }); + }); + }); + + describe('replaceMap', () => { + beforeEach(() => { + data.addNodes([node('old', 'root')]); + data.replaceMap([node('root', null, true), node('new', 'root')]); + }); + + it('announces the import in the doc', () => { + expect(doc.getMap(META).get(LAST_MAP_ANNOUNCEMENT)).toBe('import'); + }); + + it('reports a replacement, with surviving ids as updates', () => { + expect(lastChange()).toEqual({ + replaced: true, + added: ['new'], + updated: ['root'], + removed: ['old'], + }); + }); + + it('takes an undo step of its own', () => { + undoManager.undo(); + + expect(Array.from(nodesMap().keys()).sort()).toEqual(['old', 'root']); + }); + + it('keeps an edit right after it out of its undo step', () => { + data.updateNode('new', 'name', 'Edited'); + + undoManager.undo(); + + expect({ + ids: Array.from(nodesMap().keys()).sort(), + name: stored('new')?.name, + }).toEqual({ ids: ['new', 'root'], name: 'new' }); + }); + }); + + it('reverts a paste written in a batch without the edit before it', () => { + data.addNodes([node('a', 'root')]); + undoManager.stopCapturing(); + data.updateNode('a', 'name', 'Edited'); + data.batch(() => data.addNodes([node('pasted', 'a')])); + + undoManager.undo(); + + expect({ + ids: Array.from(nodesMap().keys()).sort(), + name: stored('a')?.name, + }).toEqual({ ids: ['a', 'root'], name: 'Edited' }); + }); + + it('takes the id of a record from its key, whatever its id field holds', () => { + doc.transact(() => { + const yNode = new Y.Map(); + yNode.set('id', 'root'); + yNode.set('parent', 'root'); + nodesMap().set('forged', yNode); + }, 'peer'); + + expect({ + node: data.node('forged')?.id, + ids: data.nodes().map(record => record.id), + }).toEqual({ node: 'forged', ids: ['root', 'forged'] }); + }); +}); diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-map-data.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-map-data.ts new file mode 100644 index 00000000..08f2f4cb --- /dev/null +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-map-data.ts @@ -0,0 +1,270 @@ +import * as Y from 'yjs'; +import { NodePropertyMapping } from '@teammapper/mmp'; +import type { MapData, MapDataChange, MapNodeRecord } from '@teammapper/mmp'; +import { + ExportNodeProperties, + MapSnapshot, + NodeProperty, +} from '@teammapper/shared'; +import { + collectDescendantIds, + nodeAt, + nodeEntries, + NodesMap, + nodesMapOf, + populateYMapFromNodeProps, + yMapToNodeProps, +} from './yjs-utils'; + +/** + * The origin every write of ours carries, and the only one the undo manager + * tracks. A peer's change arrives with the WebsocketProvider as its origin, + * so it is never ours to undo. + */ +export const LOCAL_ORIGIN = 'local'; + +/** Doc-level metadata, shared with peers alongside the nodes themselves. */ +export const META = 'meta'; + +/** + * The most recent full-map announcement broadcast to peers. A reader trusts + * the value only inside the transaction that wrote it; after that + * transaction the value describes a past operation. + */ +export const LAST_MAP_ANNOUNCEMENT = 'lastMapAnnouncement'; + +type Listener = (change: MapDataChange) => void; +type KeyChanges = Y.YEvent>['changes']['keys']; + +/** A deep copy of a JSON value, so no caller shares an object with the doc. */ +const clone = (value: T): T => + typeof value === 'object' && value !== null + ? (JSON.parse(JSON.stringify(value)) as T) + : value; + +/** + * Whether the changed keys add or rewrite the main root's entry. Reads + * `isRoot`, which marks the main root only: adding or pasting a tree writes + * roots without it, so the check fires for an import or the undo of one. + * Yjs reports a delete and re-set of one key as `update`, so the check reads + * `add` and `update`. + */ +export function replacesMainRoot( + keys: KeyChanges, + nodesMap: NodesMap +): boolean { + for (const [key, change] of keys) { + if (change.action === 'delete') continue; + if (nodeAt(nodesMap, key)?.get('isRoot')) return true; + } + return false; +} + +/** + * The record of the node stored under `id`. The id comes from the key, since + * a peer may write a node whose own `id` field names a different node. The + * other values stay unchecked: mmp passes every record through `resolveNode`, + * which validates each value against the shared schemas before use. + */ +function recordOf(id: string, yNode: Y.Map): MapNodeRecord { + const stored = yMapToNodeProps(yNode) as Omit; + return { ...stored, id }; +} + +/** Copy the value into `current` at the path, leaving `current` untouched. */ +function withValueAt( + current: unknown, + path: readonly string[], + value: unknown +): unknown { + if (path.length === 0) return clone(value); + const [key, ...rest] = path; + const owner = + typeof current === 'object' && current !== null + ? (current as Record) + : {}; + return { ...owner, [key]: withValueAt(owner[key], rest, value) }; +} + +/** + * The map data of a collaborative map: the nodes in the Y.Doc's `nodes` map. + * Every write carries `LOCAL_ORIGIN`, so the undo manager records it. The + * observer reports every transaction, local, remote and undo alike, as one + * change, which is how mmp learns of a peer's edit. + */ +export class YjsMapData implements MapData { + private readonly listeners = new Set(); + + /** + * @param undoManager returns the undo manager, which the sync service + * creates after the first sync, or null before that. + */ + constructor( + private readonly doc: Y.Doc, + private readonly undoManager: () => Y.UndoManager | null + ) { + this.nodesMap.observeDeep(this.notify); + } + + private get nodesMap(): NodesMap { + return nodesMapOf(this.doc); + } + + public node(id: string): MapNodeRecord | undefined { + const yNode = nodeAt(this.nodesMap, id); + return yNode ? recordOf(id, yNode) : undefined; + } + + public nodes(): MapNodeRecord[] { + return nodeEntries(this.nodesMap).map(([id, yNode]) => recordOf(id, yNode)); + } + + public mainRootId(): string | null { + const root = nodeEntries(this.nodesMap).find(([, yNode]) => + yNode.get('isRoot') + ); + return root ? root[0] : null; + } + + public addNodes(nodes: ExportNodeProperties[]): void { + this.transact(() => nodes.forEach(node => this.writeNode(node))); + } + + /** + * Write the whole top-level key the property belongs to, such as `colors` + * for `backgroundColor`, so a peer receives the object in one piece. + */ + public updateNode(id: string, property: NodeProperty, value: unknown): void { + const yNode = nodeAt(this.nodesMap, id); + if (!yNode) return; + + const [key, ...path]: readonly string[] = NodePropertyMapping[property]; + this.transact(() => + yNode.set(key, withValueAt(yNode.get(key), path, value)) + ); + } + + public removeNode(id: string): void { + if (!nodeAt(this.nodesMap, id)) return; + + const ids = [id, ...collectDescendantIds(this.nodesMap, id)]; + this.transact(() => ids.forEach(nodeId => this.nodesMap.delete(nodeId))); + } + + /** + * Replace every node and announce the import to peers in the same + * transaction. Yjs transaction origins never reach a peer, so the + * announcement is how a peer tells an import from an undo. The import gets + * an undo step of its own, apart from the edits before and after it. + */ + public replaceMap(nodes: MapSnapshot): void { + this.inOwnUndoStep(() => { + this.doc.getMap(META).set(LAST_MAP_ANNOUNCEMENT, 'import'); + Array.from(this.nodesMap.keys()).forEach(id => this.nodesMap.delete(id)); + nodes.forEach(node => this.writeNode(node)); + }); + } + + /** + * Run the writes in one transaction, so peers receive one update. Without + * the stops, Yjs merges the batch with whatever the user did in the half + * second around it, and one undo reverts both. A nested batch joins the + * transaction of the outer one. + */ + public batch(change: () => void): void { + this.inOwnUndoStep(change); + } + + public subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Stop observing the doc and drop every listener. */ + public destroy(): void { + this.nodesMap.unobserveDeep(this.notify); + this.listeners.clear(); + } + + private transact(change: () => void): void { + this.doc.transact(change, LOCAL_ORIGIN); + } + + /** + * Run the writes in one transaction that takes an undo step of its own. + * The closing stop runs even when `change` throws, so the next edit never + * joins the step of a failed batch. + */ + private inOwnUndoStep(change: () => void): void { + this.undoManager()?.stopCapturing(); + try { + this.transact(change); + } finally { + this.undoManager()?.stopCapturing(); + } + } + + private writeNode(node: ExportNodeProperties): void { + const yNode = new Y.Map(); + populateYMapFromNodeProps(yNode, clone(node)); + this.nodesMap.set(node.id, yNode); + } + + private readonly notify: Parameters[0] = events => { + const change = this.changeOf(events); + const empty = + change.added.length + change.updated.length + change.removed.length === 0; + if (empty && !change.replaced) return; + this.listeners.forEach(listener => listener(change)); + }; + + /** + * Sum the events of one transaction up by node id. A key change on the + * nodes map adds, removes or rewrites a node; a change inside a node's own + * map, such as its `colors`, updates that node. A change inside an entry + * that is no Y.Map, such as a peer's Y.Array, updates no node. + */ + private changeOf(events: Y.YEvent>[]) { + const change: MapDataChange = { + replaced: false, + added: [], + updated: [], + removed: [], + }; + for (const event of events) { + if ((event.target as unknown) === this.nodesMap) { + this.collectKeyChanges(event.changes.keys, change); + continue; + } + const id = String(event.path[0]); + if (nodeAt(this.nodesMap, id)) change.updated.push(id); + } + return this.withDistinctUpdates(change); + } + + /** + * Sort each key by whether it held a node before and after the change. A + * value that is no Y.Map holds no node, so a peer that overwrites a node + * with another value removes it, and one that writes such a value under a + * new key changes nothing. + */ + private collectKeyChanges(keys: KeyChanges, change: MapDataChange): void { + keys.forEach(({ action, oldValue }, id) => { + const before = action !== 'add' && oldValue instanceof Y.Map; + const after = action !== 'delete' && !!nodeAt(this.nodesMap, id); + if (before && after) change.updated.push(id); + else if (after) change.added.push(id); + else if (before) change.removed.push(id); + }); + change.replaced ||= replacesMainRoot(keys, this.nodesMap); + } + + /** List each updated id once, and none that the change added or removed. */ + private withDistinctUpdates(change: MapDataChange): MapDataChange { + const elsewhere = new Set([...change.added, ...change.removed]); + const updated = new Set(change.updated.filter(id => !elsewhere.has(id))); + return { ...change, updated: [...updated] }; + } +} diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-presence.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-presence.spec.ts index c885f165..f98fe1e9 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/yjs-presence.spec.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-presence.spec.ts @@ -3,8 +3,10 @@ import { MmpService } from '../mmp/mmp.service'; import { MapSyncContext } from './map-sync-context'; import { YjsSyncService } from './yjs-sync.service'; import { + capturingMmpService, createMockContext, createYjsSyncService, + MmpHandlers, } from '../../../../test/mocks/yjs-sync.mock'; /** @@ -54,6 +56,7 @@ jest.mock('y-websocket', () => ({ })); interface PresenceInternals { + createListeners: () => void; setupAwareness: () => void; updateFromAwareness: () => void; } @@ -61,28 +64,11 @@ interface PresenceInternals { const PEER_ID = 7; describe('YjsSyncService presence', () => { - type Handlers = Record void>; - - let handlers: Handlers; + let handlers: MmpHandlers; let context: MapSyncContext; let mmpService: jest.Mocked; let service: YjsSyncService; - function capturingMmpService(): jest.Mocked { - return { - on: jest.fn((event: string) => ({ - subscribe: (callback: (payload?: unknown) => void) => { - handlers[event] = callback; - return { unsubscribe: jest.fn() }; - }, - })), - selectNode: jest.fn(), - existNode: jest.fn().mockReturnValue(true), - highlightNode: jest.fn(), - exportAsJSON: jest.fn().mockReturnValue([]), - } as unknown as jest.Mocked; - } - function provider(): FakeWebsocketProvider { if (!FakeWebsocketProvider.latest) throw new Error('No provider created'); return FakeWebsocketProvider.latest; @@ -95,9 +81,12 @@ describe('YjsSyncService presence', () => { beforeEach(() => { handlers = {}; context = createMockContext(); - mmpService = capturingMmpService(); + mmpService = capturingMmpService(handlers); service = createYjsSyncService(mmpService, context); service.initMap('test-uuid'); + // attachMap subscribes once the map exists; these tests set awareness up + // on their own. + (service as unknown as PresenceInternals).createListeners(); }); afterEach(() => { @@ -112,7 +101,7 @@ describe('YjsSyncService presence', () => { expect(context.setAttachedNode).toHaveBeenLastCalledWith({ id: 'root' }); }); - it('keeps the client colour when setup follows a select', () => { + it('keeps the client colour and publishes the selection on setup', () => { handlers['nodeSelect']({ id: 'root' } as ExportNodeProperties); (service as unknown as PresenceInternals).setupAwareness(); @@ -120,7 +109,25 @@ describe('YjsSyncService presence', () => { expect(context.setClientColor).toHaveBeenLastCalledWith('#ff0000'); expect(lastBroadcast()).toEqual([ 'user', - { color: '#ff0000', selectedNodeId: null }, + { color: '#ff0000', selectedNodeId: 'root' }, + ]); + }); + }); + + describe('attachMap', () => { + it('publishes the node the map selected when it was created', () => { + mmpService.selectNode.mockReturnValue({ + id: 'main-root', + } as ExportNodeProperties); + + service.attachMap(); + + expect(context.setAttachedNode).toHaveBeenLastCalledWith({ + id: 'main-root', + }); + expect(lastBroadcast()).toEqual([ + 'user', + { color: '#ff0000', selectedNodeId: 'main-root' }, ]); }); }); @@ -171,11 +178,25 @@ describe('YjsSyncService presence', () => { [PEER_ID]: { color: '#0000ff', nodeId: '' }, }) ); - expect(mmpService.highlightNode).toHaveBeenCalledWith( - 'branch', - '', - false + expect(mmpService.highlightNode).toHaveBeenCalledWith('branch', ''); + }); + + it('falls back to the defaults for a peer state that holds no strings', () => { + const hostile = { color: { r: 1 }, selectedNodeId: 123 }; + provider().states.set(PEER_ID, { + user: hostile as unknown as AwarenessUser, + }); + + (service as unknown as PresenceInternals).updateFromAwareness(); + + expect(context.setColorMapping).toHaveBeenLastCalledWith( + expect.objectContaining({ + [PEER_ID]: { color: '#000000', nodeId: '' }, + }) ); + expect(mmpService.existNode).not.toHaveBeenCalled(); + expect(mmpService.highlightNode).not.toHaveBeenCalled(); + expect(context.emitClientList).toHaveBeenCalled(); }); }); }); diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.spec.ts index b32ad84b..93069dc6 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.spec.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.spec.ts @@ -1,25 +1,25 @@ -import { YjsSyncService } from './yjs-sync.service'; +import { ATTACHED_MAP_AUDIT_MS, YjsSyncService } from './yjs-sync.service'; import * as Y from 'yjs'; import { ExportNodeProperties } from '@teammapper/shared'; import { MmpService } from '../mmp/mmp.service'; import { SettingsService } from '../settings/settings.service'; import { MapSyncContext } from './map-sync-context'; import { populateYMapFromNodeProps } from './yjs-utils'; +import { YjsMapData } from './yjs-map-data'; import { + capturingMmpService, createMockContext, createYjsSyncService as createService, + MmpHandlers, } from '../../../../test/mocks/yjs-sync.mock'; interface YjsSyncInternals { yjsWritable: boolean; yjsSynced: boolean; yDoc: Y.Doc; - handleTopLevelNodeChanges: ( - event: unknown, - nodesMap: Y.Map> - ) => void; + yjsMapData: YjsMapData; showImportToast: () => Promise; - loadMapFromYDoc: () => void; + handleFirstSync: () => void; initUndoManager: () => void; setupNodesObserver: () => void; yUndoManager: Y.UndoManager | null; @@ -29,6 +29,14 @@ function internals(service: YjsSyncService): YjsSyncInternals { return service as unknown as YjsSyncInternals; } +function node( + id: string, + parent: string | null, + isRoot = false +): ExportNodeProperties { + return { id, parent, isRoot } as ExportNodeProperties; +} + describe('YjsSyncService', () => { describe('setWritable', () => { let settingsService: jest.Mocked; @@ -66,6 +74,21 @@ describe('YjsSyncService', () => { expect(settingsService.setEditMode).toHaveBeenCalledWith(true); }); + + // The settings page reads edit mode to decide whether the map settings + // of the map the user left stay editable. + it('keeps edit mode on destroy', () => { + service.destroy(); + + expect(settingsService.setEditMode).not.toHaveBeenCalled(); + }); + + it('keeps edit mode when it opens a new connection', () => { + service.initMap('test-uuid'); + + expect(settingsService.setEditMode).not.toHaveBeenCalled(); + service.destroy(); + }); }); describe('initMap does not alter writable state', () => { @@ -101,309 +124,124 @@ describe('YjsSyncService', () => { }); }); - describe('distributing nodes', () => { - type Handlers = Record void>; - - const snapshot = [ - { id: 'root', parent: '', isRoot: true }, - { id: 'child', parent: 'root', isRoot: false }, - ] as ExportNodeProperties[]; - - let handlers: Handlers; - let service: YjsSyncService; + describe('with an open connection', () => { + let handlers: MmpHandlers; + let mmpService: jest.Mocked; let context: MapSyncContext; - - function capturingMmpService(): jest.Mocked { - return { - on: jest.fn((event: string) => ({ - subscribe: (callback: (payload?: unknown) => void) => { - handlers[event] = callback; - return { unsubscribe: jest.fn() }; - }, - })), - selectNode: jest.fn(), - existNode: jest.fn().mockReturnValue(true), - exportAsJSON: jest.fn().mockReturnValue(snapshot), - } as unknown as jest.Mocked; - } + let settingsService: jest.Mocked; + let service: YjsSyncService; beforeEach(() => { handlers = {}; + mmpService = capturingMmpService(handlers); context = createMockContext(); - service = createService(capturingMmpService(), context); + settingsService = { + setEditMode: jest.fn(), + } as unknown as jest.Mocked; + service = createService(mmpService, context, settingsService); service.initMap('test-uuid'); - internals(service).yjsSynced = true; - // Normally created by handleFirstSync, which needs a live websocket. - internals(service).initUndoManager(); }); afterEach(() => { service.destroy(); }); - it('subscribes to the mmp distribute event', () => { - expect(Object.keys(handlers)).toContain('distribute'); - }); - - it('writes every node to the doc when a distribute happens', () => { - handlers['distribute'](); - - const nodesMap = internals(service).yDoc.getMap('nodes'); - expect(Array.from(nodesMap.keys()).sort()).toEqual(['child', 'root']); - }); - - it('refreshes the cached map so it does not keep the old coordinates', () => { - handlers['distribute'](); - - expect(context.updateAttachedMap).toHaveBeenCalled(); - }); - - it('records the replacement as a distribute so peers can identify it', () => { - handlers['distribute'](); + it('hands the map data of an empty doc over on the first sync', () => { + internals(service).handleFirstSync(); - expect( - internals(service).yDoc.getMap('meta').get('lastMapAnnouncement') - ).toBe('distribute'); + expect(context.createMap).toHaveBeenCalledWith( + internals(service).yjsMapData + ); }); - it('still records an import as an import', () => { - handlers['create'](); + it('subscribes to no mmp event before the map exists', () => { + internals(service).handleFirstSync(); - expect( - internals(service).yDoc.getMap('meta').get('lastMapAnnouncement') - ).toBe('import'); + expect(mmpService.on).not.toHaveBeenCalled(); }); - // Lay a root node down as a local edit, so the undo manager has a state - // to return to. - function seedRootNode(id: string): Y.Map> { - const doc = internals(service).yDoc; - const nodesMap = doc.getMap('nodes') as Y.Map>; - doc.transact(() => { - const yNode = new Y.Map(); - nodesMap.set(id, yNode); - yNode.set('id', id); - yNode.set('isRoot', true); - yNode.set('coordinates', { x: 5, y: 5 }); - }, 'local'); - - return nodesMap; - } - - it('makes an import undoable', () => { - const nodesMap = seedRootNode('before-import'); - - handlers['create'](); - service.undo(); - - expect({ - ids: Array.from(nodesMap.keys()), - coordinates: nodesMap.get('before-import')?.get('coordinates'), - }).toEqual({ - ids: ['before-import'], - coordinates: { x: 5, y: 5 }, - }); - }); - - // The import is sealed off from the edit before it, so reaching that edit - // takes a second press rather than being swept up in the first. - it('needs a second undo to reach the edit before the import', () => { - const nodesMap = seedRootNode('before-import'); - - handlers['create'](); - service.undo(); - service.undo(); - - expect(Array.from(nodesMap.keys())).toEqual([]); - }); - - it('makes a distribute undoable', () => { - const nodesMap = seedRootNode('root'); - - handlers['distribute'](); - service.undo(); - - expect(nodesMap.get('root')?.get('coordinates')).toEqual({ x: 5, y: 5 }); - }); + it('hands the map data over again when a map component reattaches', () => { + internals(service).yjsSynced = true; - it('sends a branch protection as one update that one undo reverts', () => { - const doc = internals(service).yDoc; - const nodesMap = seedRootNode('root'); - seedRootNode('child'); - const updates = jest.fn(); - doc.on('update', updates); - - service.transactLocally(() => { - for (const [id, flag] of [ - ['child', false], - ['root', true], - ] as const) { - handlers['nodeUpdate']({ - nodeProperties: { id, protected: flag }, - changedProperty: 'protected', - }); - } - }); - const updatesSent = updates.mock.calls.length; - service.undo(); + service.initMap('test-uuid'); - expect({ - updatesSent, - root: nodesMap.get('root')?.get('protected'), - child: nodesMap.get('child')?.get('protected'), - }).toEqual({ updatesSent: 1, root: undefined, child: undefined }); + expect(context.createMap).toHaveBeenCalledWith( + internals(service).yjsMapData + ); }); - describe('on a receiving client', () => { - let nodesMap: Y.Map>; - let importToast: jest.SpyInstance; + describe('attachMap', () => { + const root = node('root', null, true); beforeEach(() => { - const doc = internals(service).yDoc; - nodesMap = doc.getMap('nodes') as Y.Map>; - const yNode = new Y.Map(); - nodesMap.set('root', yNode); - yNode.set('isRoot', true); - - importToast = jest - .spyOn(internals(service), 'showImportToast') - .mockResolvedValue(undefined); - jest - .spyOn(internals(service), 'loadMapFromYDoc') - .mockImplementation(() => undefined); - }); - - /** - * Feed the service a full-map replacement. `metaKeys` are the meta keys - * the transaction wrote - an announcement, or nothing for an undo replay; - * `local` marks it as our own undo rather than a peer's replacement. - */ - function receiveReplacement({ - metaKeys = ['lastMapAnnouncement'], - local = false, - }: { metaKeys?: string[]; local?: boolean } = {}): void { - const changed = new Map>(); - if (metaKeys.length > 0) { - changed.set( - internals(service).yDoc.getMap('meta'), - new Set(metaKeys) - ); - } - - internals(service).handleTopLevelNodeChanges( - { - changes: { keys: new Map([['root', { action: 'add' }]]) }, - keysChanged: new Set(['root']), - transaction: { changed, local }, - }, - nodesMap - ); - } - - function setLastOperation(operation: string): void { - internals(service) - .yDoc.getMap('meta') - .set('lastMapAnnouncement', operation); - } - - it('does not show the import toast for a redistribution', () => { - setLastOperation('distribute'); - - receiveReplacement(); - - expect(importToast).not.toHaveBeenCalled(); + mmpService.selectNode.mockReturnValue(root); + service.setWritable(true); + internals(service).handleFirstSync(); + service.attachMap(); }); - // An undo replays nodes without recording an operation, so the stale - // 'import' left in the meta map must not make it announce one. - it('does not show the import toast when an import is undone', () => { - setLastOperation('import'); - - receiveReplacement({ metaKeys: [] }); - - expect(importToast).not.toHaveBeenCalled(); + it('subscribes to the map change and the selection events', () => { + expect(Object.keys(handlers).sort()).toEqual([ + 'mapChange', + 'nodeDeselect', + 'nodeSelect', + ]); }); - it('does not show the import toast when a distribute is undone', () => { - setLastOperation('distribute'); - - receiveReplacement({ metaKeys: [] }); - - expect(importToast).not.toHaveBeenCalled(); + it('attaches the node the map selected when it was created', () => { + expect(context.setAttachedNode).toHaveBeenLastCalledWith(root); }); - // A replacement cannot be merged as a replacement, so undoing into a - // map a peer has already replaced would leave two roots behind. - it('drops the undo history when a peer replaces the map', () => { - seedRootNode('mine'); - - receiveReplacement(); - - expect(internals(service).yUndoManager?.undoStack.length).toBe(0); + it('creates the undo manager', () => { + expect(internals(service).yUndoManager).not.toBeNull(); }); - // Clearing the stack has to reach the toolbar, or undo stays enabled - // with nothing left to undo. - it('reports that there is nothing left to undo', () => { - seedRootNode('mine'); - - receiveReplacement(); + it('sets edit mode after awareness is up', () => { + const awarenessOrder = (context.setClientColor as jest.Mock).mock + .invocationCallOrder[0]; + const editModeOrder = + settingsService.setEditMode.mock.invocationCallOrder[0]; - expect(context.setCanUndo).toHaveBeenLastCalledWith(false); + expect(settingsService.setEditMode).toHaveBeenCalledWith(true); + expect(editModeOrder).toBeGreaterThan(awarenessOrder); }); - it('keeps the history when the replacement is our own undo', () => { - seedRootNode('mine'); + it('refreshes the cached map and the attached node on a map change', () => { + jest.useFakeTimers(); + const selected = node('child', 'root'); + mmpService.selectNode.mockReturnValue(selected); - receiveReplacement({ metaKeys: [], local: true }); + handlers['mapChange'](); + jest.advanceTimersByTime(ATTACHED_MAP_AUDIT_MS); - expect(internals(service).yUndoManager?.undoStack.length).toBe(1); + expect(context.updateAttachedMap).toHaveBeenCalled(); + expect(context.setAttachedNode).toHaveBeenLastCalledWith(selected); + jest.useRealTimers(); }); - // The announcement is read per key, so `meta` gaining unrelated fields - // later must not turn every replacement into an import. - it('does not show the import toast for an unrelated meta write', () => { - setLastOperation('import'); + it('refreshes the attached node at once and the cached map once per burst', () => { + jest.useFakeTimers(); + const selected = node('child', 'root'); + mmpService.selectNode.mockReturnValue(selected); - receiveReplacement({ metaKeys: ['someOtherField'] }); + handlers['mapChange'](); + handlers['mapChange'](); + handlers['mapChange'](); - expect(importToast).not.toHaveBeenCalled(); - }); - - it('still shows the import toast for an actual import', () => { - setLastOperation('import'); - - receiveReplacement(); - - expect(importToast).toHaveBeenCalled(); + expect(context.setAttachedNode).toHaveBeenLastCalledWith(selected); + expect(context.updateAttachedMap).not.toHaveBeenCalled(); + jest.advanceTimersByTime(ATTACHED_MAP_AUDIT_MS); + expect(context.updateAttachedMap).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); }); }); - describe('receiving trees from a peer', () => { + describe('a peer replacing the map', () => { + let context: MapSyncContext; let service: YjsSyncService; - let mmpService: jest.Mocked; let peer: Y.Doc; - let loadMap: jest.SpyInstance; - - function receivingMmpService(): jest.Mocked { - return { - on: jest.fn().mockReturnValue({ - subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), - }), - existNode: jest.fn().mockReturnValue(false), - addNodesFromServer: jest.fn(), - removeNode: jest.fn(), - } as unknown as jest.Mocked; - } - - function node( - id: string, - parent: string | null, - isRoot = false - ): ExportNodeProperties { - return { id, parent, isRoot } as ExportNodeProperties; - } + let importToast: jest.SpyInstance; function writeNodes( nodesMap: Y.Map>, @@ -434,19 +272,24 @@ describe('YjsSyncService', () => { ); } - function undoStackLength(): number | undefined { - return internals(service).yUndoManager?.undoStack.length; + /** The peer replaces every node, as an import or its undo does. */ + function peerReplaces(metaKeys: Record = {}): void { + peerTransacts((nodesMap, meta) => { + nodesMap.delete('main'); + Object.entries(metaKeys).forEach(([key, value]) => + meta.set(key, value) + ); + writeNodes(nodesMap, [node('imported', null, true)]); + }); } - function addedIds(): string[] { - return mmpService.addNodesFromServer.mock.calls.map( - ([nodes]) => nodes[0].id - ); + function undoStackLength(): number | undefined { + return internals(service).yUndoManager?.undoStack.length; } beforeEach(() => { - mmpService = receivingMmpService(); - service = createService(mmpService); + context = createMockContext(); + service = createService(undefined, context); service.initMap('test-uuid'); internals(service).yjsSynced = true; internals(service).initUndoManager(); @@ -455,17 +298,11 @@ describe('YjsSyncService', () => { writeNodes(nodesMap, [node('main', null, true)]) ); internals(service).setupNodesObserver(); - loadMap = jest - .spyOn(internals(service), 'loadMapFromYDoc') - .mockImplementation(() => undefined); - jest + importToast = jest .spyOn(internals(service), 'showImportToast') .mockResolvedValue(undefined); - const doc = internals(service).yDoc; - doc.transact(() => { - writeNodes(doc.getMap('nodes'), [node('mine', 'main')]); - }, 'local'); + internals(service).yjsMapData.addNodes([node('mine', 'main')]); }); afterEach(() => { @@ -473,117 +310,70 @@ describe('YjsSyncService', () => { peer.destroy(); }); - it('applies a tree a peer adds as an ordinary add and keeps the undo stack', () => { + it('keeps the undo stack when a peer adds a tree', () => { peerTransacts(nodesMap => writeNodes(nodesMap, [node('second', null)])); expect({ - reloaded: loadMap.mock.calls.length, - added: addedIds(), undoStack: undoStackLength(), - }).toEqual({ reloaded: 0, added: ['second'], undoStack: 1 }); + toast: importToast.mock.calls, + }).toEqual({ undoStack: 1, toast: [] }); }); - it('applies a tree a peer pastes as ordinary adds, parent first, and keeps the undo stack', () => { - peerTransacts(nodesMap => - writeNodes(nodesMap, [ - node('pastedGrandchild', 'pastedChild'), - node('pastedChild', 'pasted'), - node('pasted', null), - ]) - ); + it('clears the undo stack and shows the toast when a peer imports', () => { + peerReplaces({ lastMapAnnouncement: 'import' }); expect({ - reloaded: loadMap.mock.calls.length, - added: addedIds(), undoStack: undoStackLength(), - }).toEqual({ - reloaded: 0, - added: ['pasted', 'pastedChild', 'pastedGrandchild'], - undoStack: 1, - }); + toasts: importToast.mock.calls.length, + }).toEqual({ undoStack: 0, toasts: 1 }); }); - it('adds every parent before its child when a peer writes two trees at once', () => { - peerTransacts(nodesMap => - writeNodes(nodesMap, [ - node('secondChild', 'second'), - node('second', null), - node('thirdChild', 'third'), - node('third', null), - ]) - ); - const added = addedIds(); + // Clearing the stack has to reach the toolbar, or undo stays enabled + // with nothing left to undo. + it('reports that there is nothing left to undo', () => { + peerReplaces({ lastMapAnnouncement: 'import' }); - expect({ - reloaded: loadMap.mock.calls.length, - secondFirst: added.indexOf('second') < added.indexOf('secondChild'), - thirdFirst: added.indexOf('third') < added.indexOf('thirdChild'), - count: added.length, - undoStack: undoStackLength(), - }).toEqual({ - reloaded: 0, - secondFirst: true, - thirdFirst: true, - count: 4, - undoStack: 1, - }); + expect(context.setCanUndo).toHaveBeenLastCalledWith(false); }); - it('applies a child a peer adds under the main root and keeps the undo stack', () => { - peerTransacts(nodesMap => - writeNodes(nodesMap, [node('peerChild', 'main')]) - ); + // An undo replays nodes without recording an operation, so the stale + // 'import' left in the meta map must not make it announce one. + it('shows no toast when a peer undoes an import', () => { + peerTransacts((_, meta) => meta.set('lastMapAnnouncement', 'import')); + importToast.mockClear(); - expect({ - reloaded: loadMap.mock.calls.length, - added: addedIds(), - undoStack: undoStackLength(), - }).toEqual({ reloaded: 0, added: ['peerChild'], undoStack: 1 }); + peerReplaces(); + + expect(importToast).not.toHaveBeenCalled(); }); - it('adds a subtree a peer pastes under the main root parent first', () => { - peerTransacts(nodesMap => - writeNodes(nodesMap, [ - node('pastedGrandchild', 'pastedChild'), - node('pastedChild', 'main'), - ]) - ); + // The announcement is read per key, so `meta` gaining unrelated fields + // later must not turn every replacement into an import. + it('shows no toast for an unrelated meta write', () => { + peerTransacts((_, meta) => meta.set('lastMapAnnouncement', 'import')); + importToast.mockClear(); - expect({ - reloaded: loadMap.mock.calls.length, - added: addedIds(), - undoStack: undoStackLength(), - }).toEqual({ - reloaded: 0, - added: ['pastedChild', 'pastedGrandchild'], - undoStack: 1, - }); + peerReplaces({ someOtherField: 'value' }); + + expect(importToast).not.toHaveBeenCalled(); }); - it('reloads and clears the undo stack when a peer imports a map', () => { - peerTransacts((nodesMap, meta) => { - nodesMap.delete('main'); - meta.set('lastMapAnnouncement', 'import'); - writeNodes(nodesMap, [node('imported', null, true)]); - }); + // ImportService shows its own toast for a local import. + it('keeps the history and shows no toast for our own import', () => { + internals(service).yjsMapData.replaceMap([node('imported', null, true)]); expect({ - reloaded: loadMap.mock.calls.length, undoStack: undoStackLength(), - }).toEqual({ reloaded: 1, undoStack: 0 }); + toasts: importToast.mock.calls.length, + }).toEqual({ undoStack: 2, toasts: 0 }); }); - it('reloads and clears the undo stack when a peer redistributes a map', () => { - peerTransacts((nodesMap, meta) => { - nodesMap.delete('main'); - meta.set('lastMapAnnouncement', 'distribute'); - writeNodes(nodesMap, [node('main', null, true), node('second', null)]); - }); + it('keeps the history when the replacement is our own undo', () => { + internals(service).yjsMapData.replaceMap([node('imported', null, true)]); - expect({ - reloaded: loadMap.mock.calls.length, - undoStack: undoStackLength(), - }).toEqual({ reloaded: 1, undoStack: 0 }); + service.undo(); + + expect(undoStackLength()).toBe(1); }); }); }); diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.ts index 5fd4fbe8..f1062a09 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-sync.service.ts @@ -1,12 +1,8 @@ -import { Subscription } from 'rxjs'; -import { NodePropertyMapping } from '@teammapper/mmp'; +import { auditTime, Subscription } from 'rxjs'; import { CachedMapOptions, DEFAULT_FONT_MAX_SIZE, ExportNodeProperties, - MapCreateEvent, - NodeUpdateEvent, - sortNodesParentFirst, } from '@teammapper/shared'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; @@ -16,89 +12,58 @@ import { UtilsService } from '../utils/utils.service'; import { ToastrService } from 'ngx-toastr'; import { ClientColorMapping, - populateYMapFromNodeProps, - yMapToNodeProps, buildYjsWsUrl, buildYjsProtocols, resolveClientColor, findAffectedNodes, - resolveMmpPropertyUpdate, - collectDescendantIds, + NodesMap, + nodesMapOf, } from './yjs-utils'; import { MapSyncContext, DEFAULT_COLOR, DEFAULT_SELF_COLOR, } from './map-sync-context'; +import { + LAST_MAP_ANNOUNCEMENT, + LOCAL_ORIGIN, + META, + YjsMapData, + replacesMainRoot, +} from './yjs-map-data'; const WS_CLOSE_MAP_DELETED = 4001; -/** - * Which operation caused a full-map replacement. Recorded in the doc because - * Yjs transaction origins are local and never reach the other clients. - */ -type FullMapOperation = 'import' | 'distribute'; - -/** Doc-level metadata, shared with peers alongside the nodes themselves. */ -const META = 'meta'; +/** The value when it is a non-empty string, and the fallback otherwise. */ +const stringOr = (value: unknown, fallback: string): string => + typeof value === 'string' && value !== '' ? value : fallback; -/** - * The most recent full-map announcement broadcast to peers. Only meaningful - * read inside the transaction that wrote it - the value it leaves behind - * describes a past operation, not the one being applied. - */ -const LAST_MAP_ANNOUNCEMENT = 'lastMapAnnouncement'; +/** The longest the cached map lags behind a change of the map data. */ +export const ATTACHED_MAP_AUDIT_MS = 250; /** - * The origin every write of ours carries, and the only one the undo manager - * tracks. A peer's change arrives with the WebsocketProvider as its origin, - * so it is never ours to undo. + * Keeps one open map in sync with the other clients over a Yjs websocket. + * The Y.Doc holds the only copy of the map, and mmp reads and writes it + * through `YjsMapData`. The service owns the connection, presence, the undo + * manager and the import notice. `MapSyncService` calls `initMap` and then + * `attachMap`. */ -const LOCAL_ORIGIN = 'local'; - -/** - * Orders the nodes of a whole map parent-first across every root, then - * appends the orphans no root reaches, in input order. - */ -function parentFirstWithOrphans( - nodes: ExportNodeProperties[] -): ExportNodeProperties[] { - const { ordered, unreached } = sortNodesParentFirst(nodes); - return [...ordered, ...unreached]; -} - -/** - * Orders a batch of added nodes parent-first. A node whose parent is outside - * the batch, such as the top of a subtree pasted under a node this client - * already holds, starts a walk as if it were a root. - */ -function batchParentFirst( - nodes: ExportNodeProperties[] -): ExportNodeProperties[] { - const batchIds = new Set(nodes.map(n => n.id)); - const keys = nodes.map(node => ({ - node, - id: node.id, - parent: node.parent && batchIds.has(node.parent) ? node.parent : null, - isRoot: node.isRoot, - })); - const { ordered, unreached } = sortNodesParentFirst(keys); - return [...ordered, ...unreached].map(key => key.node); -} - export class YjsSyncService { private yDoc: Y.Doc | null = null; + private yjsMapData: YjsMapData | null = null; private wsProvider: WebsocketProvider | null = null; private yjsSynced = false; private yjsWritable = false; private yjsSubscriptions: Subscription[] = []; private yjsMapId: string | null = null; - private yjsNodesObserver: - Parameters['observeDeep']>[0] | null = null; + private yjsNodesObserver: Parameters[0] | null = null; private yjsOptionsObserver: Parameters['observe']>[0] | null = null; private yjsAwarenessHandler: (() => void) | null = null; private yUndoManager: Y.UndoManager | null = null; + // The node this client has selected, which setupAwareness publishes once + // awareness is up. + private selectedNodeId: string | null = null; constructor( private ctx: MapSyncContext, @@ -119,11 +84,20 @@ export class YjsSyncService { return this.yDoc; } + /** The nodes of the open connection. */ + private get nodesMap(): NodesMap { + return nodesMapOf(this.doc); + } + /** - * The nodes of the open connection, typed once instead of at every read. + * The map data over the Y.Doc of the open connection. initMap creates it + * together with the doc. */ - private get nodesMap(): Y.Map> { - return this.doc.getMap('nodes') as Y.Map>; + private get mapData(): YjsMapData { + if (!this.yjsMapData) { + throw new Error('The map connection has no map data'); + } + return this.yjsMapData; } /** @@ -139,7 +113,7 @@ export class YjsSyncService { // ─── Public API ───────────────────────────────────────────── /** - * Sets whether this client may edit the map. Edit mode follows the flag from + * Sets whether this client may edit the map. Edit mode follows this value from * the first sync on, whichever of the sync and this call comes first. */ setWritable(writable: boolean): void { @@ -155,39 +129,50 @@ export class YjsSyncService { this.yUndoManager?.redo(); } - /** - * Run a local change that writes several nodes as one transaction. Yjs - * joins the transactions of the node writes into this one, so peers receive - * a single update and one undo reverts the whole change. - */ - transactLocally(change: () => void): void { - if (!this.yDoc) { - change(); - return; - } - this.yUndoManager?.stopCapturing(); - this.doc.transact(change, LOCAL_ORIGIN); - this.yUndoManager?.stopCapturing(); - } - updateMapOptions(options?: CachedMapOptions): void { this.writeMapOptionsToYDoc(options); } // ─── Connection lifecycle ─────────────────────────────────── + /** + * Open the connection to a map, or reattach to the open one. The first + * sync hands the map data to the context, which creates the map over it + * and then calls `attachMap`. An open connection that has synced hands its + * map data over before `initMap` returns. + */ initMap(uuid: string): void { if (this.hasActiveConnection(uuid)) { - this.reattachListeners(); + if (this.yjsSynced) this.handOverMapData(); return; } this.yjsMapId = uuid; this.yDoc = new Y.Doc(); + // Define `meta` as a map before the first sync. A peer's write would + // otherwise create it as a bare type, and the first getMap call would + // swap in a new object that no transaction's `changed` list contains. + this.yDoc.getMap(META); + this.yjsMapData = new YjsMapData(this.yDoc, () => this.yUndoManager); const provider = this.setupConnection(uuid); this.setupConnectionStatus(provider); this.setupMapDeletionHandler(provider); + } + + /** + * Wire the map the context created over the map data: the mmp listeners, + * the selection, the observers, awareness and the undo manager, and last + * edit mode, so the map exists when edit mode reaches it. + */ + attachMap(): void { + this.detachObservers(); this.createListeners(); + this.attachSelection(); + this.setupNodesObserver(); + this.setupMapOptionsObserver(); + if (!this.yUndoManager) this.initUndoManager(); + this.setupAwareness(); + this.settingsService.setEditMode(this.yjsWritable); } private hasActiveConnection(mapId: string): boolean { @@ -196,18 +181,6 @@ export class YjsSyncService { ); } - private reattachListeners(): void { - this.createListeners(); - if (this.yjsSynced) { - this.loadMapFromYDoc(); - this.setupNodesObserver(); - this.setupMapOptionsObserver(); - this.setupAwareness(); - this.settingsService.setEditMode(this.yjsWritable); - this.ctx.setConnectionStatus('connected'); - } - } - private setupConnection(mapId: string): WebsocketProvider { const wsUrl = buildYjsWsUrl(); const provider = new WebsocketProvider(wsUrl, mapId, this.doc, { @@ -238,22 +211,26 @@ export class YjsSyncService { return this.wsProvider === provider; } + /** + * The map exists from the first sync on, an empty doc included: the + * context creates it over the map data. + */ private handleFirstSync(): void { this.yjsSynced = true; - this.loadMapFromYDoc(); - this.setupNodesObserver(); - this.setupMapOptionsObserver(); - this.initUndoManager(); - this.setupAwareness(); - this.settingsService.setEditMode(this.yjsWritable); + this.handOverMapData(); + } + + /** Report the connection as connected and let the context create the map. */ + private handOverMapData(): void { this.ctx.setConnectionStatus('connected'); + this.ctx.createMap(this.mapData); } private initUndoManager(): void { const undoManager = new Y.UndoManager(this.nodesMap, { - // Everything we write is undoable, full-map replacements included: a - // distribute reverts the layout, an import restores the map it replaced. - // Merely opening a map is not a write - see setupCreateHandler. + // Everything we write is undoable, an import included: its undo + // restores the map it replaced. Opening a map writes nothing, since + // mmp reads the doc through the map data. trackedOrigins: new Set([LOCAL_ORIGIN]), }); this.yUndoManager = undoManager; @@ -289,6 +266,7 @@ export class YjsSyncService { provider.on('connection-close', (event: CloseEvent | null) => { if (!this.isCurrentProvider(provider)) return; if (event?.code === WS_CLOSE_MAP_DELETED) { + this.ctx.mapDeleted(); window.location.reload(); } }); @@ -296,37 +274,49 @@ export class YjsSyncService { // ─── Cleanup ──────────────────────────────────────────────── + /** + * Close the connection. Edit mode keeps its value, because the settings + * page reads it to decide whether the map settings of the map the user + * left stay editable. + */ destroy(): void { this.unsubscribeListeners(); this.detachObservers(); - if (this.yUndoManager) { - this.yUndoManager.destroy(); - this.yUndoManager = null; - this.ctx.setCanUndo(false); - this.ctx.setCanRedo(false); - } - const provider = this.wsProvider; - this.wsProvider = null; - if (provider) { - provider.disconnect(); - provider.destroy(); - } - if (this.yDoc) { - this.yDoc.destroy(); - this.yDoc = null; - } + this.destroyUndoManager(); + this.destroyConnection(); this.yjsSynced = false; this.yjsWritable = false; this.yjsMapId = null; + this.selectedNodeId = null; // Reset to the pre-connection state. A leftover 'disconnected' would // reopen the connection-lost dialog over the next map. this.ctx.setConnectionStatus(null); } + private destroyUndoManager(): void { + if (!this.yUndoManager) return; + this.yUndoManager.destroy(); + this.yUndoManager = null; + this.ctx.setCanUndo(false); + this.ctx.setCanRedo(false); + } + + private destroyConnection(): void { + const provider = this.wsProvider; + this.wsProvider = null; + if (provider) { + provider.disconnect(); + provider.destroy(); + } + this.yjsMapData?.destroy(); + this.yjsMapData = null; + this.yDoc?.destroy(); + this.yDoc = null; + } + private detachObservers(): void { if (this.yDoc && this.yjsNodesObserver) { - const nodesMap = this.yDoc.getMap('nodes'); - nodesMap.unobserveDeep(this.yjsNodesObserver); + this.nodesMap.unobserve(this.yjsNodesObserver); this.yjsNodesObserver = null; } if (this.yDoc && this.yjsOptionsObserver) { @@ -345,69 +335,33 @@ export class YjsSyncService { this.yjsSubscriptions = []; } - // ─── Initial map load ─────────────────────────────────────── - - private loadMapFromYDoc(): void { - const snapshot = this.extractSnapshotFromYDoc(this.nodesMap); - if (snapshot.length > 0) { - this.mmpService.new(snapshot, false); - } - } - - private extractSnapshotFromYDoc( - nodesMap: Y.Map> - ): ExportNodeProperties[] { - const nodes: ExportNodeProperties[] = []; - nodesMap.forEach((yNode: Y.Map) => { - nodes.push(yMapToNodeProps(yNode)); - }); - return parentFirstWithOrphans(nodes); - } - - // ─── MMP event listeners (MMP → Y.Doc) ───────────────────── + // ─── mmp event listeners ──────────────────────────────────── private createListeners(): void { this.unsubscribeListeners(); - this.setupCreateHandler(); - this.setupDistributeHandler(); + this.setupMapChangeHandler(); this.setupSelectionHandlers(); - this.setupNodeUpdateHandler(); - this.setupNodeCreateHandler(); - this.setupPasteHandler(); - this.setupNodeRemoveHandler(); - } - - /** - * An import replaces the whole map, so it goes out as a full-map - * replacement. Opening a map emits `create` too, but never reaches the undo - * stack: MapSyncService.initMap emits it before subscribing here, and - * loadMapFromYDoc replays the map with notifyWithEvent = false. - */ - private setupCreateHandler(): void { - this.yjsSubscriptions.push( - this.mmpService.on('create').subscribe((_result: MapCreateEvent) => { - this.ctx.setAttachedNode(this.mmpService.selectNode()); - this.ctx.updateAttachedMap(); - if (this.yjsSynced) { - this.writeFullMapToYDoc('import'); - } - }) - ); } /** - * A redistribution rewrites every node's coordinates at once, so it goes out - * as a full-map replacement rather than as one update per node. + * mmp draws every change of the map data, local or remote, and then emits + * `mapChange`. The attached node follows every change, so the panels + * never show the values from before it. The cached map exports the whole + * map, so it follows at most once per `ATTACHED_MAP_AUDIT_MS`, with the + * state after the last change; a peer's burst of writes or a color picker + * drag then exports once instead of per write. */ - private setupDistributeHandler(): void { + private setupMapChangeHandler(): void { this.yjsSubscriptions.push( - this.mmpService.on('distribute').subscribe(() => { - if (!this.yDoc) return; - if (this.yjsSynced) { - this.writeFullMapToYDoc('distribute'); - } - this.ctx.updateAttachedMap(); - }) + this.mmpService + .on('mapChange') + .subscribe(() => + this.ctx.setAttachedNode(this.mmpService.selectNode()) + ), + this.mmpService + .on('mapChange') + .pipe(auditTime(ATTACHED_MAP_AUDIT_MS)) + .subscribe(() => void this.ctx.updateAttachedMap()) ); } @@ -433,153 +387,17 @@ export class YjsSyncService { ); } - private setupNodeUpdateHandler(): void { - this.yjsSubscriptions.push( - this.mmpService.on('nodeUpdate').subscribe((result: NodeUpdateEvent) => { - if (!this.yDoc) return; - this.ctx.setAttachedNode(result.nodeProperties); - this.writeNodeUpdateToYDoc(result); - this.ctx.updateAttachedMap(); - }) - ); - } - - private setupNodeCreateHandler(): void { - this.yjsSubscriptions.push( - this.mmpService - .on('nodeCreate') - .subscribe((newNode: ExportNodeProperties) => { - if (!this.yDoc) return; - this.writeNodeCreateToYDoc(newNode); - this.ctx.updateAttachedMap(); - this.mmpService.selectNode(newNode.id); - this.mmpService.editNode(); - }) - ); - } - - private setupPasteHandler(): void { - this.yjsSubscriptions.push( - this.mmpService - .on('nodePaste') - .subscribe((newNodes: ExportNodeProperties[]) => { - if (!this.yDoc) return; - this.writeNodesPasteToYDoc(newNodes); - this.ctx.updateAttachedMap(); - }) - ); - } - - private setupNodeRemoveHandler(): void { - this.yjsSubscriptions.push( - this.mmpService - .on('nodeRemove') - .subscribe((removedNode: ExportNodeProperties) => { - if (!this.yDoc) return; - this.writeNodeRemoveFromYDoc(removedNode.id); - this.ctx.updateAttachedMap(); - }) - ); - } - - // ─── Write operations (MMP → Y.Doc) ──────────────────────── - - private writeNodeCreateToYDoc(nodeProps: ExportNodeProperties): void { - const nodesMap = this.nodesMap; - this.doc.transact(() => { - const yNode = new Y.Map(); - populateYMapFromNodeProps(yNode, nodeProps); - nodesMap.set(nodeProps.id, yNode); - }, LOCAL_ORIGIN); - } - - private writeNodeUpdateToYDoc(event: NodeUpdateEvent): void { - const nodesMap = this.nodesMap; - const yNode = nodesMap.get(event.nodeProperties.id); - if (!yNode) return; - - this.doc.transact(() => { - const topLevelKey = - NodePropertyMapping[ - event.changedProperty as keyof typeof NodePropertyMapping - ][0]; - const value = - event.nodeProperties[topLevelKey as keyof ExportNodeProperties]; - yNode.set(topLevelKey, value); - }, LOCAL_ORIGIN); - } - - private writeNodeRemoveFromYDoc(nodeId: string): void { - const nodesMap = this.nodesMap; - if (!nodesMap.has(nodeId)) return; - - const descendantIds = collectDescendantIds(nodesMap, nodeId); - - this.doc.transact(() => { - nodesMap.delete(nodeId); - for (const id of descendantIds) { - nodesMap.delete(id); - } - }, LOCAL_ORIGIN); - } - - private writeNodesPasteToYDoc(nodes: ExportNodeProperties[]): void { - const nodesMap = this.nodesMap; - this.doc.transact(() => { - for (const node of nodes) { - const yNode = new Y.Map(); - populateYMapFromNodeProps(yNode, node); - nodesMap.set(node.id, yNode); - } - }, LOCAL_ORIGIN); - } - - private writeFullMapToYDoc(operation: FullMapOperation): void { - const snapshot = this.mmpService.exportAsJSON(); - const nodesMap = this.nodesMap; - const sorted = parentFirstWithOrphans(snapshot); - - // Without this, Yjs merges the replacement with whatever the user did in - // the preceding half second and one undo would revert both. - this.yUndoManager?.stopCapturing(); - - this.doc.transact(() => { - this.doc.getMap(META).set(LAST_MAP_ANNOUNCEMENT, operation); - this.clearAndRepopulateNodes(nodesMap, sorted); - }, LOCAL_ORIGIN); - } - /** - * A redistribution replaces the map the way an import does, and an undo - * replays the nodes without recording an operation at all. Only a deliberate - * replacement announces itself in the same transaction as the nodes, so the - * announcement is read per key: an unrelated write to `meta` is not one. + * Take over the selection the map made when it was created. Its + * `nodeSelect` fired before the listeners above existed. */ - private shouldAnnounceImport(mapEvent: Y.YMapEvent>): boolean { - const meta = this.doc.getMap(META); - // Yjs keys `transaction.changed` by an erased `AbstractType`, which no - // concrete `Y.Map` satisfies. The lookup compares object identity. - const announced = mapEvent.transaction.changed.get( - meta as unknown as Y.AbstractType>> - ); - if (!announced?.has(LAST_MAP_ANNOUNCEMENT)) return false; - - return meta.get(LAST_MAP_ANNOUNCEMENT) !== 'distribute'; + private attachSelection(): void { + const selected = this.mmpService.selectNode(); + this.ctx.setAttachedNode(selected); + this.updateAwarenessSelection(selected?.id ?? null); } - private clearAndRepopulateNodes( - nodesMap: Y.Map>, - snapshot: ExportNodeProperties[] - ): void { - for (const key of Array.from(nodesMap.keys())) { - nodesMap.delete(key); - } - for (const node of snapshot) { - const yNode = new Y.Map(); - populateYMapFromNodeProps(yNode, node); - nodesMap.set(node.id, yNode); - } - } + // ─── Map options (Y.Doc ↔ MMP) ────────────────────────────── private writeMapOptionsToYDoc(options?: CachedMapOptions): void { // The settings page can change these with no map open, so this is the one @@ -595,138 +413,6 @@ export class YjsSyncService { }, LOCAL_ORIGIN); } - // ─── Y.Doc observers (Y.Doc → MMP) ───────────────────────── - - private setupNodesObserver(): void { - const nodesMap = this.nodesMap; - this.yjsNodesObserver = ( - events: Y.YEvent>>>[], - transaction: Y.Transaction - ) => { - if (transaction.local && transaction.origin !== this.yUndoManager) return; - for (const event of events) { - this.handleNodeEvent(event, nodesMap); - } - }; - nodesMap.observeDeep(this.yjsNodesObserver); - } - - private handleNodeEvent( - event: Y.YEvent>>>, - nodesMap: Y.Map> - ): void { - // `event.target` carries the same erased `AbstractType`. - if ((event.target as unknown) === nodesMap) { - this.handleTopLevelNodeChanges(event, nodesMap); - } else { - this.handleNodePropertyChanges(event); - } - } - - private handleTopLevelNodeChanges( - event: Y.YEvent>>>, - nodesMap: Y.Map> - ): void { - const mapEvent = event as unknown as Y.YMapEvent>; - - if (this.isFullMapReplacement(mapEvent, nodesMap)) { - this.loadMapFromYDoc(); - // A peer replaced the whole map, so our history describes a map that no - // longer exists. A replacement is a delete-and-reinsert that no CRDT can - // merge back, so undoing into it would leave the map with two roots. - if (!mapEvent.transaction.local) this.yUndoManager?.clear(); - if (this.shouldAnnounceImport(mapEvent)) { - this.showImportToast(); - } - return; - } - - const adds: string[] = []; - - mapEvent.keysChanged.forEach(key => { - const change = mapEvent.changes.keys.get(key); - if (!change) return; - - if (change.action === 'add') { - adds.push(key); - } else if (change.action === 'update') { - this.applyRemoteNodeDelete(key); - this.applyRemoteNodeAdd(nodesMap.get(key)); - } else if (change.action === 'delete') { - this.applyRemoteNodeDelete(key); - } - }); - - if (adds.length > 0) { - const nodeProps = adds - .map(key => nodesMap.get(key)) - .filter((yNode): yNode is Y.Map => !!yNode) - .map(yNode => yMapToNodeProps(yNode)); - const sorted = batchParentFirst(nodeProps); - sorted.forEach(props => this.mmpService.addNodesFromServer([props])); - } - } - - private async showImportToast(): Promise { - const msg = await this.utilsService.translate('TOASTS.MAP_IMPORT_SUCCESS'); - if (msg) this.toastrService.success(msg); - } - - /** - * Reads `isRoot`, which marks the main root only. Adding a tree or pasting - * one writes roots without the mark, so the check fires only when a - * transaction rewrites the main root's entry: an import, a redistribution, - * or an undo of either. Yjs reports a delete and re-set of one key as - * `update`, so the check reads `add` and `update`. - */ - private isFullMapReplacement( - mapEvent: Y.YMapEvent>, - nodesMap: Y.Map> - ): boolean { - for (const [key, change] of mapEvent.changes.keys) { - if (change.action === 'add' || change.action === 'update') { - const yNode = nodesMap.get(key); - if (yNode?.get('isRoot')) return true; - } - } - return false; - } - - private handleNodePropertyChanges( - event: Y.YEvent>>> - ): void { - const yNode = event.target as unknown as Y.Map; - const nodeId = yNode.get('id') as string; - if (!nodeId || !this.mmpService.existNode(nodeId)) return; - - const mapEvent = event as unknown as Y.YMapEvent; - mapEvent.keysChanged.forEach(key => { - this.applyYDocPropertyToMmp(nodeId, key, yNode.get(key)); - }); - } - - private applyRemoteNodeAdd(yNode: Y.Map | undefined): void { - if (!yNode) return; - const nodeProps = yMapToNodeProps(yNode); - this.mmpService.addNodesFromServer([nodeProps]); - } - - private applyRemoteNodeDelete(nodeId: string): void { - if (this.mmpService.existNode(nodeId)) { - this.mmpService.removeNode(nodeId, false); - } - } - - private applyYDocPropertyToMmp( - nodeId: string, - yjsKey: string, - value: unknown - ): void { - for (const update of resolveMmpPropertyUpdate(yjsKey, value)) { - this.mmpService.updateNode(update.prop, update.val, false, false, nodeId); - } - } - private setupMapOptionsObserver(): void { const optionsMap = this.doc.getMap('mapOptions'); this.yjsOptionsObserver = (_: unknown, transaction: Y.Transaction) => { @@ -747,6 +433,51 @@ export class YjsSyncService { this.mmpService.updateAdditionalMapOptions(options); } + // ─── A peer's map replacement ─────────────────────────────── + + /** + * Watch for a peer replacing the whole map. mmp learns of the new nodes + * through the map data, so this observer only clears the undo stack and + * announces an import. Our own transactions, an undo included, need + * neither: ImportService shows its own toast for a local import. + */ + private setupNodesObserver(): void { + const nodesMap = this.nodesMap; + this.yjsNodesObserver = (event, transaction) => { + if (transaction.local) return; + if (!replacesMainRoot(event.changes.keys, nodesMap)) return; + // A peer replaced the whole map, so our history describes a map that no + // longer exists. A replacement is a delete-and-reinsert that no CRDT can + // merge back, so undoing into it would leave the map with two roots. + this.yUndoManager?.clear(); + if (this.announcesImport(transaction)) void this.showImportToast(); + }; + nodesMap.observe(this.yjsNodesObserver); + } + + /** + * An undo replays the nodes and writes no announcement. An import writes + * `LAST_MAP_ANNOUNCEMENT` in the same transaction as the nodes, so the + * method checks that this transaction changed that key: a write to another + * key of `meta` announces nothing. + */ + private announcesImport(transaction: Y.Transaction): boolean { + const meta = this.doc.getMap(META); + // Yjs keys `transaction.changed` by an erased `AbstractType`, which no + // concrete `Y.Map` satisfies. The lookup compares object identity. + const announced = transaction.changed.get( + meta as unknown as Y.AbstractType>> + ); + if (!announced?.has(LAST_MAP_ANNOUNCEMENT)) return false; + + return meta.get(LAST_MAP_ANNOUNCEMENT) === 'import'; + } + + private async showImportToast(): Promise { + const msg = await this.utilsService.translate('TOASTS.MAP_IMPORT_SUCCESS'); + if (msg) this.toastrService.success(msg); + } + // ─── Awareness (presence, selection, client list) ─────────── private setupAwareness(): void { @@ -756,7 +487,7 @@ export class YjsSyncService { awareness.setLocalStateField('user', { color, - selectedNodeId: null, + selectedNodeId: this.selectedNodeId, }); this.yjsAwarenessHandler = () => { @@ -777,9 +508,10 @@ export class YjsSyncService { } private updateAwarenessSelection(nodeId: string | null): void { - // The map load on first sync selects the root before setupAwareness runs. - // A write then would make pickClientColor count this client's own colour - // as taken. + this.selectedNodeId = nodeId; + // Until setupAwareness runs, the method only records the selection, and + // setupAwareness publishes it. A write before then would make + // pickClientColor count this client's own colour as taken. if (!this.wsProvider || this.yjsAwarenessHandler === null) return; this.wsProvider.awareness.setLocalStateField('user', { color: this.ctx.getClientColor(), @@ -798,6 +530,11 @@ export class YjsSyncService { this.ctx.emitClientList(); } + /** + * The color and selected node of every client. Any peer, a read-only one + * included, writes its own awareness state, so a value that is no string + * falls back to the default instead of reaching mmp. + */ private buildColorMappingFromAwareness(): ClientColorMapping { const awareness = this.provider.awareness; const localClientId = this.doc.clientID; @@ -807,8 +544,10 @@ export class YjsSyncService { if (!state?.user) continue; const isSelf = clientId === localClientId; mapping[String(clientId)] = { - color: isSelf ? DEFAULT_SELF_COLOR : state.user.color || DEFAULT_COLOR, - nodeId: state.user.selectedNodeId || '', + color: isSelf + ? DEFAULT_SELF_COLOR + : stringOr(state.user.color, DEFAULT_COLOR), + nodeId: stringOr(state.user.selectedNodeId, ''), }; } return mapping; @@ -818,7 +557,7 @@ export class YjsSyncService { for (const nodeId of nodeIds) { if (!this.mmpService.existNode(nodeId)) continue; const color = this.ctx.colorForNode(nodeId); - this.mmpService.highlightNode(nodeId, color, false); + this.mmpService.highlightNode(nodeId, color); } } } diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-undo-manager.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-undo-manager.spec.ts index 32d97750..68915ce4 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/yjs-undo-manager.spec.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-undo-manager.spec.ts @@ -19,7 +19,6 @@ describe('YjsUndoManager', () => { colors: { branch: '#000000' }, font: { size: 14, style: 'normal', weight: 'normal' }, protected: false, - hidden: false, coordinates: undefined, image: undefined, link: undefined, @@ -117,74 +116,6 @@ describe('YjsUndoManager', () => { }); }); - // ─── Echo prevention ───────────────────────────────────────── - - describe('echo prevention', () => { - let doc: Y.Doc; - let nodesMap: Y.Map>; - let undoManager: Y.UndoManager; - - beforeEach(() => { - ({ doc, nodesMap } = createYjsContext()); - }); - - afterEach(() => { - undoManager?.destroy(); - doc.destroy(); - }); - - function observeWithFilter(um: Y.UndoManager): string[] { - const calls: string[] = []; - nodesMap.observeDeep((_events, transaction) => { - if (transaction.local && transaction.origin !== um) { - calls.push('skipped'); - return; - } - calls.push('applied'); - }); - return calls; - } - - it('filters local edits in observer', () => { - undoManager = createTrackedUndoManager(nodesMap); - const calls = observeWithFilter(undoManager); - - addNodeToMap(doc, nodesMap, { id: 'n1' }); - - expect(calls).toEqual(['skipped']); - }); - - it('passes through UndoManager-originated edits', () => { - addNodeToMap(doc, nodesMap, { id: 'n1' }); - undoManager = createTrackedUndoManager(nodesMap); - addNodeToMap(doc, nodesMap, { id: 'n2' }); - - const calls = observeWithFilter(undoManager); - undoManager.undo(); - - expect(calls).toEqual(['applied']); - }); - - it('passes through remote edits', () => { - const remoteDoc = new Y.Doc(); - const remoteNodesMap = remoteDoc.getMap('nodes') as Y.Map>; - - undoManager = createTrackedUndoManager(nodesMap); - const calls = observeWithFilter(undoManager); - - remoteDoc.transact(() => { - const yNode = new Y.Map(); - populateYMapFromNodeProps(yNode, createMockNode({ id: 'r1' })); - remoteNodesMap.set('r1', yNode); - }); - - Y.applyUpdate(doc, Y.encodeStateAsUpdate(remoteDoc)); - - expect(calls).toEqual(['applied']); - remoteDoc.destroy(); - }); - }); - // ─── canUndo/canRedo reactive state ────────────────────────── describe('canUndo/canRedo reactive state', () => { diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.spec.ts index 68f26abb..beb5c4d1 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.spec.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.spec.ts @@ -8,34 +8,9 @@ import { toTransmittableSecret, resolveClientColor, findAffectedNodes, - resolveMmpPropertyUpdate, - resolveCompoundMmpUpdates, collectDescendantIds, } from './yjs-utils'; -// Mock the NodePropertyMapping module -jest.mock('@teammapper/mmp', () => ({ - NodePropertyMapping: { - name: ['name'], - protected: ['protected'], - coordinates: ['coordinates'], - imageSrc: ['image', 'src'], - imageSize: ['image', 'size'], - linkHref: ['link', 'href'], - backgroundColor: ['colors', 'background'], - branchColor: ['colors', 'branch'], - fontWeight: ['font', 'weight'], - fontStyle: ['font', 'style'], - fontSize: ['font', 'size'], - nameColor: ['colors', 'name'], - hidden: ['hidden'], - }, -})); - -// Import NodePropertyMapping after mocking - needed for reverse mapping -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { NodePropertyMapping } from '@teammapper/mmp'; - function createMockNode( overrides?: Partial ): ExportNodeProperties { @@ -47,7 +22,6 @@ function createMockNode( colors: { branch: '#000000' }, font: { size: 14, style: 'normal', weight: 'normal' }, protected: false, - hidden: false, coordinates: undefined, image: undefined, link: undefined, @@ -113,39 +87,36 @@ describe('Y.Doc conversion utilities', () => { ); }); - it('applies defaults for missing optional properties', () => { - const input: ExportNodeProperties = { - id: 'n2', - parent: undefined, - k: undefined, - name: undefined, - isRoot: undefined, - protected: undefined, - coordinates: undefined, - colors: undefined, - font: undefined, - image: undefined, - link: undefined, - } as unknown as ExportNodeProperties; - + it('reads back a key the Y.Map lacks as absent', () => { const yNode = new Y.Map(); - populateYMapFromNodeProps(yNode, input); nodesMap.set('n2', yNode); + yNode.set('id', 'n2'); + yNode.set('parent', 'root'); - const result = yMapToNodeProps(nodesMap.get('n2')!); + expect(yMapToNodeProps(yNode)).toStrictEqual({ id: 'n2', parent: 'root' }); + }); - expect(result).toEqual( - expect.objectContaining({ - parent: null, - k: 1, - name: '', - isRoot: false, - protected: false, - coordinates: { x: 0, y: 0 }, - }) + it('writes no key for an attribute that is undefined', () => { + const yNode = new Y.Map(); + populateYMapFromNodeProps(yNode, createMockNode({ id: 'n2' })); + nodesMap.set('n2', yNode); + + expect(['coordinates', 'image', 'link'].map(key => yNode.has(key))).toEqual( + [false, false, false] ); }); + it.each([ + ['null', null], + ['an empty string', ''], + ])('stores null as the parent of a root given %s', (_label, parent) => { + const yNode = new Y.Map(); + populateYMapFromNodeProps(yNode, createMockNode({ id: 'r', parent })); + nodesMap.set('r', yNode); + + expect(yNode.get('parent')).toBeNull(); + }); + it('writes no detached entry', () => { const yNode = new Y.Map(); populateYMapFromNodeProps(yNode, createMockNode()); @@ -255,63 +226,6 @@ describe('toTransmittableSecret', () => { }); }); -// ─── Y.Doc property application to MMP ─────────────────────── - -describe('Y.Doc property application to MMP', () => { - it('resolves simple property (name) via reverse mapping', () => { - const updates = resolveMmpPropertyUpdate('name', 'New Name'); - expect(updates).toEqual([{ prop: 'name', val: 'New Name' }]); - }); - - it('resolves simple property (protected) via reverse mapping', () => { - const updates = resolveMmpPropertyUpdate('protected', true); - expect(updates).toEqual([{ prop: 'protected', val: true }]); - }); - - it('resolves compound property (colors) via reverse mapping', () => { - const updates = resolveMmpPropertyUpdate('colors', { - background: '#ff0000', - branch: '#00ff00', - name: '#0000ff', - }); - - expect(updates).toEqual([ - { prop: 'backgroundColor', val: '#ff0000' }, - { prop: 'branchColor', val: '#00ff00' }, - { prop: 'nameColor', val: '#0000ff' }, - ]); - }); - - it('resolves compound property (font) via reverse mapping', () => { - const updates = resolveMmpPropertyUpdate('font', { - size: 20, - weight: 'bold', - style: 'italic', - }); - - expect(updates).toEqual( - expect.arrayContaining([ - { prop: 'fontSize', val: 20 }, - { prop: 'fontWeight', val: 'bold' }, - { prop: 'fontStyle', val: 'italic' }, - ]) - ); - }); - - it('returns empty array for unknown property keys', () => { - const updates = resolveMmpPropertyUpdate('unknown_key', 'value'); - expect(updates).toEqual([]); - }); - - it('handles null compound value gracefully', () => { - const updates = resolveCompoundMmpUpdates( - { background: 'backgroundColor' }, - null as unknown as Record - ); - expect(updates).toEqual([]); - }); -}); - // ─── Client color resolution ───────────────────────────────── describe('client color resolution', () => { diff --git a/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.ts b/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.ts index 32a017ea..ec3a3551 100644 --- a/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.ts +++ b/teammapper-frontend/src/app/core/services/map-sync/yjs-utils.ts @@ -1,5 +1,4 @@ import * as Y from 'yjs'; -import { ReversePropertyMapping } from './server-types'; import { collectSubtreeIds, ExportNodeProperties, @@ -7,6 +6,35 @@ import { YJS_SUBPROTOCOL, } from '@teammapper/shared'; +/** + * The Y.Doc's `nodes` map. Any peer with write access can store any value + * under a key, so the type promises no Y.Map: read entries through `nodeAt` + * and `nodeEntries`, which skip every other value. + */ +export type NodesMap = Y.Map; + +export function nodesMapOf(doc: Y.Doc): NodesMap { + return doc.getMap('nodes'); +} + +/** The node stored under `id`, or undefined when the entry is no Y.Map. */ +export function nodeAt( + nodesMap: Y.Map, + id: string +): Y.Map | undefined { + const entry = nodesMap.get(id); + return entry instanceof Y.Map ? entry : undefined; +} + +/** Every node of the map with its key, without the entries that are no Y.Map. */ +export function nodeEntries(nodesMap: Y.Map): [string, Y.Map][] { + const entries: [string, Y.Map][] = []; + nodesMap.forEach((entry, id) => { + if (entry instanceof Y.Map) entries.push([id, entry]); + }); + return entries; +} + export type ClientColorMapping = Record; export interface ClientColorMappingValue { @@ -14,56 +42,52 @@ export interface ClientColorMappingValue { color: string; } +/** The keys a node's Y.Map holds, one per node attribute. */ +const NODE_KEYS = [ + 'id', + 'parent', + 'k', + 'name', + 'isRoot', + 'protected', + 'coordinates', + 'colors', + 'font', + 'image', + 'link', +] as const satisfies readonly (keyof ExportNodeProperties)[]; + +/** + * A node's attributes as its Y.Map stores them. A peer may write a value of + * any type under any key, so every value is unknown until mmp's + * `resolveNode` checks it. + */ +export type StoredNode = Partial>; + +/** + * Write every attribute of the node that is not undefined to its Y.Map. A + * root's parent goes in as null, also when the caller passes ''. + */ export function populateYMapFromNodeProps( yNode: Y.Map, nodeProps: ExportNodeProperties ): void { - yNode.set('id', nodeProps.id); - yNode.set('parent', nodeProps.parent ?? null); - yNode.set('name', nodeProps.name ?? ''); - yNode.set('isRoot', nodeProps.isRoot ?? false); - yNode.set('protected', nodeProps.protected ?? false); - yNode.set('k', nodeProps.k ?? 1); - yNode.set('coordinates', nodeProps.coordinates ?? { x: 0, y: 0 }); - yNode.set( - 'colors', - nodeProps.colors ?? { name: '', background: '', branch: '' } - ); - yNode.set('font', nodeProps.font ?? { size: 12, style: '', weight: '' }); - yNode.set('image', nodeProps.image ?? { src: '', size: 0 }); - yNode.set('link', nodeProps.link ?? { href: '' }); + for (const key of NODE_KEYS) { + const value = key === 'parent' ? nodeProps.parent || null : nodeProps[key]; + if (value !== undefined) yNode.set(key, value); + } } -export function yMapToNodeProps(yNode: Y.Map): ExportNodeProperties { - return { - id: yNode.get('id') as string, - parent: (yNode.get('parent') as string) ?? null, - k: (yNode.get('k') as number) ?? 1, - name: (yNode.get('name') as string) ?? '', - isRoot: (yNode.get('isRoot') as boolean) ?? false, - protected: (yNode.get('protected') as boolean) ?? false, - coordinates: (yNode.get('coordinates') as { x: number; y: number }) ?? { - x: 0, - y: 0, - }, - colors: (yNode.get('colors') as ExportNodeProperties['colors']) ?? { - name: '', - background: '', - branch: '', - }, - font: (yNode.get('font') as ExportNodeProperties['font']) ?? { - size: 12, - style: '', - weight: '', - }, - image: (yNode.get('image') as ExportNodeProperties['image']) ?? { - src: '', - size: 0, - }, - link: (yNode.get('link') as ExportNodeProperties['link']) ?? { - href: '', - }, - }; +/** + * The node as its Y.Map stores it. A key the Y.Map lacks stays absent, and + * mmp's `resolveNode` fills it on read. + */ +export function yMapToNodeProps(yNode: Y.Map): StoredNode { + const record: StoredNode = {}; + for (const key of NODE_KEYS) { + if (yNode.has(key)) record[key] = yNode.get(key); + } + return record; } export function buildYjsWsUrl(): string { @@ -127,55 +151,15 @@ export function findAffectedNodes( return nodes; } -export interface MmpPropertyUpdate { - prop: string; - val: unknown; -} - -export function resolveMmpPropertyUpdate( - yjsKey: string, - value: unknown -): MmpPropertyUpdate[] { - const mapping = - ReversePropertyMapping[yjsKey as keyof typeof ReversePropertyMapping]; - if (!mapping) return []; - - if (typeof mapping === 'string') { - return [{ prop: mapping, val: value }]; - } - - return resolveCompoundMmpUpdates( - mapping as Record, - value as Record - ); -} - // Collects all descendant node IDs using the shared cycle-safe BFS algorithm. -export function collectDescendantIds( - nodesMap: Y.Map>, +export function collectDescendantIds( + nodesMap: Y.Map, nodeId: string ): string[] { - const nodes: { id: string; parent: string | null }[] = []; - nodesMap.forEach((yNode: Y.Map, key: string) => { - nodes.push({ - id: key, - parent: (yNode.get('parent') as string | null) ?? null, - }); - }); + const nodes = nodeEntries(nodesMap).map(([id, yNode]) => ({ + id, + parent: (yNode.get('parent') as string | null) ?? null, + })); return collectSubtreeIds(nodes, nodeId); } - -export function resolveCompoundMmpUpdates( - mapping: Record, - value: Record -): MmpPropertyUpdate[] { - if (!value) return []; - const updates: MmpPropertyUpdate[] = []; - for (const [subKey, mmpProp] of Object.entries(mapping)) { - if (subKey in value) { - updates.push({ prop: mmpProp, val: value[subKey] }); - } - } - return updates; -} diff --git a/teammapper-frontend/src/app/core/services/mmp/mmp.service.spec.ts b/teammapper-frontend/src/app/core/services/mmp/mmp.service.spec.ts index d1b98249..d3aa8c91 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.spec.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.spec.ts @@ -1,11 +1,11 @@ import { TestBed } from '@angular/core/testing'; -import { MmpService } from './mmp.service'; +import { MmpService, NODE_FONT_FAMILY } from './mmp.service'; import { SettingsService } from '../settings/settings.service'; import { ToastrService } from 'ngx-toastr'; import { UtilsService } from '../utils/utils.service'; import * as mmp from '@teammapper/mmp'; import { Subject } from 'rxjs'; -import { OptionParameters } from '@teammapper/mmp'; +import type { MapData, MmpMap, OptionParameters } from '@teammapper/mmp'; import { ImageUploadError } from './node-images'; jest.mock('dompurify', () => { @@ -24,6 +24,19 @@ jest.mock('@teammapper/mmp', () => ({ const REFERENCE = 'image:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; +// mmp is mocked, so the map data only has to be passed through. +const DATA: MapData = { + node: () => undefined, + nodes: () => [], + mainRootId: () => null, + addNodes: jest.fn(), + updateNode: jest.fn(), + removeNode: jest.fn(), + replaceMap: jest.fn(), + batch: change => change(), + subscribe: () => () => undefined, +}; + const downloadFileSpy = jest .spyOn(UtilsService, 'downloadFile') .mockImplementation(jest.fn()); @@ -37,8 +50,7 @@ describe('MmpService', () => { const mockMap = { instance: { - unsubscribeAll: jest.fn(), - remove: jest.fn(), + destroy: jest.fn(), new: jest.fn(), zoomIn: jest.fn(), zoomOut: jest.fn(), @@ -46,7 +58,6 @@ describe('MmpService', () => { exportAsImage: jest.fn(), center: jest.fn(), on: jest.fn(), - addNodes: jest.fn(), addNode: jest.fn(), selectNode: jest.fn(), exportRootProperties: jest.fn(), @@ -61,6 +72,7 @@ describe('MmpService', () => { pasteNode: jest.fn(), pasteTree: jest.fn(), toggleBranchVisibility: jest.fn(), + childNodesHidden: jest.fn(), distributeNodes: jest.fn(), nodeChildren: jest.fn(), addTree: jest.fn(), @@ -73,6 +85,10 @@ describe('MmpService', () => { }, }; + /** Create the map with no options over `DATA`. */ + const createMap = () => + service.create('test-id', document.createElement('div'), undefined, DATA); + beforeEach(() => { (mmp.create as jest.Mock).mockReturnValue(mockMap); editModeSubject = new Subject(); @@ -132,26 +148,196 @@ describe('MmpService', () => { expect(service.hasSelectedNode()).toBe(false); }); + describe('before create', () => { + it('answers the template and presence queries with defaults', () => { + service.highlightNode('node', '#ff0000'); + service.addNode(); + service.moveNodeTo('left'); + + expect({ + selected: service.selectNode('left'), + hasSelected: service.hasSelectedNode(), + exists: service.existNode('node'), + protecting: service.protectingNode(), + hidden: service.childNodesHidden(), + }).toEqual({ + selected: null, + hasSelected: false, + exists: false, + protecting: null, + hidden: false, + }); + }); + + it.each([ + ['zoomIn', () => service.zoomIn()], + ['center', () => service.center()], + ['addTree', () => service.addTree()], + ['editNode', () => service.editNode()], + ['toggleBranchVisibility', () => service.toggleBranchVisibility()], + ['toggleBranchProtection', () => service.toggleBranchProtection()], + ['distributeNodes', () => service.distributeNodes()], + ['getRootNode', () => service.getRootNode()], + ])( + 'throws for the command %s, which waits for mapCreated$', + (_, command) => { + expect(command).toThrow('No mind map has been created yet'); + } + ); + + it.each([ + ['updateNode', () => service.updateNode('fontSize', 14)], + ['removeNode', () => service.removeNode('node')], + ['copyNode', () => service.copyNode('node')], + ['cutNode', () => service.cutNode('node')], + ['pasteNode', () => service.pasteNode()], + ])('rejects the command %s without a toast', async (_, command) => { + await expect(command()).rejects.toThrow( + 'No mind map has been created yet' + ); + expect(toastrService.error).not.toHaveBeenCalled(); + }); + + it('rejects an import and an export', async () => { + await expect(service.new([])).rejects.toThrow( + 'No mind map has been created yet' + ); + await expect(service.exportMap('json')).rejects.toThrow( + 'No mind map has been created yet' + ); + expect(downloadFileSpy).not.toHaveBeenCalled(); + }); + }); + describe('create', () => { it('should create a new mind map', async () => { const id = 'test-id'; const element = document.createElement('div'); - const options: OptionParameters = { drag: true }; + const options: OptionParameters = { zoom: true }; + editModeSubject.next(true); - await service.create(id, element, options); + const created = await service.create(id, element, options, DATA); + expect(created).toBe(mockMap); expect(mmp.create).toHaveBeenCalledWith( id, element, - expect.objectContaining(options) + expect.objectContaining({ ...options, edit: true, drag: true }), + DATA ); }); + it('creates the map with an edit mode reported before it existed', async () => { + editModeSubject.next(false); + + await service.create( + 'test-id', + document.createElement('div'), + { drag: true, edit: true }, + DATA + ); + + expect(mmp.create).toHaveBeenCalledWith( + 'test-id', + expect.anything(), + expect.objectContaining({ edit: false, drag: false }), + DATA + ); + }); + + it('creates a read-only map while the edit mode is unknown', async () => { + await service.create('test-id', document.createElement('div'), {}, DATA); + + expect(mmp.create).toHaveBeenCalledWith( + 'test-id', + expect.anything(), + expect.objectContaining({ edit: false, drag: false }), + DATA + ); + }); + + it('applies an edit mode reported after the map existed', async () => { + await service.create('test-id', document.createElement('div'), {}, DATA); + + editModeSubject.next(true); + + expect(mockMap.options.update).toHaveBeenCalledWith('drag', true); + expect(mockMap.options.update).toHaveBeenCalledWith('edit', true); + }); + + it('creates no map for a create a newer create overtook', async () => { + const newerMap = { + ...mockMap, + instance: { ...mockMap.instance, destroy: jest.fn() }, + }; + (mmp.create as jest.Mock).mockReturnValueOnce(newerMap); + + const late = service.create('a', document.createElement('div'), {}, DATA); + const newer = service.create( + 'b', + document.createElement('div'), + {}, + DATA + ); + + await expect(late).resolves.toBeNull(); + await expect(newer).resolves.toBe(newerMap); + expect(mmp.create).toHaveBeenCalledTimes(1); + service.remove(); + expect(newerMap.instance.destroy).toHaveBeenCalled(); + }); + + it('creates no map once remove ran during the create', async () => { + const pending = service.create( + 'a', + document.createElement('div'), + {}, + DATA + ); + service.remove(); + + await expect(pending).resolves.toBeNull(); + expect(mmp.create).not.toHaveBeenCalled(); + }); + + it('removes a map that is not the current one and keeps the current one', async () => { + const stale = { instance: { destroy: jest.fn() } }; + await service.create('a', document.createElement('div'), {}, DATA); + service.markMapCreated(); + const reported: boolean[] = []; + service.mapCreated$.subscribe(created => reported.push(created)); + + service.remove(stale as unknown as MmpMap); + + expect(stale.instance.destroy).toHaveBeenCalled(); + expect(mockMap.instance.destroy).not.toHaveBeenCalled(); + expect(reported).toEqual([true]); + }); + + it('reports the map created once marked, and no longer after remove', async () => { + const reported: boolean[] = []; + service.mapCreated$.subscribe(created => reported.push(created)); + + await service.create('test-id', document.createElement('div'), {}, DATA); + service.markMapCreated(); + service.remove(); + + expect(reported).toEqual([false, true, false]); + }); + + it('draws node names in the font the app ships', async () => { + await createMap(); + const passed: OptionParameters = (mmp.create as jest.Mock).mock + .calls[0][2]; + + expect(passed.fontFamily).toBe(NODE_FONT_FAMILY); + }); + it('passes a resolver that asks the registered image handlers', async () => { const resolveUrl = jest.fn().mockReturnValue('api/maps/m/images/i'); service.registerImageHandlers({ resolveUrl, upload: jest.fn() }); - await service.create('test-id', document.createElement('div')); + await createMap(); const passed: OptionParameters = (mmp.create as jest.Mock).mock .calls[0][2]; @@ -163,7 +349,7 @@ describe('MmpService', () => { const id = 'test-id'; const element = document.createElement('div'); - await service.create(id, element); + await service.create(id, element, undefined, DATA); const additionalOptions = service.getAdditionalMapOptions(); expect(additionalOptions).toEqual({ @@ -176,22 +362,21 @@ describe('MmpService', () => { describe('remove', () => { it('should remove the current map', async () => { - await service.create('test-id', document.createElement('div')); + await createMap(); service.remove(); - expect(mockMap.instance.unsubscribeAll).toHaveBeenCalled(); - expect(mockMap.instance.remove).toHaveBeenCalled(); + expect(mockMap.instance.destroy).toHaveBeenCalled(); }); it('should do nothing if no map exists', () => { service.remove(); - expect(mockMap.instance.unsubscribeAll).not.toHaveBeenCalled(); + expect(mockMap.instance.destroy).not.toHaveBeenCalled(); }); }); describe('node operations', () => { beforeEach(async () => { - await service.create('test-id', document.createElement('div')); + await createMap(); }); describe('addNode', () => { @@ -203,8 +388,6 @@ describe('MmpService', () => { service.addNode(); expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '' }, - true, - true, 'selected', undefined ); @@ -215,13 +398,28 @@ describe('MmpService', () => { service.addNode(props); expect(mockMap.instance.addNode).toHaveBeenCalledWith( props, - true, - true, 'selected', '123' ); }); + it('selects the new node and edits its name', () => { + mockMap.instance.addNode.mockReturnValue({ id: 'new' }); + + service.addNode(); + + expect(mockMap.instance.selectNode).toHaveBeenLastCalledWith('new'); + expect(mockMap.instance.editNode).toHaveBeenCalled(); + }); + + it('edits nothing when mmp refuses the node', () => { + mockMap.instance.addNode.mockReturnValue(null); + + service.addNode(); + + expect(mockMap.instance.editNode).not.toHaveBeenCalled(); + }); + it('adds no child with nothing selected', () => { mockMap.instance.selectNode.mockReturnValue(null); @@ -239,8 +437,6 @@ describe('MmpService', () => { service.addNode(); expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '' }, - true, - true, 'second-root', undefined ); @@ -254,8 +450,6 @@ describe('MmpService', () => { expect(mockMap.instance.selectNode).toHaveBeenCalledWith('named'); expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '', parent: 'named' }, - true, - true, 'named', undefined ); @@ -267,8 +461,6 @@ describe('MmpService', () => { expect(mockMap.instance.selectNode).toHaveBeenCalledWith(undefined); expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '', parent: '' }, - true, - true, 'selected', undefined ); @@ -276,10 +468,21 @@ describe('MmpService', () => { }); describe('addTree', () => { - it('lets mmp add the tree', () => { + it('lets mmp add the tree and edits the name of its root', () => { + mockMap.instance.addTree.mockReturnValue({ id: 'tree' }); + service.addTree(); expect(mockMap.instance.addTree).toHaveBeenCalled(); + expect(mockMap.instance.editNode).toHaveBeenCalled(); + }); + + it('edits nothing when mmp adds no tree', () => { + mockMap.instance.addTree.mockReturnValue(null); + + service.addTree(); + + expect(mockMap.instance.editNode).not.toHaveBeenCalled(); }); }); @@ -434,6 +637,20 @@ describe('MmpService', () => { expect(mockMap.instance.updateNode).not.toHaveBeenCalled(); }); + + it('writes the moved coordinates of the selected node to the data', () => { + mockMap.instance.selectNode.mockReturnValue({ + id: 'selected', + coordinates: { x: 100, y: 50 }, + }); + + service.moveNodeTo('up', 20); + + expect(mockMap.instance.updateNode).toHaveBeenCalledWith( + 'coordinates', + { x: 100, y: 30 } + ); + }); }); describe('copyNode', () => { @@ -456,7 +673,7 @@ describe('MmpService', () => { describe('export operations', () => { beforeEach(async () => { - await service.create('test-id', document.createElement('div')); + await createMap(); }); describe('exportMap', () => { @@ -466,6 +683,16 @@ describe('MmpService', () => { }); }); + it('exports a map without a main root under an empty name', async () => { + mockMap.instance.exportRootProperties.mockReturnValue(null); + mockMap.instance.exportAsJSON.mockReturnValue([]); + + const result = await service.exportMap('json'); + + expect(result.success).toBe(true); + expect(downloadFileSpy.mock.calls[0][0]).toBe('.json'); + }); + it('should export to JSON', async () => { mockMap.instance.exportAsJSON.mockReturnValue([{ id: 'root' }]); @@ -490,7 +717,7 @@ describe('MmpService', () => { }; beforeEach(async () => { - await service.create('test-id', document.createElement('div')); + await createMap(); service.registerImageHandlers({ resolveUrl: jest.fn().mockReturnValue('api/maps/m/images/i'), upload: jest.fn(), @@ -590,9 +817,63 @@ describe('MmpService', () => { }); }); + describe('on', () => { + const removeCallback = jest.fn(); + const changeCallbacks = new Set<(payload: string) => void>(); + + beforeEach(async () => { + await createMap(); + changeCallbacks.clear(); + removeCallback.mockClear(); + mockMap.instance.on.mockImplementation( + (event: string, callback: (payload: string) => void) => { + if (event !== 'mapChange') return undefined; + changeCallbacks.add(callback); + return () => { + removeCallback(); + changeCallbacks.delete(callback); + }; + } + ); + }); + + afterEach(() => { + mockMap.instance.on.mockReset(); + }); + + const emitChange = (payload: string) => + changeCallbacks.forEach(callback => callback(payload)); + + it('gives every subscriber of an event the payload', () => { + const first = jest.fn(); + const second = jest.fn(); + service.on('mapChange').subscribe(first); + service.on('mapChange').subscribe(second); + + emitChange('change'); + + expect(first).toHaveBeenCalledWith('change'); + expect(second).toHaveBeenCalledWith('change'); + }); + + it('removes the mmp callback of an unsubscribed subscriber only', () => { + const first = jest.fn(); + const second = jest.fn(); + const subscription = service.on('mapChange').subscribe(first); + service.on('mapChange').subscribe(second); + + subscription.unsubscribe(); + emitChange('change'); + + expect(removeCallback).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith('change'); + }); + }); + describe('distributeNodes', () => { beforeEach(async () => { - await service.create('test-id', document.createElement('div')); + await createMap(); }); it('should delegate distributing the nodes to the mmp instance', () => { @@ -602,12 +883,31 @@ describe('MmpService', () => { }); }); + describe('view state', () => { + it('reports no hidden child nodes before create', () => { + expect(service.childNodesHidden()).toBe(false); + }); + + describe('after create', () => { + beforeEach(async () => { + await createMap(); + }); + + it('asks the mmp instance whether the selected node hides its child nodes', () => { + mockMap.instance.childNodesHidden.mockReturnValue(true); + + expect(service.childNodesHidden()).toBe(true); + expect(mockMap.instance.childNodesHidden).toHaveBeenCalledWith(); + }); + }); + }); + describe('addNodeImage', () => { const image = new Blob(['image'], { type: 'image/png' }); let upload: jest.Mock; beforeEach(async () => { - await service.create('test-id', document.createElement('div')); + await createMap(); upload = jest.fn().mockResolvedValue(REFERENCE); service.registerImageHandlers({ resolveUrl: jest.fn(), upload }); mockMap.instance.getSelectedNode.mockReturnValue({ id: 'node-a' }); @@ -631,8 +931,6 @@ describe('MmpService', () => { expect(mockMap.instance.updateNode).toHaveBeenCalledWith( 'imageSrc', REFERENCE, - true, - true, 'node-a' ); }); @@ -651,7 +949,7 @@ describe('MmpService', () => { await adding; expect(mockMap.instance.updateNode).toHaveBeenCalledTimes(1); - expect(mockMap.instance.updateNode.mock.calls[0][4]).toBe('node-a'); + expect(mockMap.instance.updateNode.mock.calls[0][2]).toBe('node-a'); }); it('keeps the image and says the storage is full on a 413', async () => { diff --git a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts index 84393ee4..0a52dfad 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -1,11 +1,11 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; -import { Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { SettingsService } from '../settings/settings.service'; import { ToastrService } from 'ngx-toastr'; import { UtilsService } from '../utils/utils.service'; import { jsPDF } from 'jspdf'; import { filter } from 'rxjs/operators'; -import { create, MmpMap, OptionParameters } from '@teammapper/mmp'; +import { create, MapData, MmpMap, OptionParameters } from '@teammapper/mmp'; import DOMPurify from 'dompurify'; import { CachedMapOptions, @@ -13,6 +13,7 @@ import { MapOptions, MapSnapshot, MmpEventPayloadMap, + MmpEventType, NodeProperty, NodePropertyValue, UserNodeProperties, @@ -37,6 +38,12 @@ import { ExportService } from '../export/export.service'; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); +/** + * Node names use the font `styles.scss` ships, so a name has the same width + * on every system. An exported image cannot load it and falls back to Arial. + */ +export const NODE_FONT_FAMILY = "'Fira Sans', Arial, sans-serif"; + /** The formats `exportMap` knows how to write. */ export type ExportFormat = 'json' | 'pdf' | 'mermaid' | 'svg' | 'jpeg' | 'png'; @@ -60,6 +67,19 @@ export class MmpService implements OnDestroy { private exportService = inject(ExportService); private currentMap: MmpMap | null = null; + // The last edit mode SettingsService reported, which `create` applies to + // a map created after the report. + private editMode: boolean | null = null; + // Counts the calls of `create` and `remove`, so a create that awaited its + // options can tell that a newer one or a removal ran during the wait. + private creations = 0; + private readonly mapCreatedSubject = new BehaviorSubject(false); + /** + * True once the map is created and wired up, false again after `remove`. + * The toolbar, the floating buttons and the shortcuts act only while true. + */ + public readonly mapCreated$: Observable = + this.mapCreatedSubject.asObservable(); private readonly branchColors: string[]; // additional options that are not handled within mmp, like fontMaxSize etc. @@ -78,18 +98,24 @@ export class MmpService implements OnDestroy { .getEditModeObservable() .pipe(filter((val: boolean | null): val is boolean => val !== null)) .subscribe((result: boolean) => { - if (!this.currentMap) return; - - this.currentMap.options.update('drag', result); - this.currentMap.options.update('edit', result); + this.editMode = result; + if (this.currentMap) this.applyEditMode(this.currentMap, result); }); } + /** Let the map edit and drag nodes only in edit mode. */ + private applyEditMode(map: MmpMap, editMode: boolean) { + map.options.update('drag', editMode); + map.options.update('edit', editMode); + } + /** - * The map this service is attached to. Most of the operations below need - * one, so calling them before `create` is a programming error, and this - * throws instead of returning null. A few methods run before `create`. Those - * read `currentMap` directly and return early. + * The map this service is attached to. The commands below need one, and + * the UI calls them only once `mapCreated$` reports true, so a call before + * `create` is a programming error and this throws instead of returning + * null. The queries that templates call before `create`, and presence + * updates call after `remove`, read `currentMap` directly and return a + * default. */ private get map(): MmpMap { if (!this.currentMap) { @@ -103,25 +129,51 @@ export class MmpService implements OnDestroy { } /** - * Create a mindmap using mmp and save the instance with corresponding id. - * All function below require the mmp id. + * Create a mind map with mmp over the map data, keep the instance and + * return it. mmp draws every node the data holds while it creates the map. + * The map takes the current edit mode and stays read-only while the edit + * mode is unknown, so a read-only client never gets an editable map. + * Returns null without + * creating a map when a later `create` or a `remove` ran while the options + * loaded, so a late create never replaces a newer map. */ public async create( id: string, ref: HTMLElement, - options?: OptionParameters - ) { - const map: MmpMap = create(id, ref, { - ...options, - resolveImageUrl: reference => - this.imageHandlers?.resolveUrl(reference) ?? null, - }); - + options: OptionParameters | undefined, + data: MapData + ): Promise { + const creation = ++this.creations; // additional options do not include the standard mmp map options - this.additionalOptions = await this.defaultAdditionalOptions(); + const additionalOptions = await this.defaultAdditionalOptions(); + if (creation !== this.creations) return null; + this.additionalOptions = additionalOptions; + const map: MmpMap = create(id, ref, this.mapOptions(options), data); this.currentMap = map; map.instance.on('nodeProtected', () => void this.showProtectedNotice()); + return map; + } + + /** + * The mmp options of a new map: `options` with the app font as default, + * the current edit mode, read-only while unknown, and the image resolver. + */ + private mapOptions(options: OptionParameters | undefined): OptionParameters { + const editable = this.editMode ?? false; + return { + fontFamily: NODE_FONT_FAMILY, + ...options, + edit: editable, + drag: editable, + resolveImageUrl: reference => + this.imageHandlers?.resolveUrl(reference) ?? null, + }; + } + + /** Report that the map exists and is wired up; see `mapCreated$`. */ + public markMapCreated() { + this.mapCreatedSubject.next(true); } /** @@ -138,20 +190,30 @@ export class MmpService implements OnDestroy { } /** - * Remove the mind mmp. + * Remove the map, the current one by default. The method destroys a map + * that is no longer the current one, such as one a stale create returned, + * and leaves the current map alone. Removing the current map also stops a + * create that still loads its options. */ - public remove() { - if (!this.currentMap) return; + public remove(map: MmpMap | null = this.currentMap) { + if (map !== this.currentMap) { + map?.instance.destroy(); + return; + } + + this.creations++; + this.mapCreatedSubject.next(false); + if (!map) return; - this.currentMap.instance.unsubscribeAll(); - this.currentMap.instance.remove(); + map.instance.destroy(); this.currentMap = null; } /** - * Clear or load an existing mind mmp. + * Replace the mind map with the given nodes: an import. */ - public async new(map: MapSnapshot, notifyWithEvent = true) { + public async new(map: MapSnapshot) { + const instance = this.map.instance; const hasInvalidUUID = map.some(node => !uuidValidate(node.id)); if (hasInvalidUUID) { @@ -162,9 +224,7 @@ export class MmpService implements OnDestroy { return; } - const mapWithCoordinates = - this.map.instance.applyCoordinatesToMapSnapshot(map); - this.map.instance.new(mapWithCoordinates, notifyWithEvent); + instance.new(instance.applyCoordinatesToMapSnapshot(map)); } /** @@ -262,92 +322,91 @@ export class MmpService implements OnDestroy { } /** - * Return the subscribe of the mind mmp event with the node or nothing. + * Return an Observable of the mmp event. Each subscriber adds its own + * callback to mmp, and unsubscribing removes it again. */ - public on( + public on( event: K - ): Observable; - public on(event: string): Observable; - public on(event: string): Observable { - return new Observable(observer => { - this.map.instance.on(event, (args: unknown) => { - observer.next(args); - }); - }); - } - - /** - * Adds already created nodes from the server to the local map - * - * @param nodes Given nodes from the server - */ - public addNodesFromServer(nodes: ExportNodeProperties[]) { - this.map.instance.addNodes(nodes); + ): Observable { + return new Observable(observer => + this.map.instance.on(event, payload => observer.next(payload)) + ); } /** - * Add a node in the mind mmp triggered by the user. + * Add a node in the mind mmp triggered by the user, then select it and + * start editing its name. * * addNode puts a child under `properties.parent`, or under the selected * node when no parent is named, and adds no child when nothing is selected. * Call `addTree` to add a root node. */ - public addNode( - properties?: Partial, - notifyWithEvent = true - ) { - const newProps: UserNodeProperties = properties || { name: '' }; + public addNode(properties?: Partial) { const parent = this.selectNode(properties?.parent || undefined); if (!parent) return; - const settings = this.settingsService.getCachedUserSettings(); - - if (properties?.colors?.branch) { - newProps.colors = { - branch: properties.colors.branch, - }; - } else if (parent.colors?.branch) { - newProps.colors = { - branch: parent.colors.branch, - }; - } else if ( - settings !== null && - settings.mapOptions !== null && - settings.mapOptions.autoBranchColors === true - ) { - const children = this.nodeChildren().length; - - newProps.colors = { - branch: this.branchColors[children % this.branchColors.length], - }; - } - - this.map.instance.addNode( - newProps, - notifyWithEvent, - true, + const node = this.map.instance.addNode( + this.newNodeProperties(parent, properties), parent.id, properties?.id ); + if (!node) return; + + this.selectNode(node.id); + this.editNode(); + } + + /** + * The properties of a new child of `parent`. The branch color comes from + * the given properties, then from the parent, then from the automatic + * branch colors setting. + */ + private newNodeProperties( + parent: ExportNodeProperties, + properties?: Partial + ): UserNodeProperties { + const newProps: UserNodeProperties = properties || { name: '' }; + const branch = + properties?.colors?.branch || + parent.colors?.branch || + this.autoBranchColor(); + if (branch) newProps.colors = { branch }; + return newProps; + } + + /** + * The automatic branch color for the next child of the selected node, or + * null when the user settings turn automatic branch colors off. + */ + private autoBranchColor(): string | null { + const settings = this.settingsService.getCachedUserSettings(); + if (settings?.mapOptions?.autoBranchColors !== true) return null; + + const children = this.nodeChildren().length; + return this.branchColors[children % this.branchColors.length]; } /** * Add the root of a new tree in the viewport, clear of every tree this - * client holds, and select it. The root has no parent and no main-root mark. + * client holds, select it and start editing its name. The root has no + * parent and its isRoot attribute is false. */ public addTree() { - this.map.instance.addTree(); + const instance = this.map.instance; + if (!instance.addTree()) return; + instance.editNode(); } /** * Select the node with the id or in the direction passed as parameter. * If the node id is not defined return the current selected node, or null - * when nothing is selected. + * when nothing is selected or no map exists. Templates, `addNode` and + * `moveNodeTo` call it while no map exists. */ public selectNode( nodeId?: string | 'left' | 'right' | 'up' | 'down' ): ExportNodeProperties | null { - return this.map.instance.selectNode(nodeId); + return this.currentMap?.instance.selectNode(nodeId) ?? null; } /** @@ -359,28 +418,26 @@ export class MmpService implements OnDestroy { } /** - * exports the root node props + * Export the properties of the main root, or null for a map without one. */ - public getRootNode(): ExportNodeProperties { + public getRootNode(): ExportNodeProperties | null { return this.map.instance.exportRootProperties(); } /** - * Checks if a given node actually exists + * Return true when the map holds the node, and false while no map exists: + * a peer's presence update can arrive after `remove`. */ public existNode(nodeId: string): boolean { - return this.map.instance.existNode(nodeId); + return this.currentMap?.instance.existNode(nodeId) ?? false; } /** - * Highlights a node + * Draw a ring in the color around the node. A peer's presence update can + * arrive after `remove`, and then this draws nothing. */ - public highlightNode( - nodeId: string, - color: string, - notifyWithEvent = true - ): void { - return this.map.instance.highlightNode(nodeId, color, notifyWithEvent); + public highlightNode(nodeId: string, color: string): void { + this.currentMap?.instance.highlightNode(nodeId, color); } /** @@ -403,18 +460,11 @@ export class MmpService implements OnDestroy { public async updateNode( property: NodeProperty | string, value?: NodePropertyValue | ArrayBuffer | unknown, - notifyWithEvent?: boolean, - updateHistory?: boolean, id?: string ) { + const instance = this.map.instance; try { - this.map.instance.updateNode( - property, - value, - notifyWithEvent, - updateHistory, - id - ); + instance.updateNode(property, value, id); } catch { const genericErrorMessage = await this.utilsService.translate( 'TOASTS.ERRORS.NODE_UPDATE_GENERIC' @@ -433,20 +483,24 @@ export class MmpService implements OnDestroy { /** * Protect the selected node and its branch, or release the protection of - * the branch the selected node belongs to. + * the branch the selected node belongs to. mmp writes the whole toggle as + * one batch, so peers never see a child released before its parent is + * protected. */ public toggleBranchProtection() { - if (this.protectingNode() === null) this.map.instance.protectBranch(); - else this.map.instance.releaseBranch(); + const instance = this.map.instance; + if (this.protectingNode() === null) instance.protectBranch(); + else instance.releaseBranch(); } /** * Remove the node with the id passed as parameter or, if the id is * not defined, the current selected node. */ - public async removeNode(nodeId?: string, notifyWithEvent = true) { + public async removeNode(nodeId?: string) { + const instance = this.map.instance; try { - this.map.instance.removeNode(nodeId, notifyWithEvent); + instance.removeNode(nodeId); } catch (e) { if (errorMessage(e) == 'The root node can not be deleted') { const rootNodeFailureMessage = await this.utilsService.translate( @@ -467,10 +521,11 @@ export class MmpService implements OnDestroy { * If id is not specified, copy the selected node. */ public async copyNode(nodeId?: string) { + const instance = this.map.instance; if (!nodeId && !this.hasSelectedNode()) return; try { - this.map.instance.copyNode(nodeId); + instance.copyNode(nodeId); const successMessage = await this.utilsService.translate('TOASTS.NODE_COPIED'); @@ -495,10 +550,11 @@ export class MmpService implements OnDestroy { * If id is not specified, copy the selected node. */ public async cutNode(nodeId?: string) { + const instance = this.map.instance; if (!nodeId && !this.hasSelectedNode()) return; try { - if (!this.map.instance.cutNode(nodeId)) return; + if (!instance.cutNode(nodeId)) return; const successMessage = await this.utilsService.translate('TOASTS.NODE_CUT'); @@ -523,8 +579,9 @@ export class MmpService implements OnDestroy { * paste the nodes of the mmp clipboard in the selected node. */ public async pasteNode(nodeId?: string) { + const instance = this.map.instance; try { - this.pasteFromClipboard(nodeId); + this.pasteFromClipboard(instance, nodeId); } catch (e) { if (errorMessage(e) == 'There are not nodes in the mmp clipboard') { const rootNodeFailureMessage = await this.utilsService.translate( @@ -544,11 +601,11 @@ export class MmpService implements OnDestroy { * Paste as an independent tree when the caller names no node and nothing is * selected. Paste under the named or the selected node otherwise. */ - private pasteFromClipboard(nodeId?: string) { + private pasteFromClipboard(instance: MmpMap['instance'], nodeId?: string) { const asTree = !nodeId && !this.hasSelectedNode(); - if (asTree) this.map.instance.pasteTree(); - else this.map.instance.pasteNode(nodeId); + if (asTree) instance.pasteTree(); + else instance.pasteNode(nodeId); } /** @@ -558,6 +615,14 @@ export class MmpService implements OnDestroy { this.map.instance.toggleBranchVisibility(); } + /** + * Return true when this person hid the child nodes of the selected node, + * and false with nothing selected or before `create` builds the map. + */ + public childNodesHidden(): boolean { + return this.currentMap?.instance.childNodesHidden() ?? false; + } + /** * Recompute every node's position from the tree, discarding manual placement. */ @@ -569,14 +634,14 @@ export class MmpService implements OnDestroy { * Return the children of the current node. */ public nodeChildren(): ExportNodeProperties[] { - return this.currentMap?.instance.nodeChildren() ?? []; + return this.map.instance.nodeChildren(); } /** * Move the node in a direction. */ public moveNodeTo(direction: 'left' | 'right' | 'up' | 'down', range = 10) { - const coordinates = this.map.instance.selectNode()?.coordinates; + const coordinates = this.selectNode()?.coordinates; if (!coordinates) return; switch (direction) { @@ -604,7 +669,7 @@ export class MmpService implements OnDestroy { format: ExportFormat = 'json' ): Promise<{ success: boolean; size?: number }> { const name = DOMPurify.sanitize( - (this.getRootNode().name ?? '').replace(/\n/g, ' ').replace(/\s+/g, ' ') + (this.getRootNode()?.name ?? '').replace(/\n/g, ' ').replace(/\s+/g, ' ') ); switch (format) { @@ -660,7 +725,7 @@ export class MmpService implements OnDestroy { // The upload succeeded; a node deleted meanwhile needs no image and no // error. The cleanup job deletes the unused image. if (!this.existNode(node.id)) return; - await this.updateNode('imageSrc', reference, true, true, node.id); + await this.updateNode('imageSrc', reference, node.id); } catch (error) { await this.showImageUploadError(error); } diff --git a/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.spec.ts b/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.spec.ts index 8d058d5c..aaf588fb 100644 --- a/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.spec.ts +++ b/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.spec.ts @@ -16,7 +16,10 @@ describe('ShortcutsService', () => { let dialogService: { openAboutDialog: jest.Mock }; let editMode: BehaviorSubject; let hotkeysService: { add: jest.Mock; remove: jest.Mock }; + let mapCreated: BehaviorSubject; let mmpService: { + mapCreated$: BehaviorSubject; + center: jest.Mock; selectNode: jest.Mock; updateNode: jest.Mock; getAdditionalMapOptions: jest.Mock; @@ -33,7 +36,10 @@ describe('ShortcutsService', () => { } beforeEach(() => { + mapCreated = new BehaviorSubject(true); mmpService = { + mapCreated$: mapCreated, + center: jest.fn(), selectNode: jest.fn().mockReturnValue(null), updateNode: jest.fn(), // Limits that would allow either change, so only the selection blocks it. @@ -83,7 +89,7 @@ describe('ShortcutsService', () => { press('alt+.'); - expect(mmpService.updateNode).toHaveBeenCalledWith('fontSize', 14, false); + expect(mmpService.updateNode).toHaveBeenCalledWith('fontSize', 14); }); it('opens the info dialog on ?', () => { @@ -92,6 +98,39 @@ describe('ShortcutsService', () => { expect(dialogService.openAboutDialog).toHaveBeenCalledTimes(1); }); + describe('before the map exists', () => { + beforeEach(() => { + mapCreated.next(false); + mmpService.selectNode.mockReturnValue({ font: { size: 12 } }); + }); + + it('acts on no map key', () => { + press('c'); + press('alt+.'); + press('left'); + + expect({ + center: mmpService.center.mock.calls.length, + select: mmpService.selectNode.mock.calls.length, + update: mmpService.updateNode.mock.calls.length, + }).toEqual({ center: 0, select: 0, update: 0 }); + }); + + it('still opens the info dialog on ?', () => { + press('?'); + + expect(dialogService.openAboutDialog).toHaveBeenCalledTimes(1); + }); + + it('acts once the map exists', () => { + mapCreated.next(true); + + press('c'); + + expect(mmpService.center).toHaveBeenCalled(); + }); + }); + describe('before the map connection reports edit mode', () => { beforeEach(() => { editMode.next(null); diff --git a/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts b/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts index 814bd1b2..9f956263 100644 --- a/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts +++ b/teammapper-frontend/src/app/core/services/shortcuts/shortcuts.service.ts @@ -6,6 +6,13 @@ import { distinctUntilChanged, Subscription } from 'rxjs'; import { SettingsService } from '../settings/settings.service'; import { DialogService } from '../dialog/dialog.service'; +/** One shortcut before it becomes a `Hotkey`. */ +interface HotkeyOptions { + keys: string | string[]; + description: string; + callback: (event?: KeyboardEvent) => void; +} + @Injectable({ providedIn: 'root', }) @@ -18,29 +25,38 @@ export class ShortcutsService implements OnDestroy { private hotKeys: Hotkey[] = []; private editMode: boolean | null = null; - private settingsSubscription: Subscription | null = null; + private mapCreated = false; + private readonly subscriptions = new Subscription(); /** * Add all global hot keys of the application. The viewer keys work at once, * and the edit keys follow edit mode, which stays unknown until the map - * connection syncs and can change when a map turns out writable. + * connection syncs and can change when a map turns out writable. Every key + * that acts on the map does nothing until the map exists. */ public init() { - this.settingsSubscription = this.settingsService - .getEditModeObservable() - .pipe(distinctUntilChanged()) - .subscribe((result: boolean | null) => { - this.editMode = result; - this.registerHotKeys(); - }); + this.subscriptions.add( + this.settingsService + .getEditModeObservable() + .pipe(distinctUntilChanged()) + .subscribe((result: boolean | null) => { + this.editMode = result; + this.registerHotKeys(); + }) + ); + this.subscriptions.add( + this.mmpService.mapCreated$.subscribe(created => { + this.mapCreated = created; + }) + ); } ngOnDestroy() { - this.settingsSubscription?.unsubscribe(); + this.subscriptions.unsubscribe(); } public registerHotKeys() { - const viewerHotkeys = [ + const appHotkeys: HotkeyOptions[] = [ { keys: '?', description: 'TOOLTIPS.SHORTCUTS', @@ -63,6 +79,9 @@ export class ShortcutsService implements OnDestroy { window.location.replace(`${document.baseURI}/map`); }, }, + ]; + + const mapHotkeys: HotkeyOptions[] = [ { keys: 'c', description: 'TOOLTIPS.CENTER_MAP', @@ -79,7 +98,7 @@ export class ShortcutsService implements OnDestroy { }, ]; - const editHotkeys = [ + const editHotkeys: HotkeyOptions[] = [ { keys: '+', description: 'TOOLTIPS.ADD_NODE', @@ -200,11 +219,7 @@ export class ShortcutsService implements OnDestroy { const options = this.mmpService.getAdditionalMapOptions(); if (size == null || !options || size >= options.fontMaxSize) return; - this.mmpService.updateNode( - 'fontSize', - size + options.fontIncrement, - false - ); + this.mmpService.updateNode('fontSize', size + options.fontIncrement); }, }, { @@ -215,26 +230,34 @@ export class ShortcutsService implements OnDestroy { const options = this.mmpService.getAdditionalMapOptions(); if (size == null || !options || size <= options.fontMinSize) return; - this.mmpService.updateNode( - 'fontSize', - size - options.fontIncrement, - false - ); + this.mmpService.updateNode('fontSize', size - options.fontIncrement); }, }, ]; if (this.hotKeys.length > 0) this.hotkeysService.remove(this.hotKeys); - if (this.editMode) { - this.hotKeys = [...viewerHotkeys, ...editHotkeys].map(this.getHotKey); - } else { - this.hotKeys = viewerHotkeys.map(this.getHotKey); - } + const mapKeys = this.editMode + ? [...mapHotkeys, ...editHotkeys] + : mapHotkeys; + this.hotKeys = [ + ...appHotkeys, + ...mapKeys.map(options => this.requireMap(options)), + ].map(this.getHotKey); this.hotkeysService.add(this.hotKeys); } + /** Make the hot key do nothing while no map exists. */ + private requireMap(options: HotkeyOptions): HotkeyOptions { + return { + ...options, + callback: event => { + if (this.mapCreated) options.callback(event); + }, + }; + } + /** * Return all the shortcuts. */ @@ -245,11 +268,7 @@ export class ShortcutsService implements OnDestroy { /** * Get some shortcut parameters and return the corresponding hot key. */ - private getHotKey(options: { - keys: string | string[]; - description: string; - callback: (event?: KeyboardEvent) => void; - }) { + private getHotKey(options: HotkeyOptions) { return new Hotkey( options.keys, (event: KeyboardEvent) => { diff --git a/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts b/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts index c4be6230..b8190e4c 100644 --- a/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/color-panels/color-panels.component.ts @@ -77,7 +77,7 @@ export class ColorPanelsComponent implements OnChanges { } public colorPickerChange(property: NodeProperty, value: string) { - this.mmpService.updateNode(property, value, true); + this.mmpService.updateNode(property, value); } public colorPickerToggleChange( diff --git a/teammapper-frontend/src/app/modules/application/components/dialog-import-mermaid/dialog-import-mermaid.component.ts b/teammapper-frontend/src/app/modules/application/components/dialog-import-mermaid/dialog-import-mermaid.component.ts index f8017cfe..dfa569ef 100644 --- a/teammapper-frontend/src/app/modules/application/components/dialog-import-mermaid/dialog-import-mermaid.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/dialog-import-mermaid/dialog-import-mermaid.component.ts @@ -1,5 +1,4 @@ import { Component, inject } from '@angular/core'; -import { Router } from '@angular/router'; import { MatDialogRef, MatDialogTitle, @@ -39,7 +38,6 @@ export class DialogImportMermaidComponent { private dialogRef = inject>(MatDialogRef); - private router = inject(Router); public mermaidInput = ''; diff --git a/teammapper-frontend/src/app/modules/application/components/dialog-share/dialog-share.component.ts b/teammapper-frontend/src/app/modules/application/components/dialog-share/dialog-share.component.ts index 6764f681..84ab02a2 100644 --- a/teammapper-frontend/src/app/modules/application/components/dialog-share/dialog-share.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/dialog-share/dialog-share.component.ts @@ -11,7 +11,6 @@ import { API_URL, HttpService } from 'src/app/core/http/http.service'; import { UtilsService } from 'src/app/core/services/utils/utils.service'; import { ToastrService } from 'ngx-toastr'; import { StorageService } from 'src/app/core/services/storage/storage.service'; -import { Router } from '@angular/router'; import { MatDialogRef, MatDialogTitle, @@ -61,7 +60,6 @@ export class DialogShareComponent implements OnInit { private utilsService = inject(UtilsService); private storageService = inject(StorageService); private dialogRef = inject>(MatDialogRef); - private router = inject(Router); @ViewChild('qrcodecanvas', { static: true }) private qrCodeCanvasRef: ElementRef | null = null; @@ -136,7 +134,8 @@ export class DialogShareComponent implements OnInit { } async duplicateMindMap(): Promise { - // getCurrentMap from the MmpService doesn't give us the UUID of the map, only a legacy id and the root note ID, so we'll have to use the URL params. + // MmpService holds no map uuid, and the second path segment of the URL + // is the map's uuid. const id = window.location.pathname.split('/')[2]; const response = await this.httpService.post( API_URL.ROOT, diff --git a/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.html b/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.html index 00462d34..bd38fc70 100644 --- a/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.html +++ b/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.html @@ -27,6 +27,7 @@ id="center-map-button" (click)="mmpService.center()" [title]="'TOOLTIPS.CENTER_MAP' | translate" + [disabled]="(mapCreated$ | async) !== true" class="mat-elevation-z2" mat-mini-fab> center_focus_weak @@ -38,6 +39,7 @@ id="zoom-in-button" (click)="mmpService.zoomIn()" [title]="'TOOLTIPS.ZOOM_IN_MAP' | translate" + [disabled]="(mapCreated$ | async) !== true" class="mat-elevation-z2" mat-mini-fab> zoom_in @@ -49,6 +51,7 @@ id="zoom-out-button" (click)="mmpService.zoomOut()" [title]="'TOOLTIPS.ZOOM_OUT_MAP' | translate" + [disabled]="(mapCreated$ | async) !== true" class="mat-elevation-z2" mat-mini-fab> zoom_out diff --git a/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.spec.ts b/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.spec.ts index f8729d65..ef983b67 100644 --- a/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.spec.ts +++ b/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.spec.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { MmpService } from 'src/app/core/services/mmp/mmp.service'; import { FloatingButtonsComponent } from './floating-buttons.component'; @@ -17,7 +17,10 @@ class FakeTranslateLoader implements TranslateLoader { */ describe('FloatingButtonsComponent', () => { let fixture: ComponentFixture; - let mmpService: { hasSelectedNode: jest.Mock }; + let mmpService: { + hasSelectedNode: jest.Mock; + mapCreated$: BehaviorSubject; + }; const disabled = (selector: string): boolean | undefined => fixture.nativeElement.querySelector(selector)?.disabled; @@ -29,7 +32,10 @@ describe('FloatingButtonsComponent', () => { } beforeEach(async () => { - mmpService = { hasSelectedNode: jest.fn().mockReturnValue(true) }; + mmpService = { + hasSelectedNode: jest.fn().mockReturnValue(true), + mapCreated$: new BehaviorSubject(true), + }; await TestBed.configureTestingModule({ imports: [ @@ -65,4 +71,19 @@ describe('FloatingButtonsComponent', () => { expect(disabled('#floating-add-node')).toBe(true); expect(disabled('#floating-remove-node')).toBe(true); }); + + it('enables center and zoom once the map exists', () => { + expect(disabled('#center-map-button')).toBe(false); + expect(disabled('#zoom-in-button')).toBe(false); + expect(disabled('#zoom-out-button')).toBe(false); + }); + + it('disables center and zoom before the map exists', () => { + mmpService.mapCreated$.next(false); + fixture.detectChanges(); + + expect(disabled('#center-map-button')).toBe(true); + expect(disabled('#zoom-in-button')).toBe(true); + expect(disabled('#zoom-out-button')).toBe(true); + }); }); diff --git a/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.ts b/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.ts index d703c67e..e651d1d9 100644 --- a/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/floating-buttons/floating-buttons.component.ts @@ -3,17 +3,20 @@ import { MmpService } from '../../../../core/services/mmp/mmp.service'; import { MatMiniFabButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; +import { AsyncPipe } from '@angular/common'; @Component({ selector: 'teammapper-floating-buttons', templateUrl: './floating-buttons.component.html', styleUrls: ['./floating-buttons.component.scss'], - imports: [MatMiniFabButton, MatIcon, TranslatePipe], + imports: [MatMiniFabButton, MatIcon, TranslatePipe, AsyncPipe], }) export class FloatingButtonsComponent { mmpService = inject(MmpService); @Input() public editDisabled = false; + // Center and zoom need a map, read-only or not. + public mapCreated$ = this.mmpService.mapCreated$; /** Adding a child and removing a node both need a selected node. */ get nodeActionsDisabled(): boolean { diff --git a/teammapper-frontend/src/app/modules/application/components/map/map.component.ts b/teammapper-frontend/src/app/modules/application/components/map/map.component.ts index 15d276c2..eec42f9d 100644 --- a/teammapper-frontend/src/app/modules/application/components/map/map.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/map/map.component.ts @@ -27,21 +27,20 @@ export class MapComponent implements AfterViewInit, OnDestroy { private mapSyncServiceSubscription: Subscription | null = null; - public async ngAfterViewInit() { + public ngAfterViewInit() { const settings = this.settingsService.getCachedUserSettings(); this.mapSyncServiceSubscription = this.mapSyncService .getAttachedMapObservable() .pipe(first((val: CachedMapEntry | null) => val !== null)) - .subscribe(async (_result: CachedMapEntry | null) => { + .subscribe(() => { // With no cached settings mmp falls back to its own defaults rather - // than refusing to draw the map. - await this.mmpService.create( - 'map_1', + // than refusing to draw the map. MapSyncService creates the map once + // the connection syncs. + this.mapSyncService.openMap( this.mapWrapper().nativeElement, settings?.mapOptions ); - this.mapSyncService.initMap(); }); } diff --git a/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.spec.ts b/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.spec.ts index 6050b310..90cd73c1 100644 --- a/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.spec.ts +++ b/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.spec.ts @@ -75,7 +75,7 @@ describe('SliderPanelsComponent', () => { input.value = '35'; input.dispatchEvent(new Event('input')); - expect(mmpService.updateNode).toHaveBeenCalledWith('fontSize', 35, true); + expect(mmpService.updateNode).toHaveBeenCalledWith('fontSize', 35); }); it('hides the font slider while no map has been created', () => { @@ -91,6 +91,6 @@ describe('SliderPanelsComponent', () => { input.value = '200'; input.dispatchEvent(new Event('input')); - expect(mmpService.updateNode).toHaveBeenCalledWith('imageSize', 200, true); + expect(mmpService.updateNode).toHaveBeenCalledWith('imageSize', 200); }); }); diff --git a/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.ts b/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.ts index 74f1c4d6..3dac36a6 100644 --- a/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.ts +++ b/teammapper-frontend/src/app/modules/application/components/slider-panels/slider-panels.component.ts @@ -41,13 +41,13 @@ export class SliderPanelsComponent implements OnChanges { const target = event.target as HTMLInputElement; const value = parseInt(target.value, 10); - this.mmpService.updateNode('fontSize', value, true); + this.mmpService.updateNode('fontSize', value); } public updateNodeImageSize(event: Event) { const target = event.target as HTMLInputElement; const value = parseInt(target.value, 10); - this.mmpService.updateNode('imageSize', value, true); + this.mmpService.updateNode('imageSize', value); } } diff --git a/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html b/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html index 2eb1c3dc..9ce33088 100644 --- a/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html +++ b/teammapper-frontend/src/app/modules/application/components/toolbar/toolbar.component.html @@ -12,6 +12,7 @@ id="menu-import" [matMenuTriggerFor]="import_map" [title]="'TOOLTIPS.IMPORT_MAP' | translate" + [disabled]="(mapCreated$ | async) !== true" color="primary" mat-icon-button> publish @@ -63,6 +64,7 @@ id="menu-export" [matMenuTriggerFor]="export_map" [title]="'TOOLTIPS.EXPORT_MAP' | translate" + [disabled]="(mapCreated$ | async) !== true" color="primary" mat-icon-button> get_app @@ -172,7 +174,9 @@ [disabled]="nodeActionsDisabled || !canHideNodes" color="primary" mat-icon-button> - {{ hasHiddenNodes ? 'visibility_off' : 'visibility' }} + {{ + childNodesHidden ? 'visibility_off' : 'visibility' + }}