From d48343197eea09c95de95cbab464f3dc34e4e62c Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Thu, 1 Oct 2026 16:55:09 +0200 Subject: [PATCH 01/25] update mermaid parser with the dom purify version (#1438) --- .../mermaid-mindmap-parser/package-lock.json | 1325 ----------------- packages/mermaid-mindmap-parser/package.json | 4 +- .../mermaid-mindmap-parser/src/mindmapDb.ts | 22 +- packages/mermaid-mindmap-parser/src/types.ts | 1 + .../mermaid-mindmap-parser/vite.config.ts | 2 +- packages/mmp/package.json | 2 +- pnpm-lock.yaml | 291 ++-- pnpm-workspace.yaml | 3 +- teammapper-frontend/package.json | 2 +- 9 files changed, 129 insertions(+), 1523 deletions(-) delete mode 100644 packages/mermaid-mindmap-parser/package-lock.json 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/package.json b/packages/mmp/package.json index 21605ef7..28845fb6 100644 --- a/packages/mmp/package.json +++ b/packages/mmp/package.json @@ -30,7 +30,7 @@ "dependencies": { "@teammapper/shared": "workspace:*", "d3": "7.9.0", - "dompurify": "3.4.15", + "dompurify": "3.4.16", "uuid": "13.0.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db5019bd..728f4506 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ overrides: '@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' 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,8 +95,8 @@ 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 @@ -115,13 +115,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 +152,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 +360,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 +428,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 +466,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 +508,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 +517,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 +2847,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 +3086,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 +3098,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 +3110,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 +3122,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 +3134,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 +3146,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 +3159,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 +3173,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 +3201,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 +3215,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 +3228,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 +3245,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 +3257,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 +5292,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==} @@ -7976,8 +7976,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 +9016,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 +9472,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 +9513,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) @@ -9575,7 +9575,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' @@ -12216,7 +12216,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 +12450,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 +12530,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 +14706,7 @@ snapshots: dependencies: domelementtype: 3.0.0 - dompurify@3.4.15: + dompurify@3.4.16: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16079,25 +16079,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 +16249,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 +16258,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 +16427,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 +16556,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: @@ -17936,26 +17891,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 +18670,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 +18711,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 +19050,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..ce772947 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,7 +59,8 @@ overrides: '@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' '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-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", From 5cc75a6509d383d8e749c196c179c94bbbd68615 Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Fri, 2 Oct 2026 17:45:07 +0200 Subject: [PATCH 02/25] test: cover GET /api/maps against forged or empty person_id cookies (#1437) --- .../src/auth/person-id.middleware.spec.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 teammapper-backend/src/auth/person-id.middleware.spec.ts 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() + }) +}) From 92353a52474e9039368ec5f43f2b9d608953864a Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Sat, 3 Oct 2026 17:21:56 +0200 Subject: [PATCH 03/25] refactor: single change path in mmp for nodes and a DOM-free node store (#1447) --- packages/mmp/package.json | 3 +- .../map/handlers/branch-visibility.spec.ts | 7 +- .../handlers/calculate-coordinates.spec.ts | 17 +- packages/mmp/src/map/handlers/copy-paste.ts | 3 +- .../src/map/handlers/distribute-nodes.spec.ts | 32 +- packages/mmp/src/map/handlers/drag.ts | 15 +- packages/mmp/src/map/handlers/draw.ts | 137 +++- .../src/map/handlers/empty-selection.spec.ts | 44 +- packages/mmp/src/map/handlers/events.ts | 93 ++- packages/mmp/src/map/handlers/history.ts | 78 +- .../src/map/handlers/legacy-detached.spec.ts | 2 +- .../mmp/src/map/handlers/multi-tree.spec.ts | 11 +- .../mmp/src/map/handlers/node-image.spec.ts | 4 +- .../mmp/src/map/handlers/node-link.spec.ts | 2 +- .../src/map/handlers/node-protection.spec.ts | 26 +- .../src/map/handlers/node-selection.spec.ts | 3 +- packages/mmp/src/map/handlers/node-view.ts | 11 + packages/mmp/src/map/handlers/nodes.ts | 673 ++++-------------- .../map/handlers/paste-coordinates.spec.ts | 4 +- .../src/map/handlers/root-selection.spec.ts | 35 +- .../src/map/handlers/tree-clipboard.spec.ts | 23 +- packages/mmp/src/map/map.ts | 18 +- packages/mmp/src/map/mmp-instance.spec.ts | 514 +++++++++++++ .../mmp/src/map/models/node-store.spec.ts | 103 +++ packages/mmp/src/map/models/node-store.ts | 95 +++ packages/mmp/src/utils/utils.ts | 15 - packages/shared/src/models/index.ts | 2 +- packages/shared/src/schemas/index.ts | 35 +- packages/shared/src/schemas/schemas.spec.ts | 45 ++ pnpm-lock.yaml | 3 + .../map-sync/map-sync.service.spec.ts | 3 +- .../services/map-sync/yjs-presence.spec.ts | 6 +- .../services/map-sync/yjs-sync.service.ts | 7 +- .../app/core/services/mmp/mmp.service.spec.ts | 7 +- .../src/app/core/services/mmp/mmp.service.ts | 22 +- 35 files changed, 1246 insertions(+), 852 deletions(-) create mode 100644 packages/mmp/src/map/handlers/node-view.ts create mode 100644 packages/mmp/src/map/mmp-instance.spec.ts create mode 100644 packages/mmp/src/map/models/node-store.spec.ts create mode 100644 packages/mmp/src/map/models/node-store.ts diff --git a/packages/mmp/package.json b/packages/mmp/package.json index 28845fb6..9adec56e 100644 --- a/packages/mmp/package.json +++ b/packages/mmp/package.json @@ -31,7 +31,8 @@ "@teammapper/shared": "workspace:*", "d3": "7.9.0", "dompurify": "3.4.16", - "uuid": "13.0.2" + "uuid": "13.0.2", + "valibot": "^1.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/mmp/src/map/handlers/branch-visibility.spec.ts b/packages/mmp/src/map/handlers/branch-visibility.spec.ts index 29871272..dc5c4865 100644 --- a/packages/mmp/src/map/handlers/branch-visibility.spec.ts +++ b/packages/mmp/src/map/handlers/branch-visibility.spec.ts @@ -11,7 +11,6 @@ import MmpMap from '../map.js'; */ interface NodesInternals { - nodes: Map; selectedNode: Node; } @@ -22,7 +21,7 @@ function makeTree(): { } { const map = { options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn() }, + draw: { update: jest.fn(), renderNodeProperty: jest.fn() }, history: { save: jest.fn() }, } as unknown as MmpMap; @@ -57,7 +56,7 @@ function makeTree(): { }); const nodes = { root, first, second, grandchild }; - for (const node of Object.values(nodes)) internals.nodes.set(node.id, node); + for (const node of Object.values(nodes)) handler.store.set(node); return { handler, internals, nodes }; } @@ -101,7 +100,7 @@ describe('toggleBranchVisibility', () => { k: 1, coordinates: { x: 200, y: 200 }, }); - internals.nodes.set(late.id, late); + handler.store.set(late); handler.toggleBranchVisibility(); diff --git a/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts b/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts index 3148be43..48af53bd 100644 --- a/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/calculate-coordinates.spec.ts @@ -13,15 +13,14 @@ function makeNode(properties: Partial & { id: string }): Node { * out, so it never counts as its own sibling. */ 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); + const handler = new Nodes({ rootId: 'root' } as unknown as MmpMap); + [...existing, node].forEach(each => handler.setNode(each)); + + return ( + handler as unknown as { + calculateCoordinates(node: Node): MapNodeCoordinates; + } + ).calculateCoordinates(node); } describe('calculateCoordinates', () => { diff --git a/packages/mmp/src/map/handlers/copy-paste.ts b/packages/mmp/src/map/handlers/copy-paste.ts index 53089003..9a0fc5ae 100644 --- a/packages/mmp/src/map/handlers/copy-paste.ts +++ b/packages/mmp/src/map/handlers/copy-paste.ts @@ -7,7 +7,6 @@ import type { } from '@teammapper/shared'; import Log from '../../utils/log.js'; import Utils from '../../utils/utils.js'; -import { Event } from './events.js'; import type { Bounds } from './node-geometry.js'; import { moveBounds, nodeBounds, unionBounds } from './tree-placement.js'; @@ -172,7 +171,7 @@ export default class CopyPaste { this.map.history.save(); const pasted = newNodes.map(node => this.map.nodes.getNodeProperties(node)); - this.map.events.call(Event.nodePaste, parent?.dom, pasted); + this.map.events.emit('nodePaste', pasted); return pastedNode; } diff --git a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts index 49021a73..aa18f0d4 100644 --- a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts +++ b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts @@ -1,14 +1,19 @@ import * as d3 from 'd3'; import Nodes from './nodes.js'; +import Draw from './draw.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 }; + draw: { + update: jest.Mock; + drawBranch: jest.Mock; + redrawBranches: () => void; + map: { id: string }; + }; history: { save: jest.Mock }; - events: { call: jest.Mock }; + events: { emit: jest.Mock }; } function stubMap(): StubMap { @@ -18,9 +23,12 @@ function stubMap(): StubMap { update: jest.fn(), // The real `drawBranch` returns nothing for a node without a parent. drawBranch: jest.fn((node: Node) => (node.parent ? 'M0,0' : undefined)), + // The real redraw, reading the map id and drawBranch from this stub. + redrawBranches: Draw.prototype.redrawBranches, + map: { id: 'test-map' }, }, history: { save: jest.fn() }, - events: { call: jest.fn() }, + events: { emit: jest.fn() }, }; } @@ -54,16 +62,10 @@ function liveNode( } as unknown as Node; } -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]) - ); + nodes.forEach(node => handler.setNode(node)); return { handler, map }; } @@ -159,8 +161,8 @@ describe('distributeNodes', () => { handler.distributeNodes(); - const events = map.events.call.mock.calls.map(call => call[0]); - expect(events).toContain(Event.distribute); + const events = map.events.emit.mock.calls.map(call => call[0]); + expect(events).toContain('distribute'); }); it('does not emit the distribute event when notification is suppressed', () => { @@ -168,8 +170,8 @@ describe('distributeNodes', () => { handler.distributeNodes(false); - const events = map.events.call.mock.calls.map(call => call[0]); - expect(events).not.toContain(Event.distribute); + const events = map.events.emit.mock.calls.map(call => call[0]); + expect(events).not.toContain('distribute'); }); it('redraws every branch, dropping the path of a second root', () => { diff --git a/packages/mmp/src/map/handlers/drag.ts b/packages/mmp/src/map/handlers/drag.ts index 470c7b57..9e9291c7 100644 --- a/packages/mmp/src/map/handlers/drag.ts +++ b/packages/mmp/src/map/handlers/drag.ts @@ -2,7 +2,6 @@ import * as d3 from 'd3'; import { DragBehavior, D3DragEvent } from 'd3'; import Map from '../map.js'; import Node from '../models/node.js'; -import { Event } from './events.js'; /** * Manage the drag events of the nodes. @@ -133,17 +132,15 @@ export default class Drag { 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), + // The drag moved each node many times, so no single previous value + // describes the change. + for (const moved of [...this.descendants, node]) { + this.map.events.emit('nodeUpdate', { + nodeProperties: this.map.nodes.getNodeProperties(moved), changedProperty: 'coordinates', + previousValue: undefined, }); } - - this.map.events.call(Event.nodeUpdate, node.dom, { - nodeProperties: this.map.nodes.getNodeProperties(node), - changedProperty: 'coordinates', - }); } } } diff --git a/packages/mmp/src/map/handlers/draw.ts b/packages/mmp/src/map/handlers/draw.ts index e4e3095a..4cacac32 100644 --- a/packages/mmp/src/map/handlers/draw.ts +++ b/packages/mmp/src/map/handlers/draw.ts @@ -5,7 +5,9 @@ import { isImageDataUrl, isImageReference, isSafeLinkHref, + type NodeProperty, } from '@teammapper/shared'; +import type { NodeView } from './node-view.js'; import Map, { DomElements } from '../map.js'; import Utils from '../../utils/utils.js'; import Node from '../models/node.js'; @@ -19,7 +21,7 @@ import { /** * Draw the map and update it. */ -export default class Draw { +export default class Draw implements NodeView { private map: Map; private editing = false; private mapRef: HTMLElement; @@ -158,7 +160,7 @@ export default class Draw { // Set background of the node outer .insert('path', 'foreignObject') - .style('fill', (node: Node) => DOMPurify.sanitize(node.colors.background)) + .style('fill', (node: Node) => node.colors.background) .style('stroke-width', 3) .attr('d', (node: Node) => this.drawNodeBackground(node).toString()); @@ -174,8 +176,8 @@ export default class Draw { dom.branches .enter() .insert('path', 'g') - .style('fill', (node: Node) => DOMPurify.sanitize(node.colors.branch)) - .style('stroke', (node: Node) => DOMPurify.sanitize(node.colors.branch)) + .style('fill', (node: Node) => node.colors.branch) + .style('stroke', (node: Node) => 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 @@ -189,6 +191,111 @@ export default class Draw { dom.branches.exit().remove(); } + /** + * Draw the property of the node as its model holds it. + * @param {Node} node + * @param {NodeProperty} property + */ + public renderNodeProperty(node: Node, property: NodeProperty) { + switch (property) { + case 'name': + node.getNameDOM().innerHTML = DOMPurify.sanitize(node.name); + this.updateNodeShapes(node); + break; + case 'coordinates': + node.dom.setAttribute( + 'transform', + 'translate(' + [node.coordinates.x, node.coordinates.y] + ')' + ); + this.redrawBranches(); + break; + case 'imageSrc': + this.setImage(node); + break; + case 'imageSize': + this.resizeImage(node); + break; + case 'linkHref': + this.setLink(node); + break; + case 'backgroundColor': + this.paintBackground(node); + break; + case 'branchColor': { + const branch = document.getElementById(node.id + '_branch'); + if (branch) + branch.style.fill = branch.style.stroke = node.colors.branch; + break; + } + case 'nameColor': + node.getNameDOM().style.color = node.colors.name; + this.updateProtectionIcon(node); + break; + case 'fontSize': + node.getNameDOM().style.fontSize = node.font.size + 'px'; + this.updateNodeShapes(node); + break; + case 'fontWeight': + node.getNameDOM().style.fontWeight = node.font.weight; + this.updateNodeShapes(node); + break; + case 'fontStyle': + node.getNameDOM().style.fontStyle = node.font.style; + break; + case 'protected': + this.updateProtectionIcon(node); + break; + case 'hidden': + // The caller hides and shows nodes with a full update. + break; + } + } + + /** + * Redraw the branch of every node. + */ + public redrawBranches() { + d3.selectAll('.' + this.map.id + '_branch').attr( + 'd', + (node: Node) => this.drawBranch(node)?.toString() ?? null + ); + } + + /** + * Fill the node background and darken the selection ring along with it. + * @param {Node} node + */ + private paintBackground(node: Node) { + const background = node.getBackgroundDOM(); + background.style.fill = node.colors.background; + + if (background.style.stroke !== '') { + const darker = d3.color(node.colors.background)?.darker(0.5); + if (darker) background.style.stroke = darker.toString(); + } + } + + /** + * Scale the node image to its size. An image that failed to load has no + * element, and its size waits in the model. + * @param {Node} node + */ + private resizeImage(node: Node) { + const image = node.dom.querySelector('image'); + if (!image) return; + + const box = image.getBBox(), + height = node.image.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()); + } + /** * Remove all nodes and branches of the map. */ @@ -276,10 +383,7 @@ export default class Draw { d3.select(background).attr('d', (node: Node) => this.drawNodeBackground(node).toString() ); - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => this.drawBranch(node)?.toString() ?? null - ); + this.redrawBranches(); this.updateImagePosition(node); this.updateLinkPosition(node); @@ -394,7 +498,7 @@ export default class Draw { domIcon.textContent = 'visibility_off'; domIcon.classList.add('hidden-icon'); domIcon.classList.add('material-icons'); - domIcon.style.setProperty('fill', DOMPurify.sanitize(node.colors.name)); + domIcon.style.setProperty('fill', node.colors.name); domIcon.setAttribute('y', (-node.dimensions.height + 30).toString()); domIcon.setAttribute('x', '-60'); node.dom.appendChild(domIcon); @@ -433,7 +537,7 @@ export default class Draw { icon.classList.add('protected-icon', 'material-icons'); node.dom.appendChild(icon); } - icon.style.setProperty('fill', DOMPurify.sanitize(node.colors.name)); + icon.style.setProperty('fill', node.colors.name); icon.setAttribute('y', (-node.dimensions.height + 30).toString()); icon.setAttribute('x', (node.dimensions.width / 2).toString()); } @@ -615,13 +719,10 @@ export default class Draw { private createNodeNameDOM(node: Node) { const div = document.createElement('div'); - div.style.setProperty( - 'font-size', - DOMPurify.sanitize(node.font.size.toString()) + 'px' - ); - 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)); + div.style.setProperty('font-size', node.font.size + 'px'); + div.style.setProperty('color', node.colors.name); + div.style.setProperty('font-style', node.font.style); + div.style.setProperty('font-weight', node.font.weight); div.style.setProperty('touch-action', 'none'); div.style.setProperty('display', 'inline-block'); @@ -682,7 +783,7 @@ export default class Draw { domText.textContent = 'link'; domText.classList.add('material-icons'); } - domText.style.setProperty('fill', DOMPurify.sanitize(node.colors.link)); + domText.style.setProperty('fill', node.colors.link); domText.setAttribute('y', node.dimensions.height.toString()); domText.setAttribute('text-anchor', 'middle'); } diff --git a/packages/mmp/src/map/handlers/empty-selection.spec.ts b/packages/mmp/src/map/handlers/empty-selection.spec.ts index 8ff44992..e4f8ef0c 100644 --- a/packages/mmp/src/map/handlers/empty-selection.spec.ts +++ b/packages/mmp/src/map/handlers/empty-selection.spec.ts @@ -3,7 +3,6 @@ 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'; /** * Deselecting leaves no node selected, the main root included. With nothing @@ -31,12 +30,16 @@ function makeNode(properties: Partial & { id: string }): Node { } function makeMap() { - const events = { call: jest.fn() }; + const events = { emit: jest.fn() }; const history = { save: jest.fn() }; const map = { rootId: 'root', options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn(), clear: jest.fn() }, + draw: { + update: jest.fn(), + clear: jest.fn(), + renderNodeProperty: jest.fn(), + }, events, history, } as unknown as MmpMap; @@ -56,14 +59,14 @@ function makeMap() { parent: root, coordinates: { x: -200, y: 0 }, }); - for (const node of [root, branch, other]) handler.setNode(node.id, node); + for (const node of [root, branch, other]) handler.setNode(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]); +function firedEvents(events: { emit: jest.Mock }): string[] { + return events.emit.mock.calls.map(call => call[0]); } describe('deselectNode', () => { @@ -80,13 +83,12 @@ describe('deselectNode', () => { it('tells listeners which node lost the selection', () => { const { handler, events, nodes } = makeMap(); handler.selectNode(nodes.branch.id); - events.call.mockClear(); + events.emit.mockClear(); handler.deselectNode(); - expect(events.call).toHaveBeenCalledWith( - Event.nodeDeselect, - nodes.branch.dom, + expect(events.emit).toHaveBeenCalledWith( + 'nodeDeselect', expect.objectContaining({ id: nodes.branch.id }) ); expect(nodes.branch.getBackgroundDOM().style.stroke).toBe(''); @@ -97,7 +99,7 @@ describe('deselectNode', () => { handler.deselectNode(); - expect(events.call).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); }); }); @@ -109,7 +111,7 @@ describe('selectNode with nothing selected', () => { expect(selected?.id).toBe(nodes.branch.id); expect(handler.getSelectedNode()).toBe(nodes.branch); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(firedEvents(events)).toEqual(['nodeSelect']); }); it('ignores a direction', () => { @@ -117,7 +119,7 @@ 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(); }); }); @@ -125,13 +127,13 @@ 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(); + events.emit.mockClear(); handler.selectNode(nodes.other.id); - 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', nodes.branch.id], + ['nodeSelect', nodes.other.id], ]); }); @@ -139,8 +141,8 @@ describe('selectNode from one node to another', () => { const { handler, events, nodes } = makeMap(); handler.selectNode(nodes.branch.id); const selectedDuringDeselect: (Node | null)[] = []; - events.call.mockImplementation((event: string) => { - if (event === Event.nodeDeselect) { + events.emit.mockImplementation((event: string) => { + if (event === 'nodeDeselect') { selectedDuringDeselect.push(handler.getSelectedNode()); } }); @@ -214,7 +216,7 @@ describe('operations on the selected node with nothing selected', () => { map.copyPaste.paste(); expect(handler.getNodes()).toHaveLength(3); - expect(events.call).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); }); }); @@ -232,7 +234,7 @@ describe('removeNode and the selection', () => { 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.setNode(grandchild); handler.selectNode(grandchild.id); handler.removeNode(nodes.branch.id, false); diff --git a/packages/mmp/src/map/handlers/events.ts b/packages/mmp/src/map/handlers/events.ts index 6efb3275..e03c7afb 100644 --- a/packages/mmp/src/map/handlers/events.ts +++ b/packages/mmp/src/map/handlers/events.ts @@ -1,71 +1,60 @@ -import { dispatch, Dispatch } from 'd3'; -import Utils from '../../utils/utils.js'; import Log from '../../utils/log.js'; - -export type MmpEventCallback = (...args: unknown[]) => void; +import type { MmpEventPayloadMap, MmpEventType } from '@teammapper/shared'; + +export type MmpEventCallback = ( + payload: MmpEventPayloadMap[K] +) => void; + +type EventCallbacks = { + [P in K]?: MmpEventCallback

; +}; + +const EVENT_TYPES: readonly MmpEventType[] = [ + 'create', + 'nodeSelect', + 'nodeDeselect', + 'nodeUpdate', + 'nodeCreate', + 'nodePaste', + 'nodeRemove', + 'distribute', + 'nodeProtected', +]; /** - * Manage the events of the map. + * Manage the events of the map. `@teammapper/shared` types each payload, and + * each event holds at most one callback. */ 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 the callback registered for the event with its payload. */ - public call(event: Event, that?: object, ...parameters: unknown[]) { - return this.dispatcher.call(event, that, ...parameters); + public emit( + event: K, + payload: MmpEventPayloadMap[K] + ) { + this.callbacks[event]?.(payload); } /** - * Add a callback for specific map event. - * @param {string} event - * @param {Function} callback + * Register the callback for the event, replacing an earlier one. */ - 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 + ) => { + if (!EVENT_TYPES.includes(event)) Log.error('The event does not exist'); + + const callbacks: EventCallbacks = this.callbacks; + callbacks[event] = 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/history.ts b/packages/mmp/src/map/handlers/history.ts index a46db01b..cf04d2ca 100644 --- a/packages/mmp/src/map/handlers/history.ts +++ b/packages/mmp/src/map/handlers/history.ts @@ -1,6 +1,7 @@ import Map from '../map.js'; import Node, { NodeProperties } from '../models/node.js'; -import { Event } from './events.js'; +import * as v from 'valibot'; +import { LinkSchema, NodeSchema } from '@teammapper/shared'; import Log from '../../utils/log.js'; import Utils from '../../utils/utils.js'; import { DefaultNodeValues } from '../options.js'; @@ -15,6 +16,16 @@ import type { OldMmpNode, } from '@teammapper/shared'; +/** + * A node of a snapshot the map accepts. Older maps carry no link and no + * main-root mark. + */ +const SnapshotNodeSchema = v.object({ + ...NodeSchema.entries, + link: v.optional(LinkSchema), + isRoot: v.optional(v.boolean()), +}); + /** * Hold the snapshot of the current map and rebuild the map from one. */ @@ -45,7 +56,10 @@ export default class History { * Replace old map with a new one or create a new empty map. * @param {MapSnapshot} snapshot */ - public new = (snapshot?: MapSnapshot, notifyWithEvent = true) => { + public new = (input?: MapSnapshot, notifyWithEvent = true) => { + // The conversions below write to the snapshot, and the caller keeps its own. + const snapshot = input === undefined ? undefined : Utils.cloneObject(input); + if (snapshot === undefined) { this.map.nodes.clear(); @@ -58,7 +72,7 @@ export default class History { this.save(); - if (notifyWithEvent) this.map.events.call(Event.create, this.map.dom); + if (notifyWithEvent) this.map.events.emit('create', {}); } else if (this.checkSnapshotStructure(snapshot)) { const previousData = this.map.export.asJSON(); @@ -78,10 +92,9 @@ export default class History { ); } else { this.save(); - if (notifyWithEvent) - this.map.events.call(Event.create, this.map.dom, { - previousMap: previousData, - }); + if (notifyWithEvent) { + this.map.events.emit('create', { previousMapData: previousData }); + } } } else { Log.error('The snapshot is not correct'); @@ -129,7 +142,7 @@ export default class History { }; const node: Node = new Node(properties); - this.map.nodes.setNode(node.id, node); + this.map.nodes.setNode(node); if (mergedProperty.isRoot) this.map.rootId = mergedProperty.id; }); @@ -173,53 +186,7 @@ export default class History { 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); + return snapshot.every(node => v.is(SnapshotNodeSchema, node)); } /** @@ -237,6 +204,7 @@ export default class History { ? 'map_node_' + oldNode.value.parent.substr(4) : ''; target.k = oldNode.value.k; + target.isRoot = !oldNode.value.parent; target.name = oldNode.value.name; target.coordinates = { x: oldNode.value.x, diff --git a/packages/mmp/src/map/handlers/legacy-detached.spec.ts b/packages/mmp/src/map/handlers/legacy-detached.spec.ts index 607a4e27..f46d90be 100644 --- a/packages/mmp/src/map/handlers/legacy-detached.spec.ts +++ b/packages/mmp/src/map/handlers/legacy-detached.spec.ts @@ -66,7 +66,7 @@ function makeMap(): MmpMap { options: { defaultNode: DefaultNodeValues }, draw: { clear: jest.fn(), update }, zoom: { center: jest.fn() }, - events: { call: jest.fn() }, + events: { emit: jest.fn() }, export: { asJSON: () => [] }, } as unknown as MmpMap; map.nodes = new Nodes(map); diff --git a/packages/mmp/src/map/handlers/multi-tree.spec.ts b/packages/mmp/src/map/handlers/multi-tree.spec.ts index a3ce45b7..0f5b748e 100644 --- a/packages/mmp/src/map/handlers/multi-tree.spec.ts +++ b/packages/mmp/src/map/handlers/multi-tree.spec.ts @@ -18,7 +18,6 @@ import type { ExportNodeProperties } from '@teammapper/shared'; */ interface NodesInternals { - nodes: Map; selectedNode: Node | null; moveSelectionOnLevel(selected: Node, direction: boolean): void; moveSelectionOnBranch(selected: Node, direction: boolean): void; @@ -59,8 +58,8 @@ function makeMap(view: Bounds | null = null): { const map = { rootId: 'root', options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn() }, - events: { call: jest.fn() }, + draw: { update: jest.fn(), renderNodeProperty: jest.fn() }, + events: { emit: jest.fn() }, history, zoom, } as unknown as MmpMap; @@ -81,7 +80,7 @@ function makeMap(view: Bounds | null = null): { }); const nodes = { root, branch, secondRoot }; - for (const node of Object.values(nodes)) internals.nodes.set(node.id, node); + for (const node of Object.values(nodes)) handler.store.set(node); internals.selectedNode = branch; return { handler, internals, nodes, history, zoom }; @@ -377,7 +376,7 @@ describe('moveSelectionOnLevel', () => { coordinates: { x: 800, y: 200 }, }), ]; - children.forEach(child => internals.nodes.set(child.id, child)); + children.forEach(child => handler.store.set(child)); const selectNode = jest.fn(); handler.selectNode = selectNode; @@ -402,7 +401,7 @@ describe('moveSelectionOnBranch', () => { coordinates: { x: 1200, y: 0 }, }), ]; - children.forEach(child => internals.nodes.set(child.id, child)); + children.forEach(child => handler.store.set(child)); const selectNode = jest.fn(); handler.selectNode = selectNode; diff --git a/packages/mmp/src/map/handlers/node-image.spec.ts b/packages/mmp/src/map/handlers/node-image.spec.ts index cacb81f2..147790a2 100644 --- a/packages/mmp/src/map/handlers/node-image.spec.ts +++ b/packages/mmp/src/map/handlers/node-image.spec.ts @@ -48,7 +48,7 @@ function makeMap(resolveImageUrl?: (reference: string) => string | null) { rootId: 'root', options: { defaultNode: DefaultNodeValues, resolveImageUrl }, draw: { update: jest.fn(), clear: jest.fn() }, - events: { call: jest.fn() }, + events: { emit: jest.fn() }, history: { save: jest.fn() }, } as unknown as MmpMap; const draw = new Draw(map, document.createElement('div')); @@ -148,7 +148,7 @@ describe('node images', () => { 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); + map.nodes.setNode(node); draw.setImage(node); lastImage().fail(); diff --git a/packages/mmp/src/map/handlers/node-link.spec.ts b/packages/mmp/src/map/handlers/node-link.spec.ts index 1ea802c5..10cfd454 100644 --- a/packages/mmp/src/map/handlers/node-link.spec.ts +++ b/packages/mmp/src/map/handlers/node-link.spec.ts @@ -11,7 +11,7 @@ function makeDraw(): Draw { rootId: 'root', options: { defaultNode: DefaultNodeValues }, draw: { update: jest.fn(), clear: jest.fn() }, - events: { call: jest.fn() }, + events: { emit: jest.fn() }, history: { save: jest.fn() }, } as unknown as MmpMap; const draw = new Draw(map, document.createElement('div')); diff --git a/packages/mmp/src/map/handlers/node-protection.spec.ts b/packages/mmp/src/map/handlers/node-protection.spec.ts index 93952447..a2793a10 100644 --- a/packages/mmp/src/map/handlers/node-protection.spec.ts +++ b/packages/mmp/src/map/handlers/node-protection.spec.ts @@ -5,7 +5,6 @@ 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, @@ -44,7 +43,7 @@ function makeNode(properties: Partial & { id: string }): Node { /** root -> a -> b, root -> c, with `a` protected. */ function makeMap() { - const events = { call: jest.fn() }; + const events = { emit: jest.fn() }; const map = { id: 'map', rootId: 'root', @@ -55,6 +54,7 @@ function makeMap() { updateNodeShapes: jest.fn(), updateProtectionIcon: jest.fn(), drawBranch: jest.fn(() => null), + renderNodeProperty: jest.fn(), }, history: { save: jest.fn() }, events, @@ -76,18 +76,18 @@ function makeMap() { 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)); + Object.values(tree).forEach(node => nodes.setNode(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); +function calls(events: { emit: jest.Mock }, event: string): unknown[][] { + return events.emit.mock.calls.filter(([name]) => name === event); } -function refusals(events: { call: jest.Mock }): string[] { - return calls(events, Event.nodeProtected).map( - ([, , properties]) => (properties as ExportNodeProperties).id +function refusals(events: { emit: jest.Mock }): string[] { + return calls(events, 'nodeProtected').map( + ([, properties]) => (properties as ExportNodeProperties).id ); } @@ -133,8 +133,8 @@ describe('protectBranch', () => { nodes.protectBranch('root'); - const changed = calls(events, Event.nodeUpdate).map( - ([, , update]) => + const changed = calls(events, 'nodeUpdate').map( + ([, update]) => (update as { nodeProperties: ExportNodeProperties }).nodeProperties.id ); expect(changed).toEqual(['a', 'root']); @@ -191,7 +191,7 @@ describe('local edits inside a protected branch', () => { 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); + nodes.setNode(d); tree.a.parent = d; nodes.removeNode('d'); @@ -278,7 +278,7 @@ describe('changes that stay allowed', () => { clipboard.paste('c'); - const pasted = (calls(events, Event.nodePaste)[0][2] ?? + const pasted = (calls(events, 'nodePaste')[0][1] ?? []) as ExportNodeProperties[]; expect(pasted).toHaveLength(2); expect(pasted.every(node => node.protected === false)).toBe(true); @@ -293,7 +293,7 @@ describe('drag', () => { drag(map, tree.b, 50, 50); expect(tree.b.coordinates).toEqual({ x: 400, y: 0 }); - expect(calls(events, Event.nodeUpdate)).toHaveLength(0); + expect(calls(events, 'nodeUpdate')).toHaveLength(0); expect(refusals(events)).toEqual(['b']); }); diff --git a/packages/mmp/src/map/handlers/node-selection.spec.ts b/packages/mmp/src/map/handlers/node-selection.spec.ts index 1237d6ca..cad78825 100644 --- a/packages/mmp/src/map/handlers/node-selection.spec.ts +++ b/packages/mmp/src/map/handlers/node-selection.spec.ts @@ -35,12 +35,11 @@ function selectionAfterBranchMove( ): 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; }; for (const node of [ROOT, LEFT_CHILD, LEFT_GRANDCHILD, RIGHT_CHILD]) { - internals.nodes.set(node.id, node); + handler.store.set(node); } const selectNode = jest.fn(); diff --git a/packages/mmp/src/map/handlers/node-view.ts b/packages/mmp/src/map/handlers/node-view.ts new file mode 100644 index 00000000..1584ea84 --- /dev/null +++ b/packages/mmp/src/map/handlers/node-view.ts @@ -0,0 +1,11 @@ +import type { NodeProperty } from '@teammapper/shared'; +import type Node from '../models/node.js'; + +/** + * What the node change path tells the view. The model already holds the new + * value when the view hears of it. + */ +export interface NodeView { + /** Draw the property of the node as its model holds it. */ + renderNodeProperty(node: Node, property: NodeProperty): void; +} diff --git a/packages/mmp/src/map/handlers/nodes.ts b/packages/mmp/src/map/handlers/nodes.ts index 6dc89618..2291a8a9 100644 --- a/packages/mmp/src/map/handlers/nodes.ts +++ b/packages/mmp/src/map/handlers/nodes.ts @@ -1,9 +1,11 @@ import Node, { NodeProperties } from '../models/node.js'; +import NodeStore from '../models/node-store.js'; +import type { NodeView } from './node-view.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 { CssColorSchema, NodePropertySchemas } from '@teammapper/shared'; import Log from '../../utils/log.js'; import Utils from '../../utils/utils.js'; import { computeMapLayout, LayoutInputNode } from './layout.js'; @@ -47,7 +49,10 @@ export const PropertyMapping = { fontSize: ['font', 'size'], nameColor: ['colors', 'name'], hidden: ['hidden'], -} as const; +} as const satisfies Record; + +const isNodeProperty = (property: string): property is NodeProperty => + Object.keys(PropertyMapping).includes(property); /** * Manage the nodes of the map. @@ -59,14 +64,17 @@ export default class Nodes { */ constructor(map: MmpMap) { this.map = map; - - this.nodes = new Map(); } static NodePropertyMapping: typeof PropertyMapping = PropertyMapping; private map: MmpMap; - private nodes: Map; + public readonly store = new NodeStore(); + + /** The view every node change reports to. */ + private get view(): NodeView { + return this.map.draw; + } // deselectNode sets this to null. A map load selects the main root. private selectedNode: Node | null = null; @@ -97,7 +105,7 @@ export default class Nodes { node.coordinates.y = coordinates.y || node.coordinates.y; } - this.nodes.set(properties.id, node); + this.store.set(node); this.map.draw.update(); @@ -159,7 +167,7 @@ export default class Nodes { const node: Node = new Node(properties); - this.nodes.set(properties.id, node); + this.store.set(node); if ( !properties.coordinates?.x && @@ -175,12 +183,9 @@ export default class Nodes { this.map.history.save(); } - if (notifyWithEvent) - this.map.events.call( - Event.nodeCreate, - node.dom, - this.getNodeProperties(node) - ); + if (notifyWithEvent) { + this.map.events.emit('nodeCreate', this.getNodeProperties(node)); + } return node; }; @@ -229,7 +234,7 @@ export default class Nodes { } if (!this.nodeSelectionTo(id)) { - const node = this.nodes.get(id); + const node = this.store.get(id); if (node) { const background = node.getBackgroundDOM(); @@ -279,11 +284,7 @@ export default class Nodes { */ private announceSelection(node: Node) { this.selectedNode = node; - this.map.events.call( - Event.nodeSelect, - node.dom, - this.getNodeProperties(node) - ); + this.map.events.emit('nodeSelect', this.getNodeProperties(node)); } /** @@ -314,47 +315,21 @@ export default class Nodes { // 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.map.events.emit('nodeDeselect', this.getNodeProperties(previous)); } /** - * 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 => { + const node = this.store.get(id); + if (!node) 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'); - } - } + node.getBackgroundDOM().style.stroke = color; }; /** @@ -369,7 +344,7 @@ export default class Nodes { return false; } - return this.nodes.has(id); + return this.store.has(id); } return false; }; @@ -469,73 +444,27 @@ export default class Nodes { 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) { + const previousValue = Utils.get(node, PropertyMapping[property]); + const nextValue = this.validatedValue(node, property, value); + if (Nodes.sameValue(previousValue, nextValue)) return; + + this.writeProperty(node, property, nextValue); + this.view.renderNodeProperty(node, property); + + if (updateHistory) { this.map.history.save(); } - if (updated !== false && notifyWithEvent) { - this.map.events.call(Event.nodeUpdate, node.dom, { + if (notifyWithEvent) { + this.map.events.emit('nodeUpdate', { nodeProperties: this.getNodeProperties(node), changedProperty: property, previousValue, @@ -543,6 +472,63 @@ export default class Nodes { } }; + /** + * Check a new value of the property against the shared schema and the + * node, and return the value the model takes. An empty value clears a + * text property and leaves a number as it is. + */ + private validatedValue( + node: Node, + 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 (property === 'imageSize' && node.image.src === '') { + Log.error('The node does not have an image'); + } + + const previousValue = Utils.get(node, PropertyMapping[property]); + const nextValue = + result.output ?? (typeof previousValue === 'string' ? '' : previousValue); + + // A remote colors sync sends the branch color with the other colors, + // unchanged, so a root accepts its own value without an error. + if ( + property === 'branchColor' && + !node.parent && + nextValue !== node.colors.branch + ) { + Log.error('A root node has no branches'); + } + + return nextValue; + } + + 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; + } + + /** Write the value at the property's path in the node model. */ + private writeProperty(node: Node, property: NodeProperty, value: unknown) { + const path = PropertyMapping[property]; + const key = path[path.length - 1]; + const owner = path + .slice(0, -1) + .reduce>( + (target, segment) => target[segment] as Record, + node as unknown as Record + ); + + owner[key] = Utils.isPureObjectType(value) ? { ...value } : value; + } + /** * Remove the selected node. * @param {string} id @@ -554,10 +540,10 @@ export default class Nodes { if (this.refusesLocalRemoval(node, notifyWithEvent)) return; if (!node.isRoot) { - this.nodes.delete(node.id); + this.store.delete(node.id); this.getDescendants(node).forEach((node: Node) => { - this.nodes.delete(node.id); + this.store.delete(node.id); }); this.map.draw.clear(); @@ -565,16 +551,13 @@ export default class Nodes { this.map.history.save(); - if (notifyWithEvent) - this.map.events.call( - Event.nodeRemove, - undefined, - this.getNodeProperties(node) - ); + if (notifyWithEvent) { + this.map.events.emit('nodeRemove', this.getNodeProperties(node)); + } // Deselect only when the removal deleted the selected node or one of // its ancestors. - if (this.selectedNode && !this.nodes.has(this.selectedNode.id)) { + if (this.selectedNode && !this.store.has(this.selectedNode.id)) { this.deselectNode(); } else { this.redrawSelectionRing(); @@ -592,15 +575,8 @@ export default class Nodes { * @returns {string | null} */ public protectingNode = (id?: string): string | null => { - const visited = new Set(); - let node = this.getTargetNode(id) ?? null; - - while (node && !visited.has(node)) { - if (node.protected) return node.id; - visited.add(node); - node = node.parent; - } - return null; + const node = this.getTargetNode(id); + return node ? (this.store.protectingNode(node)?.id ?? null) : null; }; /** @@ -650,11 +626,7 @@ export default class Nodes { * @param {Node} node */ public refuseProtected(node: Node) { - this.map.events.call( - Event.nodeProtected, - node.dom, - this.getNodeProperties(node) - ); + this.map.events.emit('nodeProtected', this.getNodeProperties(node)); } /** @@ -801,9 +773,7 @@ export default class Nodes { * @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 this.store.children(node); } /** @@ -812,30 +782,15 @@ export default class Nodes { * @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 this.store.orientation(node); } /** - * 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. + * Return the root of the tree a node belongs to. * @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; + return this.store.treeRoot(node); } /** @@ -908,19 +863,14 @@ export default class Nodes { * @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 this.store.descendants(node); } /** * Return an array of all nodes. */ public getNodes(): Node[] { - return Array.from(this.nodes.values()); + return this.store.all(); } /** @@ -933,8 +883,8 @@ export default class Nodes { /** * Set a node as a id-value copy. */ - public setNode(key: string, node: Node) { - this.nodes.set(key, node); + public setNode(node: Node) { + this.store.set(node); } /** @@ -953,7 +903,7 @@ export default class Nodes { // 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) { + if (selected && this.store.get(selected.id) !== selected) { this.selectedNode = null; } @@ -972,7 +922,7 @@ export default class Nodes { * Delete all nodes. */ public clear() { - this.nodes.clear(); + this.store.clear(); } /** @@ -980,7 +930,7 @@ export default class Nodes { * @returns {Node} rootNode */ public getRoot = (): Node => { - const root = this.nodes.get(this.map.rootId); + const root = this.store.get(this.map.rootId); if (root === undefined) { Log.error('The map has no root node'); @@ -1000,7 +950,7 @@ export default class Nodes { Log.error('The node id must be a string', 'type'); return undefined; } - return this.nodes.get(id); + return this.store.get(id); } return undefined; }; @@ -1011,18 +961,7 @@ export default class Nodes { * @returns {Array} siblings */ 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 []; + return this.store.siblings(node); } /** @@ -1124,18 +1063,18 @@ export default class Nodes { // 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.redrawBranches(); this.map.draw.update(); this.map.history.save(); if (notifyWithEvent) { - this.map.events.call(Event.distribute); + this.map.events.emit('distribute', undefined); } }; /** Move one node, leaving the branch redraw to the caller. */ private moveNodeTo(id: string, coordinates: MapNodeCoordinates): void { - const node = this.nodes.get(id); + const node = this.store.get(id); if (!node) return; node.coordinates = { x: coordinates.x, y: coordinates.y }; @@ -1145,21 +1084,8 @@ export default class Nodes { ); } - 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); - - return branch ? branch.toString() : null; - } - ); - } - private toLayoutInput(): LayoutInputNode[] { - return Array.from(this.nodes.values()).map(node => ({ + return this.store.all().map(node => ({ id: node.id, parent: node.parent ? node.parent.id : '', isRoot: node.isRoot, @@ -1188,355 +1114,6 @@ export default class Nodes { }, 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 diff --git a/packages/mmp/src/map/handlers/paste-coordinates.spec.ts b/packages/mmp/src/map/handlers/paste-coordinates.spec.ts index 9ca9e31f..56334d84 100644 --- a/packages/mmp/src/map/handlers/paste-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/paste-coordinates.spec.ts @@ -52,7 +52,7 @@ function makeHandler(liveNodes: Node[]): { 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)); + [ROOT, ...liveNodes].forEach(node => nodes.setNode(node)); // No zoom transform applies in these tests, so this is the identity. nodes.fixCoordinates = (coordinates: MapNodeCoordinates) => coordinates; @@ -188,7 +188,7 @@ describe('calculatePastedCoordinates', () => { handler.copy(oldParentNode.id); nodes.clear(); - [ROOT, newParent].forEach(node => nodes.setNode(node.id, node)); + [ROOT, newParent].forEach(node => nodes.setNode(node)); expect(handler.copiedTreeRootX).toBe(secondTreeRootX); expect(handler.calculatePastedCoordinates(pasted, newParent)).toEqual({ diff --git a/packages/mmp/src/map/handlers/root-selection.spec.ts b/packages/mmp/src/map/handlers/root-selection.spec.ts index f97c67be..f1d7c9e3 100644 --- a/packages/mmp/src/map/handlers/root-selection.spec.ts +++ b/packages/mmp/src/map/handlers/root-selection.spec.ts @@ -2,7 +2,6 @@ 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, @@ -27,7 +26,7 @@ function nodeDom(fill: string): SVGGElement { * node a DOM filled with the node's background colour. */ function makeMap() { - const events = { call: jest.fn() }; + const events = { emit: jest.fn() }; const update = jest.fn(() => { for (const node of map.nodes.getNodes()) { node.dom = nodeDom(node.colors.background); @@ -82,8 +81,8 @@ function ring(fill: string): string | undefined { } /** The event names mmp fired, in order. */ -function firedEvents(events: { call: jest.Mock }): string[] { - return events.call.mock.calls.map(call => call[0]); +function firedEvents(events: { emit: jest.Mock }): string[] { + return events.emit.mock.calls.map(call => call[0]); } // A map load selects the main root: it draws the ring on the root and tells @@ -104,38 +103,37 @@ describe('a map load', () => { map.history.new(snapshot('root'), false); - 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)).not.toContain('create'); }); 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(); + events.emit.mockClear(); map.history.new(snapshot('root'), false); const root = map.nodes.getRoot(); expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(firedEvents(events)).toEqual(['nodeSelect']); }); 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(); + events.emit.mockClear(); map.history.new(snapshot('other-root'), false); 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(firedEvents(events)).toEqual(['nodeSelect']); + expect(events.emit.mock.calls[0][1].id).toBe('other-root'); }); it('rings the root it creates when no snapshot is given', () => { @@ -147,9 +145,8 @@ describe('a map load', () => { expect(root.getBackgroundDOM().style.stroke).toBe( ring(DefaultRootNodeValues.colors.background) ); - expect(events.call).toHaveBeenCalledWith( - Event.nodeSelect, - root.dom, + expect(events.emit).toHaveBeenCalledWith( + 'nodeSelect', expect.objectContaining({ id: root.id }) ); }); @@ -160,7 +157,7 @@ describe('a map load', () => { map.history.new(snapshot('root', ''), false); expect(map.nodes.getSelectedNode()?.id).toBe('root'); - expect(firedEvents(events)).toEqual([Event.nodeSelect]); + expect(firedEvents(events)).toEqual(['nodeSelect']); }); }); @@ -195,11 +192,11 @@ describe('selectRootNode', () => { const { map, events } = makeMap(); map.history.new(snapshot('root'), false); 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/tree-clipboard.spec.ts b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts index 6147ade0..a39f82ba 100644 --- a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts +++ b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts @@ -3,7 +3,6 @@ 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 { @@ -39,7 +38,7 @@ function makeNode(properties: Partial & { id: string }): Node { * has a left-hand child with a grandchild and a right-hand child. */ function makeMap(view: Bounds | null = null) { - const events = { call: jest.fn() }; + const events = { emit: 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() }; @@ -87,7 +86,7 @@ function makeMap(view: Bounds | null = null) { }); const tree = { root, branch, second, left, grandchild, right }; - Object.values(tree).forEach(node => nodes.setNode(node.id, node)); + Object.values(tree).forEach(node => nodes.setNode(node)); const clipboard = new CopyPaste(map); @@ -99,11 +98,9 @@ function ids(nodes: Node[]): string[] { } /** 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[]; +function pastedNodes(nodes: Nodes, events: { emit: jest.Mock }): Node[] { + const call = events.emit.mock.calls.find(([event]) => event === 'nodePaste'); + const pasted = (call?.[1] ?? []) as ExportNodeProperties[]; return pasted.map(properties => nodes.getNode(properties.id) as Node); } @@ -273,8 +270,8 @@ describe('pasteTree', () => { parent: inner, coordinates: { x: 1050, y: -360 }, }); - context.nodes.setNode(inner.id, inner); - context.nodes.setNode(innermost.id, innermost); + context.nodes.setNode(inner); + context.nodes.setNode(innermost); context.clipboard.copy('right'); const expectedRoot = context.nodes.newTreeCoordinates(); @@ -330,10 +327,10 @@ describe('pasteTree', () => { clipboard.pasteTree(); clipboard.pasteTree(); - const pasteCalls = events.call.mock.calls.filter( - ([event]) => event === Event.nodePaste + const pasteCalls = events.emit.mock.calls.filter( + ([event]) => event === 'nodePaste' ); - const roots = pasteCalls.map(([, , pasted]) => { + const roots = pasteCalls.map(([, pasted]) => { const [first] = pasted as ExportNodeProperties[]; return nodes.getNode(first.id); }); diff --git a/packages/mmp/src/map/map.ts b/packages/mmp/src/map/map.ts index b3406287..64fa12e4 100644 --- a/packages/mmp/src/map/map.ts +++ b/packages/mmp/src/map/map.ts @@ -1,5 +1,5 @@ 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'; @@ -12,6 +12,7 @@ import Node from './models/node.js'; import type { ExportNodeProperties, MapSnapshot, + MmpEventType, NodeProperty, NodePropertyValue, UserNodeProperties, @@ -75,9 +76,12 @@ export default class MmpMap { } /** - * 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.events.unsubscribeAll(); this.dom.svg.remove(); const instanceRecord = this.instance as unknown as Record; @@ -117,7 +121,7 @@ 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, @@ -150,16 +154,16 @@ export interface MmpInstance { exportAsImage: (callback: (url: string) => void, type?: string) => void; exportAsJSON: () => MapSnapshot; exportRootProperties: () => ExportNodeProperties; - highlightNode: (id: string, color: string, notifyWithEvent?: boolean) => void; + highlightNode: (id: string, color: string) => void; new: (snapshot?: MapSnapshot, notifyWithEvent?: boolean) => 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; + destroy: () => void; removeNode: (id?: string, notifyWithEvent?: boolean) => void; selectNode: (id?: string) => ExportNodeProperties | null; unsubscribeAll: () => 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..39a89765 --- /dev/null +++ b/packages/mmp/src/map/mmp-instance.spec.ts @@ -0,0 +1,514 @@ +import * as d3 from 'd3'; +import { create } from '../index.js'; +import MmpMap from './map.js'; +import type { + ExportNodeProperties, + MapSnapshot, + NodeProperty, + NodeUpdateEvent, + OldMmpNode, +} from '@teammapper/shared'; + +/** + * 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. + */ + +const IMAGE_REFERENCE = 'image:5f0c4b1e-3a8d-4c6e-9f2a-7b1d2e3f4a5b'; + +function makeMap(): MmpMap { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref); + // jsdom lays nothing out; d3-zoom reads the extent from the view box. + map.dom.svg.attr('viewBox', '0 0 800 600'); + 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 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; +} + +function nodeDom(id: string): SVGGElement { + const dom = document.getElementById(id); + if (!(dom instanceof SVGGElement)) 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 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 => document.getElementById(id + '_branch')?.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: 'hidden', + value: true, + read: n => n.hidden, + }, + { + 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 model', () => { + const { map, child } = makeMapWithChild(); + + map.instance.updateNode(property, value, true, true, child); + + expect(read(exported(map, child))).toEqual(value); + }); + + it('announces the change with the previous value', () => { + const { map, child } = makeMapWithChild(); + const previousValue = read(exported(map, child)); + const updates: NodeUpdateEvent[] = []; + map.instance.on('nodeUpdate', event => updates.push(event)); + + map.instance.updateNode(property, value, true, true, child); + + expect(updates).toHaveLength(1); + expect(updates[0].changedProperty).toBe(property); + expect(updates[0].previousValue).toEqual(previousValue); + expect(read(updates[0].nodeProperties)).toEqual(value); + }); + + it('announces nothing when the value stays the same', () => { + const { map, child } = makeMapWithChild(); + map.instance.updateNode(property, value, true, true, child); + const listener = jest.fn(); + map.instance.on('nodeUpdate', listener); + + map.instance.updateNode(property, value, true, true, child); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('announces nothing without notifyWithEvent', () => { + const { map, child } = makeMapWithChild(); + const listener = jest.fn(); + map.instance.on('nodeUpdate', listener); + + map.instance.updateNode(property, value, false, true, child); + + expect(listener).not.toHaveBeenCalled(); + expect(read(exported(map, child))).toEqual(value); + }); + }); + + 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, true, true, 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, true, true, child); + + map.instance.updateNode('imageSize', 40, true, true, 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, true, true, 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(); + const root = map.instance.exportRootProperties().id; + + expect(() => + map.instance.updateNode('branchColor', '#00ff00', true, true, root) + ).toThrow(); + }); + + it('refuses a property it does not know', () => { + const { map, child } = makeMapWithChild(); + + expect(() => + map.instance.updateNode('shadow', '1px', true, true, 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], + ['hidden', 'yes'], + ['protected', 1], + ['coordinates', { x: 'left', y: 0 }], + ])('refuses %s %p and keeps the model', (property, value) => { + const { map, child } = makeMapWithChild(); + const before = exported(map, child); + + expect(() => + map.instance.updateNode(property, value, false, false, 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', + true, + true, + child + ); + map.instance.updateNode('imageSrc', IMAGE_REFERENCE, true, true, child); + + map.instance.updateNode('backgroundColor', '', true, true, child); + map.instance.updateNode('linkHref', '', true, true, child); + map.instance.updateNode('imageSrc', '', true, true, child); + + const node = exported(map, child); + expect(node.colors?.background).toBe(''); + expect(node.link?.href).toBe(''); + expect(node.image?.src).toBe(''); + }); +}); + +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' }, + true, + true, + 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 !== 'hidden' && c.property !== 'protected' + ) + )( + 'refuses a local $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, true, true, child); + + expect(read(exported(map, child))).toEqual(before); + expect(refused.map(node => node.id)).toEqual([child]); + } + ); + + it('applies a remote change below it', () => { + const { map, child } = makeProtectedMap(); + + map.instance.updateNode('name', 'Remote', false, true, child); + + expect(exported(map, child).name).toBe('Remote'); + }); + + it('still lets a node below it hide', () => { + const { map, child } = makeProtectedMap(); + + map.instance.updateNode('hidden', true, true, true, child); + + expect(exported(map, child).hidden).toBe(true); + }); + + it('refuses a local child and a local removal', () => { + const { map, parent, child } = makeProtectedMap(); + const refused = jest.fn(); + map.instance.on('nodeProtected', refused); + + expect(map.instance.addNode({ name: 'x' }, true, true, 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 new node, its selection and its removal', () => { + const map = makeMap(); + const created: string[] = []; + const selected: string[] = []; + const deselected: string[] = []; + const removed: string[] = []; + map.instance.on('nodeCreate', node => created.push(node.id)); + map.instance.on('nodeSelect', node => selected.push(node.id)); + map.instance.on('nodeDeselect', node => deselected.push(node.id)); + map.instance.on('nodeRemove', node => removed.push(node.id)); + const root = map.instance.exportRootProperties().id; + + const node = map.instance.addNode({ name: 'a' }); + if (!node) throw new Error('addNode added no node'); + map.instance.updateNode( + 'backgroundColor', + '#ff0000', + false, + false, + node.id + ); + map.instance.selectNode(node.id); + map.instance.removeNode(node.id); + + expect(created).toEqual([node.id]); + expect(selected).toEqual([node.id]); + expect(deselected).toEqual([root, node.id]); + expect(removed).toEqual([node.id]); + }); + + it('hands the previous map to create listeners', () => { + const map = makeMap(); + const previous = map.instance.exportAsJSON(); + const payloads: (MapSnapshot | undefined)[] = []; + map.instance.on('create', event => payloads.push(event.previousMapData)); + + map.instance.new(map.instance.exportAsJSON()); + + expect(payloads).toEqual([previous]); + }); + + it('announces a distribute without a payload', () => { + const { map } = makeMapWithChild(); + const listener = jest.fn(); + map.instance.on('distribute', listener); + + map.instance.distributeNodes(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('refuses an event it does not know', () => { + const map = makeMap(); + + expect(() => + map.instance.on('nodeHover' as 'nodeSelect', jest.fn()) + ).toThrow(); + }); +}); + +describe('destroy', () => { + it('removes the svg, every subscription and the resize listener', () => { + const map = makeMap(); + const ref = map.dom.container.node(); + const listener = jest.fn(); + map.instance.on('nodeSelect', listener); + + map.instance.destroy(); + + expect(ref?.querySelector('svg')).toBeNull(); + expect(d3.select(window).on('resize.map')).toBeUndefined(); + map.events.emit( + 'nodeSelect', + map.nodes.getNodeProperties(map.nodes.getRoot()) + ); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('new with a snapshot', () => { + it('leaves the passed snapshot unchanged when it re-hides a branch', () => { + const { map, child } = makeMapWithChild(); + const root = map.instance.exportRootProperties().id; + map.instance.selectNode(root); + map.instance.toggleBranchVisibility(); + const snapshot = map.instance + .exportAsJSON() + .map(node => ({ ...node, hidden: false, hasHiddenChildNodes: false })); + const copy = JSON.parse(JSON.stringify(snapshot)); + + map.instance.new(deepFreeze(snapshot)); + + expect(snapshot).toEqual(copy); + expect(exported(map, child).hidden).toBe(true); + }); + + it('leaves a legacy snapshot 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 snapshot whose node carries an invalid color', () => { + const map = makeMap(); + const snapshot = map.instance + .exportAsJSON() + .map(node => ({ ...node, colors: { ...node.colors, name: 'red' } })); + + expect(() => map.instance.new(snapshot)).toThrow( + 'The snapshot is not correct' + ); + }); +}); diff --git a/packages/mmp/src/map/models/node-store.spec.ts b/packages/mmp/src/map/models/node-store.spec.ts new file mode 100644 index 00000000..82f631cb --- /dev/null +++ b/packages/mmp/src/map/models/node-store.spec.ts @@ -0,0 +1,103 @@ +import Node from './node.js'; +import NodeStore from './node-store.js'; + +/** root -> a -> b, root -> c (left of root), plus a second tree r2 -> d. */ +function makeStore() { + const store = new NodeStore(); + const root = new Node({ id: 'root', parent: null, isRoot: true, k: 1 }); + const a = new Node({ + id: 'a', + parent: root, + k: 1, + coordinates: { x: 200, y: 0 }, + }); + const b = new Node({ + id: 'b', + parent: a, + k: 1, + coordinates: { x: 400, y: 0 }, + }); + const c = new Node({ + id: 'c', + parent: root, + k: 1, + coordinates: { x: -200, y: 0 }, + }); + const r2 = new Node({ + id: 'r2', + parent: null, + k: 1, + coordinates: { x: 900, y: 0 }, + }); + const d = new Node({ + id: 'd', + parent: r2, + k: 1, + coordinates: { x: 1100, y: 0 }, + }); + [root, a, b, c, r2, d].forEach(node => store.set(node)); + return { store, root, a, b, c, r2, d }; +} + +describe('NodeStore', () => { + it('returns the children of a node', () => { + const { store, root, a, c } = makeStore(); + + expect(store.children(root)).toEqual([a, c]); + }); + + it('returns the descendants of a node, each parent before its children', () => { + const { store, root, a, b, c } = makeStore(); + + expect(store.descendants(root)).toEqual([a, b, c]); + }); + + it('returns the siblings of a node and none for a root', () => { + const { store, root, a, c } = makeStore(); + + expect(store.siblings(a)).toEqual([c]); + expect(store.siblings(root)).toEqual([]); + }); + + it('returns the root of the tree a node belongs to', () => { + const { store, root, b, r2, d } = makeStore(); + + expect(store.treeRoot(b)).toBe(root); + expect(store.treeRoot(d)).toBe(r2); + }); + + it('stops at the node that closes a cycle of ancestors', () => { + const { store, a, b } = makeStore(); + a.parent = b; + + expect([a, b]).toContain(store.treeRoot(b)); + }); + + it('tells whether a node sits left of its tree root', () => { + const { store, root, a, c } = makeStore(); + + expect(store.orientation(c)).toBe(true); + expect(store.orientation(a)).toBe(false); + expect(store.orientation(root)).toBeUndefined(); + }); + + it('names the node carrying the protection of a node', () => { + const { store, a, b, c } = makeStore(); + a.protected = true; + + expect(store.protectingNode(b)).toBe(a); + expect(store.protectingNode(a)).toBe(a); + expect(store.protectingNode(c)).toBeNull(); + }); + + it('removes a node and clears every node', () => { + const { store, b } = makeStore(); + + store.delete(b.id); + expect(store.has(b.id)).toBe(false); + expect(store.get('a')?.id).toBe('a'); + + store.clear(); + expect(store.all()).toEqual([]); + }); +}); diff --git a/packages/mmp/src/map/models/node-store.ts b/packages/mmp/src/map/models/node-store.ts new file mode 100644 index 00000000..9ef39ee3 --- /dev/null +++ b/packages/mmp/src/map/models/node-store.ts @@ -0,0 +1,95 @@ +import Node from './node.js'; + +/** + * Hold the nodes of a map and answer the tree queries over them. The store + * reads and writes the model only and never touches the DOM. + */ +export default class NodeStore { + private nodes = new Map(); + + public get(id: string): Node | undefined { + return this.nodes.get(id); + } + + public has(id: string): boolean { + return this.nodes.has(id); + } + + public set(node: Node) { + this.nodes.set(node.id, node); + } + + public delete(id: string) { + this.nodes.delete(id); + } + + public clear() { + this.nodes.clear(); + } + + public all(): Node[] { + return Array.from(this.nodes.values()); + } + + public children(node: Node): Node[] { + return this.all().filter(n => n.parent?.id === node.id); + } + + /** Every node below `node`, each parent before its own children. */ + public descendants(node: Node): Node[] { + return this.children(node).flatMap(child => [ + child, + ...this.descendants(child), + ]); + } + + /** The other children of the node's parent, none for a root. */ + public siblings(node: Node): Node[] { + const parent = node.parent; + if (!parent) return []; + + return this.children(parent).filter(n => n !== node); + } + + /** + * 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. + */ + public treeRoot(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; + } + + /** + * Whether a node sits left of the root of its own tree. A root has no side + * and returns undefined. + */ + public orientation(node: Node): boolean | undefined { + if (!node.parent) return; + + return node.coordinates.x < this.treeRoot(node).coordinates.x; + } + + /** + * The node carrying the protection of `node`: the node itself or its + * nearest protected ancestor. Null when the node is not protected. + */ + public protectingNode(node: Node): Node | null { + const visited = new Set(); + let current: Node | null = node; + + while (current && !visited.has(current)) { + if (current.protected) return current; + visited.add(current); + current = current.parent; + } + return null; + } +} 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/shared/src/models/index.ts b/packages/shared/src/models/index.ts index 68abfcf2..fe4fb521 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -191,7 +191,7 @@ export type NodePropertyValue = export interface NodeUpdateEvent { nodeProperties: ExportNodeProperties; previousValue: unknown; - changedProperty: NodeProperty | string; + changedProperty: NodeProperty; } export interface MapCreateEvent { diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 2e984817..d45f1783 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) + ), + ]) ), }) ); @@ -77,6 +83,23 @@ export const NodeSchema = v.object({ 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, + hidden: v.boolean(), +} 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..7728ad9d 100644 --- a/packages/shared/src/schemas/schemas.spec.ts +++ b/packages/shared/src/schemas/schemas.spec.ts @@ -12,9 +12,49 @@ 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], + ['hidden', false], + ])('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'], + ['hidden', 1], + ])('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 +80,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 +136,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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 728f4506..28bf2914 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: 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 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..db65582a 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 @@ -7,7 +7,6 @@ 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 { YjsSyncService } from './yjs-sync.service'; import { MapSyncContext } from './map-sync-context'; @@ -85,7 +84,7 @@ describe('MapSyncService', () => { const subscribeMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); mmpService.on.mockReturnValue({ subscribe: subscribeMock, - } as unknown as Observable); + } as unknown as ReturnType); mmpService.getRootNode.mockReturnValue( createMockNode({ id: 'root', name: 'Root', isRoot: true }) 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..eb12c9a2 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 @@ -171,11 +171,7 @@ describe('YjsSyncService presence', () => { [PEER_ID]: { color: '#0000ff', nodeId: '' }, }) ); - expect(mmpService.highlightNode).toHaveBeenCalledWith( - 'branch', - '', - false - ); + expect(mmpService.highlightNode).toHaveBeenCalledWith('branch', ''); }); }); }); 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..0d370c39 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 @@ -499,10 +499,7 @@ export class YjsSyncService { if (!yNode) return; this.doc.transact(() => { - const topLevelKey = - NodePropertyMapping[ - event.changedProperty as keyof typeof NodePropertyMapping - ][0]; + const topLevelKey = NodePropertyMapping[event.changedProperty][0]; const value = event.nodeProperties[topLevelKey as keyof ExportNodeProperties]; yNode.set(topLevelKey, value); @@ -818,7 +815,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/mmp/mmp.service.spec.ts b/teammapper-frontend/src/app/core/services/mmp/mmp.service.spec.ts index d1b98249..a6b1edd3 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 @@ -38,7 +38,7 @@ describe('MmpService', () => { const mockMap = { instance: { unsubscribeAll: jest.fn(), - remove: jest.fn(), + destroy: jest.fn(), new: jest.fn(), zoomIn: jest.fn(), zoomOut: jest.fn(), @@ -179,13 +179,12 @@ describe('MmpService', () => { await service.create('test-id', document.createElement('div')); 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(); }); }); 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..e583e69c 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -13,6 +13,7 @@ import { MapOptions, MapSnapshot, MmpEventPayloadMap, + MmpEventType, NodeProperty, NodePropertyValue, UserNodeProperties, @@ -143,8 +144,7 @@ export class MmpService implements OnDestroy { public remove() { if (!this.currentMap) return; - this.currentMap.instance.unsubscribeAll(); - this.currentMap.instance.remove(); + this.currentMap.instance.destroy(); this.currentMap = null; } @@ -264,14 +264,12 @@ export class MmpService implements OnDestroy { /** * Return the subscribe of the mind mmp event with the node or nothing. */ - public on( + public on( event: K - ): Observable; - public on(event: string): Observable; - public on(event: string): Observable { + ): Observable { return new Observable(observer => { - this.map.instance.on(event, (args: unknown) => { - observer.next(args); + this.map.instance.on(event, payload => { + observer.next(payload); }); }); } @@ -375,12 +373,8 @@ export class MmpService implements OnDestroy { /** * Highlights a node */ - 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.map.instance.highlightNode(nodeId, color); } /** From 985f5070ab087c57fecc40ca6eece95861ca872d Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Sun, 4 Oct 2026 15:06:28 +0200 Subject: [PATCH 04/25] refactor: data-driven d3 renderer in mmp with node parts (#1448) --- docs/glossary.md | 50 +- .../map/handlers/apply-coordinates.spec.ts | 5 +- packages/mmp/src/map/handlers/copy-paste.ts | 15 +- .../src/map/handlers/distribute-nodes.spec.ts | 79 +- packages/mmp/src/map/handlers/drag.ts | 24 +- packages/mmp/src/map/handlers/draw.spec.ts | 139 +++ packages/mmp/src/map/handlers/draw.ts | 974 ++++++++---------- .../src/map/handlers/empty-selection.spec.ts | 33 +- packages/mmp/src/map/handlers/history.ts | 2 +- packages/mmp/src/map/handlers/layout.ts | 23 +- .../src/map/handlers/legacy-detached.spec.ts | 28 +- .../mmp/src/map/handlers/multi-tree.spec.ts | 79 +- .../src/map/handlers/node-geometry.spec.ts | 11 + .../mmp/src/map/handlers/node-geometry.ts | 90 +- .../mmp/src/map/handlers/node-image.spec.ts | 141 ++- .../mmp/src/map/handlers/node-link.spec.ts | 57 - .../mmp/src/map/handlers/node-marks.spec.ts | 95 ++ packages/mmp/src/map/handlers/node-marks.ts | 261 +++++ .../src/map/handlers/node-protection.spec.ts | 59 +- packages/mmp/src/map/handlers/nodes.ts | 113 +- .../map/handlers/paste-coordinates.spec.ts | 3 +- .../src/map/handlers/root-selection.spec.ts | 41 +- .../src/map/handlers/tree-clipboard.spec.ts | 24 +- .../src/map/handlers/tree-placement.spec.ts | 14 +- .../mmp/src/map/handlers/tree-placement.ts | 7 +- packages/mmp/src/map/map.ts | 1 + packages/mmp/src/map/mmp-instance.spec.ts | 15 +- packages/mmp/src/map/models/node.ts | 61 +- packages/mmp/src/test/fake-draw.ts | 34 + packages/mmp/tsconfig.build.json | 2 +- teammapper-frontend/e2e/app.spec.ts | 2 +- teammapper-frontend/e2e/branch-colors.spec.ts | 7 +- teammapper-frontend/e2e/import-export.spec.ts | 3 +- teammapper-frontend/e2e/navigation.spec.ts | 2 +- .../node-after-drag-webkit-linux.png | Bin 36665 -> 35987 bytes .../node-before-drag-webkit-linux.png | Bin 33825 -> 33322 bytes teammapper-frontend/e2e/node-sizes.spec.ts | 95 ++ .../app/core/services/mmp/mmp.service.spec.ts | 10 +- .../src/app/core/services/mmp/mmp.service.ts | 10 +- 39 files changed, 1486 insertions(+), 1123 deletions(-) create mode 100644 packages/mmp/src/map/handlers/draw.spec.ts delete mode 100644 packages/mmp/src/map/handlers/node-link.spec.ts create mode 100644 packages/mmp/src/map/handlers/node-marks.spec.ts create mode 100644 packages/mmp/src/map/handlers/node-marks.ts create mode 100644 packages/mmp/src/test/fake-draw.ts create mode 100644 teammapper-frontend/e2e/node-sizes.spec.ts diff --git a/docs/glossary.md b/docs/glossary.md index 5b34b104..b4fa03eb 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 @@ -147,6 +148,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 +242,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 +287,22 @@ 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 +`hidden`. 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. + +### 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/packages/mmp/src/map/handlers/apply-coordinates.spec.ts b/packages/mmp/src/map/handlers/apply-coordinates.spec.ts index 25c4a1b3..ea1fff79 100644 --- a/packages/mmp/src/map/handlers/apply-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/apply-coordinates.spec.ts @@ -1,5 +1,6 @@ import Nodes from './nodes.js'; import MmpMap from '../map.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import type { MapNodeCoordinates, ExportNodeProperties, @@ -13,8 +14,8 @@ import type { 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/copy-paste.ts b/packages/mmp/src/map/handlers/copy-paste.ts index 9a0fc5ae..b64837ed 100644 --- a/packages/mmp/src/map/handlers/copy-paste.ts +++ b/packages/mmp/src/map/handlers/copy-paste.ts @@ -8,7 +8,7 @@ import type { import Log from '../../utils/log.js'; import Utils from '../../utils/utils.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 }; @@ -94,12 +94,9 @@ export default class CopyPaste { private footprintOf(nodes: Node[], origin: MapNodeCoordinates): Bounds { 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); @@ -185,10 +182,8 @@ export default class CopyPaste { newParentNode: Node | null, newNodes: Node[] ): Node { - const createdNode = this.map.nodes.addNode( + const createdNode = this.map.nodes.insertNode( this.pastedProperties(nodeProperties, newParentNode), - false, - false, newParentNode?.id ?? null ); newNodes.push(createdNode); diff --git a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts index aa18f0d4..b7dce097 100644 --- a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts +++ b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts @@ -1,49 +1,20 @@ -import * as d3 from 'd3'; import Nodes from './nodes.js'; -import Draw from './draw.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import Node from '../models/node.js'; import MmpMap from '../map.js'; -interface StubMap { - id: string; - draw: { - update: jest.Mock; - drawBranch: jest.Mock; - redrawBranches: () => void; - map: { id: string }; - }; - history: { save: jest.Mock }; - events: { emit: jest.Mock }; -} +type StubMap = ReturnType; -function stubMap(): StubMap { +function 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)), - // The real redraw, reading the map id and drawBranch from this stub. - redrawBranches: Draw.prototype.redrawBranches, - map: { id: 'test-map' }, - }, + // Every node measured at 100 by 30. + draw: fakeDraw(() => ({ width: 100, height: 30 })), history: { save: jest.fn() }, events: { emit: 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`); -} - /** Only the fields distribution reads, hence the cast. */ function liveNode( id: string, @@ -57,7 +28,6 @@ function liveNode( isRoot: false, hidden: false, coordinates: { x: 0, y: 0 }, - dimensions: { width: 100, height: 30 }, ...overrides, } as unknown as Node; } @@ -86,10 +56,6 @@ function aiShapedNodes(): Node[] { } describe('distributeNodes', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - it('writes new coordinates onto the live nodes', () => { const nodes = aiShapedNodes(); const { handler } = handlerWith(nodes); @@ -124,22 +90,6 @@ describe('distributeNodes', () => { 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()); @@ -174,25 +124,6 @@ describe('distributeNodes', () => { expect(events).not.toContain('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(); - }); - it('leaves an empty map alone', () => { const { handler, map } = handlerWith([]); diff --git a/packages/mmp/src/map/handlers/drag.ts b/packages/mmp/src/map/handlers/drag.ts index 9e9291c7..e0d2403e 100644 --- a/packages/mmp/src/map/handlers/drag.ts +++ b/packages/mmp/src/map/handlers/drag.ts @@ -76,22 +76,12 @@ export default class Drag { 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] + ')'); + node.coordinates.x += dx; + node.coordinates.y += dy; this.moveDescendants(node, dx, dy); - // Update all mind map branches - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => { - return this.map.draw.drawBranch(node)?.toString() ?? null; - } - ); + this.map.draw.renderPositions([node, ...this.descendants]); // This is here and not in the started function because started function // is also executed when there is no drag events @@ -108,14 +98,12 @@ export default class Drag { orientationIsChanged = newOrientation !== this.orientation; for (const node of this.descendants) { - let x = (node.coordinates.x += dx); - const y = (node.coordinates.y += dy); + node.coordinates.x += dx; + node.coordinates.y += dy; if (orientationIsChanged) { - x = node.coordinates.x += (root.coordinates.x - node.coordinates.x) * 2; + node.coordinates.x += (root.coordinates.x - node.coordinates.x) * 2; } - - node.dom.setAttribute('transform', 'translate(' + [x, y] + ')'); } if (orientationIsChanged) { 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..56267cad --- /dev/null +++ b/packages/mmp/src/map/handlers/draw.spec.ts @@ -0,0 +1,139 @@ +import { create } from '../../index.js'; +import MmpMap from '../map.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; +} + +function makeMap(): MmpMap { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref); + // jsdom lays nothing out; d3-zoom reads the extent from the view box. + map.dom.svg.attr('viewBox', '0 0 800 600'); + map.instance.new(); + return map; +} + +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 = map.nodes.getRoot(); + + 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 = map.nodes.getRoot(); + map.draw.enableNodeNameEditing(root); + + map.draw.clear(); + map.draw.update(); + + expect(rootName(map).innerHTML).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('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 4cacac32..78b1ea60 100644 --- a/packages/mmp/src/map/handlers/draw.ts +++ b/packages/mmp/src/map/handlers/draw.ts @@ -1,38 +1,87 @@ import * as d3 from 'd3'; -import { Path } from 'd3'; import DOMPurify from 'dompurify'; import { isImageDataUrl, isImageReference, - isSafeLinkHref, + type MapNodeDimensions, + type MapNodeFont, type NodeProperty, } from '@teammapper/shared'; import type { NodeView } from './node-view.js'; -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 { 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; +} /** - * Draw the map and update it. + * Draws the mind map and redraws a node when it changes. A node gets as big + * as its name, so the renderer measures each name after drawing it. Sizes and + * the selection ring belong to the screen only and never reach the saved map. */ export default class Draw implements NodeView { - 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(); + 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; } /** @@ -62,133 +111,45 @@ export default class Draw implements NodeView { 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. + * Redraw the whole map: draw new nodes, remove deleted ones and redraw the + * rest. A hidden node stays drawn but invisible. */ 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 + ')' + const groups = this.nodeGroups() + .data(nodes, node => node.id) + .join( + enter => this.enterNodes(enter), + update => update, + exit => this.exitNodes(exit) + ); + const branches = this.branchPaths() + .data( + nodes.filter(node => node.parent !== null), + node => node.id ) - .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 } + .join(enter => + enter.append('path').attr('class', 'branch') ); - if (this.map.options.drag === true) { - outer.call(this.map.drag.getDragBehavior()); - } else { - outer.on('mousedown', (node: Node) => { - this.map.nodes.selectNode(node.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) => 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) => node.colors.branch) - .style('stroke', (node: Node) => 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); + // Forget the sizes, rings and images of deleted nodes. + const ids = new Set(nodes.map(node => node.id)); + for (const state of [this.textExtents, this.rings, this.images]) { + for (const id of state.keys()) if (!ids.has(id)) state.delete(id); + } - dom.nodes.exit().remove(); - dom.branches.exit().remove(); + this.render(groups, branches); } /** @@ -197,373 +158,106 @@ export default class Draw implements NodeView { * @param {NodeProperty} property */ public renderNodeProperty(node: Node, property: NodeProperty) { - switch (property) { - case 'name': - node.getNameDOM().innerHTML = DOMPurify.sanitize(node.name); - this.updateNodeShapes(node); - break; - case 'coordinates': - node.dom.setAttribute( - 'transform', - 'translate(' + [node.coordinates.x, node.coordinates.y] + ')' - ); - this.redrawBranches(); - break; - case 'imageSrc': - this.setImage(node); - break; - case 'imageSize': - this.resizeImage(node); - break; - case 'linkHref': - this.setLink(node); - break; - case 'backgroundColor': - this.paintBackground(node); - break; - case 'branchColor': { - const branch = document.getElementById(node.id + '_branch'); - if (branch) - branch.style.fill = branch.style.stroke = node.colors.branch; - break; - } - case 'nameColor': - node.getNameDOM().style.color = node.colors.name; - this.updateProtectionIcon(node); - break; - case 'fontSize': - node.getNameDOM().style.fontSize = node.font.size + 'px'; - this.updateNodeShapes(node); - break; - case 'fontWeight': - node.getNameDOM().style.fontWeight = node.font.weight; - this.updateNodeShapes(node); - break; - case 'fontStyle': - node.getNameDOM().style.fontStyle = node.font.style; - break; - case 'protected': - this.updateProtectionIcon(node); - break; - case 'hidden': - // The caller hides and shows nodes with a full update. - break; - } - } - - /** - * Redraw the branch of every node. - */ - public redrawBranches() { - d3.selectAll('.' + this.map.id + '_branch').attr( - 'd', - (node: Node) => this.drawBranch(node)?.toString() ?? null - ); - } + // The caller hides and shows nodes with a full update. + if (property === 'hidden') return; - /** - * Fill the node background and darken the selection ring along with it. - * @param {Node} node - */ - private paintBackground(node: Node) { - const background = node.getBackgroundDOM(); - background.style.fill = node.colors.background; - - if (background.style.stroke !== '') { - const darker = d3.color(node.colors.background)?.darker(0.5); - if (darker) background.style.stroke = darker.toString(); + // The selection ring darkens along with the background. + if (property === 'backgroundColor' && this.rings.has(node.id)) { + this.setRing(node, this.ringColor(node)); } + this.render(this.nodeGroupsOf([node]), this.branchPathsOf([node])); } /** - * Scale the node image to its size. An image that failed to load has no - * element, and its size waits in the model. - * @param {Node} node + * Move the drawn nodes to their coordinates, along with their branches and + * those of their children. + * @param {Node[]} nodes */ - private resizeImage(node: Node) { - const image = node.dom.querySelector('image'); - if (!image) return; - - const box = image.getBBox(), - height = node.image.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()); + public renderPositions(nodes: Node[]) { + this.nodeGroupsOf(nodes).attr('transform', translate); + this.branchPathsOf(nodes).attr('d', node => this.branchShape(node)); } /** * Remove all nodes and branches of the map. */ public clear() { - d3.selectAll( - '.' + this.map.id + '_node, .' + this.map.id + '_branch' - ).remove(); + this.nodeGroups().call(groups => this.exitNodes(groups)); + this.branchPaths().remove(); + this.rings.clear(); } /** - * Draw the background shape of the node. - * @param {Node} node - * @returns {Path} path + * Stop watching names for size changes. The map calls this on removal. */ - 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(); - - return path; + public destroy() { + this.resizeObserver?.disconnect(); } /** - * Draw the branch of the node. + * The size of the node's box: its name plus padding. Before the name is + * drawn, a canvas estimates its size. * @param {Node} node - * @returns {Path | null} path, or null for a node without a parent */ - 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(); - - return path; - } + public dimensionsOf = (node: Node): MapNodeDimensions => + withPadding(this.textExtentOf(node)); /** - * Update the node HTML elements. - * @param {Node} node + * The box size a name will get, estimated before the node is drawn. The + * layout uses it to place the nodes of an imported map. */ - public updateNodeShapes(node: Node) { - const background = node.getBackgroundDOM(); - - d3.select(background).attr('d', (node: Node) => - this.drawNodeBackground(node).toString() - ); - this.redrawBranches(); - - this.updateImagePosition(node); - this.updateLinkPosition(node); - this.updateProtectionIcon(node); - - this.updateNodeNameContainer(node); - } + public estimateExtent = ( + name: string, + font: MapNodeFont + ): MapNodeDimensions => + measureNodeExtent(name, { ...font, family: this.map.options.fontFamily }); /** - * Set main properties of node image and create it if it does not exist. + * The color of the ring around the node, null for none. * @param {Node} node */ - 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; - - 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)'); - }; - - // 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(); - } - } - - /** - * 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 imageUrlOf(src: string): string | null { - if (isImageReference(src)) { - return this.map.options.resolveImageUrl?.(src) ?? null; - } - return isImageDataUrl(src) ? src : null; + public ringOf(node: Node): string | null { + return this.rings.get(node.id) ?? null; } /** - * Set main properties of node image and create it if it does not exist. + * Draw a ring in `color` around the node, or none for null or ''. * @param {Node} node + * @param {string | null} color */ - 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'); - } + public setRing(node: Node, color: string | null) { + if (color) this.rings.set(node.id, color); + else this.rings.delete(node.id); - if (domText) { - // Set the correct styling of the link - this.updateLinkStyle(domText, 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(); - } - } - - /** - * Set a hidden eye icon if child nodes are hidden. - * @param {Node} node - */ - 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', node.colors.name); - domIcon.setAttribute('y', (-node.dimensions.height + 30).toString()); - domIcon.setAttribute('x', '-60'); - node.dom.appendChild(domIcon); - } + this.nodeGroupsOf([node]) + .selectChildren('path.background') + .style('stroke', () => color || null); } /** - * Explicitly remove the hidden eye icon even if not set + * The ring color of a node: its background color, darkened. Null when the + * background holds no color. * @param {Node} node */ - public removeHiddenChildrenIcon(node: Node) { - const domIcon = node.getHiddenChildIconDOM(); - if (domIcon) { - domIcon.remove(); - } + public ringColor(node: Node): string | null { + return d3.color(node.colors.background)?.darker(0.5).toString() ?? 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. + * True while the person edits the name of the node. * @param {Node} 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', node.colors.name); - icon.setAttribute('y', (-node.dimensions.height + 30).toString()); - icon.setAttribute('x', (node.dimensions.width / 2).toString()); + public isEditing(node: Node): boolean { + const name = this.nameOf(node); + return name !== null && name.ownerDocument.activeElement === name; } /** - * Update the node image position. + * Take the focus from the name of the node, which ends its editing. * @param {Node} node */ - 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()); - } - - /** - * Update the node link position. - * @param {Node} node - */ - public updateLinkPosition(node: Node) { - if (isSafeLinkHref(node.link.href)) { - const link = node.getLinkDOM(), - y = node.dimensions.height; - link.setAttribute('y', y.toString()); - } + public blurName(node: Node) { + this.nameOf(node)?.blur(); } /** @@ -573,8 +267,10 @@ export default class Draw implements NodeView { public enableNodeNameEditing(node: Node) { if (this.map.nodes.refusesLocalChange(node)) return; - this.editing = true; - const name = node.getNameDOM(); + const name = this.nameOf(node); + if (!name) return; + + this.editingId = node.id; name.setAttribute('contenteditable', 'true'); name.innerHTML = DOMPurify.sanitize(node.name); @@ -582,16 +278,10 @@ export default class Draw implements NodeView { 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. @@ -638,153 +328,325 @@ export default class Draw implements NodeView { }; 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(); + if (name.innerHTML !== node.name) { + this.map.nodes.updateNode('name', DOMPurify.sanitize(name.innerHTML)); + } + // Draw node.name back, so the DOM drops the typed text when a peer + // protected the branch during the edit and updateNode refused it. + this.render(this.nodeGroupsOf([node]), this.branchPathsOf([node])); }; } /** - * Check if given node has hidden children and render the eye icon if so - * @param {Node} node + * 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 updateHiddenChildrenIcon(node: Node) { - if (node.hasHiddenChildNodes) { - this.setHiddenChildrenIcon(node); - } else { - this.removeHiddenChildrenIcon(node); - } + private render(groups: NodeGroups, branches: BranchPaths) { + const context = this.markContext(); + + groups + .attr('transform', translate) + .style('visibility', node => (node.hidden ? 'hidden' : 'visible')); + NODE_MARKS.forEach(mark => mark.draw(groups, context)); + this.observe(groups); + branches + .style('fill', node => node.colors.branch) + .style('stroke', node => node.colors.branch) + .style('visibility', node => (node.hidden ? 'hidden' : 'visible')); + + this.measure(groups); + + this.finish(groups, branches, context); } /** - * Update node name container (foreign object) dimensions. - * @param {Node} node + * Resize the nodes whose names changed size. + * @param {Node[]} nodes */ - 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)); - - foreignObject.setAttribute('x', (-width / 2).toString()); - foreignObject.setAttribute('y', (-height / 2).toString()); - foreignObject.setAttribute('width', width.toString()); - foreignObject.setAttribute('height', height.toString()); + private resize(nodes: Node[]) { + const changed = this.measure(this.nodeGroupsOf(nodes)); + if (changed.length === 0) return; + + this.finish( + this.nodeGroupsOf(changed), + this.branchPathsOf(changed), + this.markContext() + ); } /** - * Create a string with HTML of the node name div. - * @param {Node} node - * @returns {string} html + * Read and store the size of each drawn name. + * @returns {Node[]} the nodes whose size changed */ - private createNodeNameDOM(node: Node) { - const div = document.createElement('div'); + private measure(groups: NodeGroups): Node[] { + const changed: Node[] = []; + + nameElements(groups).each((node, 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(node.id); + if (known?.width === extent.width && known.height === extent.height) { + return; + } + this.textExtents.set(node.id, extent); + changed.push(node); + }); - div.style.setProperty('font-size', node.font.size + 'px'); - div.style.setProperty('color', node.colors.name); - div.style.setProperty('font-style', node.font.style); - div.style.setProperty('font-weight', node.font.weight); + return changed; + } - 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'); + /** Draw everything that depends on the size of the nodes. */ + private finish( + groups: NodeGroups, + branches: BranchPaths, + context: MarkContext + ) { + NODE_MARKS.forEach(mark => mark.finish(groups, context)); + branches.attr('d', node => this.branchShape(node)); + } - div.innerHTML = DOMPurify.sanitize(node.name); + private markContext(): MarkContext { + return { + textExtentOf: node => this.textExtentOf(node), + dimensionsOf: this.dimensionsOf, + ringOf: node => this.ringOf(node), + imageOf: node => this.imageOf(node), + isEditing: node => this.editingId === node.id, + fontFamily: this.map.options.fontFamily, + showLinktext: this.map.options.showLinktext, + }; + } - return div.outerHTML; + private textExtentOf(node: Node): MapNodeDimensions { + return ( + this.textExtents.get(node.id) ?? + measureTextExtent(node.name, { + ...node.font, + family: this.map.options.fontFamily, + }) + ); } /** - * Checks if the target of the event is the link below the node - * @param {TouchEvent} event - * @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 {Node} node */ - private isLinkTarget(event: TouchEvent): boolean { - return (event.target as Element).classList[0] === 'link-text'; + private imageOf(node: Node): LoadedImage | null { + const src = node.image.src; + const known = this.images.get(node.id); + if (known?.src === src) { + return known.ratio === null + ? null + : { url: known.url, ratio: known.ratio }; + } + + this.images.delete(node.id); + const url = this.imageUrlOf(src); + if (url === null) return null; + + const load: ImageLoad = { src, url, ratio: null }; + this.images.set(node.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(node.id) !== load) return; + // A failed image stays hidden and keeps its value: clearing it would + // erase the image in the Y.Doc for every client on a network error. + load.ratio = ratio; + this.render(this.nodeGroupsOf([node]), this.branchPathsOf([])); + }; + image.onload = () => settle(image.width / image.height); + image.onerror = () => settle(null); + + return null; } /** - * Checks if the browser is firefox - * @returns {boolean} + * 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 browserIsFirefox(): boolean { - return navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + private imageUrlOf(src: string): string | null { + if (isImageReference(src)) { + return this.map.options.resolveImageUrl?.(src) ?? null; + } + return isImageDataUrl(src) ? src : null; } /** - * Truncates Text to a maximum Length - * @param text - * @param maxLength + * The shape of the branch from the node's parent to the node, null for a + * node without a parent. + * @param {Node} node */ - private truncateText(text: string, maxLength = 50): string { - if (text.length <= maxLength) return text; - return text.slice(0, maxLength - 3) + '...'; + private branchShape(node: Node): string | null { + if (node.parent === null) return null; + + const parent = node.parent, + { width: nodeWidth, height: nodeHeight } = this.dimensionsOf(node), + 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 + nodeHeight / 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 + nodeHeight / 2 - width / 3, + node.coordinates.x - (nodeWidth / 3) * orx, + node.coordinates.y + nodeHeight / 2 + 3 + ); + path.bezierCurveTo( + parent.coordinates.x + (width / 2) * inv, + node.coordinates.y + nodeHeight / 2 + width / 3, + mx + width * inv, + parent.coordinates.y + width / 2, + parent.coordinates.x, + parent.coordinates.y + width * 0.8 + ); + path.closePath(); + + return path.toString(); } - /** - * Set linktext or link icon based on options - * @param domText The dom element for the linktext - * @param node The node that should be modified - */ - 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 nodeGroups(): NodeGroups { + return this.layers.nodes.selectChildren('g.node'); + } + + private branchPaths(): BranchPaths { + return this.layers.branches.selectChildren( + 'path.branch' + ); + } + + private nodeGroupsOf(nodes: Node[]): NodeGroups { + const ids = new Set(nodes.map(node => node.id)); + return this.nodeGroups().filter(node => ids.has(node.id)); + } + + /** The branches of the nodes and of their children. */ + private branchPathsOf(nodes: Node[]): BranchPaths { + const ids = new Set(nodes.map(node => node.id)); + return this.branchPaths().filter( + node => + ids.has(node.id) || (node.parent !== null && ids.has(node.parent.id)) + ); + } + + private nameOf(node: Node): HTMLDivElement | null { + return nameElements(this.nodeGroupsOf([node])).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, 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; + // 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(node); + }, + { 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, node: Node) => { + this.map.nodes.selectNode(node.id); + }); } - domText.style.setProperty('fill', node.colors.link); - domText.setAttribute('y', node.dimensions.height.toString()); - domText.setAttribute('text-anchor', 'middle'); + + 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((_node, i, names) => { + if (this.observed.has(names[i])) return; + this.observed.add(names[i]); + this.resizeObserver?.observe(names[i]); + }); + } + + private exitNodes(exit: NodeGroups) { + nameElements(exit).each((node, i, names) => { + this.resizeObserver?.unobserve(names[i]); + // Removing a focused name fires no blur in Firefox and WebKit, so the + // edit ends here. + if (node.id === this.editingId) this.editingId = null; + }); + exit.remove(); + } + + /** + * 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(node: Node): string { + return 'translate(' + node.coordinates.x + ',' + node.coordinates.y + ')'; } diff --git a/packages/mmp/src/map/handlers/empty-selection.spec.ts b/packages/mmp/src/map/handlers/empty-selection.spec.ts index e4f8ef0c..2a8dc523 100644 --- a/packages/mmp/src/map/handlers/empty-selection.spec.ts +++ b/packages/mmp/src/map/handlers/empty-selection.spec.ts @@ -1,4 +1,5 @@ import Nodes from './nodes.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import CopyPaste from './copy-paste.js'; import Node, { NodeProperties } from '../models/node.js'; import { DefaultNodeValues } from '../options.js'; @@ -10,23 +11,13 @@ import MmpMap from '../map.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; + return new Node({ + k: 1, + parent: null, + colors: { background: '#ffffff' }, + ...properties, + }); } function makeMap() { @@ -35,11 +26,7 @@ function makeMap() { const map = { rootId: 'root', options: { defaultNode: DefaultNodeValues }, - draw: { - update: jest.fn(), - clear: jest.fn(), - renderNodeProperty: jest.fn(), - }, + draw: fakeDraw(), events, history, } as unknown as MmpMap; @@ -81,7 +68,7 @@ describe('deselectNode', () => { }); it('tells listeners which node lost the selection', () => { - const { handler, events, nodes } = makeMap(); + const { map, handler, events, nodes } = makeMap(); handler.selectNode(nodes.branch.id); events.emit.mockClear(); @@ -91,7 +78,7 @@ describe('deselectNode', () => { 'nodeDeselect', expect.objectContaining({ id: nodes.branch.id }) ); - expect(nodes.branch.getBackgroundDOM().style.stroke).toBe(''); + expect(map.draw.ringOf(nodes.branch)).toBeNull(); }); it('fires nothing when nothing is selected', () => { diff --git a/packages/mmp/src/map/handlers/history.ts b/packages/mmp/src/map/handlers/history.ts index cf04d2ca..9927c979 100644 --- a/packages/mmp/src/map/handlers/history.ts +++ b/packages/mmp/src/map/handlers/history.ts @@ -18,7 +18,7 @@ import type { /** * A node of a snapshot the map accepts. Older maps carry no link and no - * main-root mark. + * isRoot attribute. */ const SnapshotNodeSchema = v.object({ ...NodeSchema.entries, 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 f46d90be..f943b833 100644 --- a/packages/mmp/src/map/handlers/legacy-detached.spec.ts +++ b/packages/mmp/src/map/handlers/legacy-detached.spec.ts @@ -1,4 +1,5 @@ import Nodes from './nodes.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import History from './history.js'; import MmpMap from '../map.js'; import { DefaultNodeValues, DefaultRootNodeValues } from '../options.js'; @@ -38,33 +39,12 @@ 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; -} - -/** - * A map stub around the real node handler and history. Its draw gives each - * node the DOM that selection reads. - */ +/** A map stub around the real node handler and history. */ 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 }, + draw: fakeDraw(), zoom: { center: jest.fn() }, events: { emit: jest.fn() }, export: { asJSON: () => [] }, @@ -115,7 +95,7 @@ describe('a snapshot that still carries the detached key', () => { expect(pasted?.parent?.id).toBe('note'); }); - it('keeps the main root as the only node with the main-root mark', () => { + it('keeps the main root as the only node with isRoot set', () => { const map = loadLegacySnapshot(); expect( diff --git a/packages/mmp/src/map/handlers/multi-tree.spec.ts b/packages/mmp/src/map/handlers/multi-tree.spec.ts index 0f5b748e..7f3c2d5d 100644 --- a/packages/mmp/src/map/handlers/multi-tree.spec.ts +++ b/packages/mmp/src/map/handlers/multi-tree.spec.ts @@ -1,4 +1,5 @@ import Nodes from './nodes.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import Node, { NodeProperties } from '../models/node.js'; import { DefaultNodeValues } from '../options.js'; import MmpMap from '../map.js'; @@ -6,15 +7,17 @@ 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 { + ExportNodeProperties, + MapNodeDimensions, +} 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 { @@ -52,13 +55,17 @@ function makeMap(view: Bounds | null = null): { nodes: Record; history: { save: jest.Mock }; zoom: { visibleArea: jest.Mock; panIntoView: jest.Mock }; + sizes: Map; + draw: ReturnType; } { + const sizes = new Map(); const history = { save: jest.fn() }; const zoom = { visibleArea: jest.fn(() => view), panIntoView: jest.fn() }; + const draw = fakeDraw(node => sizes.get(node.id) ?? { width: 0, height: 0 }); const map = { rootId: 'root', options: { defaultNode: DefaultNodeValues }, - draw: { update: jest.fn(), renderNodeProperty: jest.fn() }, + draw, events: { emit: jest.fn() }, history, zoom, @@ -83,7 +90,7 @@ function makeMap(view: Bounds | null = null): { for (const node of Object.values(nodes)) handler.store.set(node); internals.selectedNode = branch; - return { handler, internals, nodes, history, zoom }; + return { handler, internals, nodes, history, zoom, sizes, draw }; } describe('addNodes', () => { @@ -113,6 +120,20 @@ describe('addNodes', () => { expect(handler.getNode('grandchild')?.parent?.id).toBe('child'); expect(handler.getChildren(nodes.branch)).toEqual([]); }); + + it('draws the map once for all added nodes', () => { + const { handler, draw } = makeMap(); + + handler.addNodes( + [ + exported('third-root', '', { x: 2000, y: 0 }), + exported('child', 'third-root', { x: 1800, y: -120 }), + ], + false + ); + + expect(draw.update).toHaveBeenCalledTimes(1); + }); }); describe('addNode', () => { @@ -154,15 +175,19 @@ describe('addNode', () => { }); describe('newTreeCoordinates', () => { - function sizeNodes(nodes: Record, width: number): void { + function sizeNodes( + sizes: Map, + nodes: Record, + width: number + ): void { for (const node of Object.values(nodes)) { - node.dimensions = { width, height: 30 }; + sizes.set(node.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 { handler, nodes, sizes } = makeMap(); + sizeNodes(sizes, nodes, 120); const rightEdge = nodes.secondRoot.coordinates.x + 60; expect(handler.newTreeCoordinates().x).toBe( @@ -171,8 +196,8 @@ describe('newTreeCoordinates', () => { }); it("keeps the new root's first child clear of the other trees", () => { - const { handler, nodes } = makeMap(); - sizeNodes(nodes, 120); + const { handler, nodes, sizes } = makeMap(); + sizeNodes(sizes, nodes, 120); const rightEdge = nodes.secondRoot.coordinates.x + 60; const root = handler.addNode( { coordinates: handler.newTreeCoordinates() }, @@ -182,17 +207,17 @@ describe('newTreeCoordinates', () => { ); const child = handler.addNode({}, false, false, root.id); - child.dimensions = { width: 120, height: 30 }; + sizes.set(child.id, { width: 120, height: 30 }); 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); + const { handler, nodes, sizes } = makeMap(); + sizeNodes(sizes, nodes, 100); nodes.branch.coordinates = { x: 950, y: 0 }; - nodes.branch.dimensions = { width: 400, height: 30 }; + sizes.set(nodes.branch.id, { width: 400, height: 30 }); expect(handler.newTreeCoordinates().x).toBe( 1150 + 2 * NODE_HORIZONTAL_SPACING @@ -227,8 +252,10 @@ 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 trees = treeBounds( + handler.getNodes(), + node => handler.getTreeRoot(node), + handler.boundsOf ); return trees.some( @@ -252,13 +279,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, nodes, sizes } = makeMap({ minX: 600, maxX: 1400, minY: -300, maxY: 300, }); - nodes.secondRoot.dimensions = { width: 120, height: 60 }; + sizes.set(nodes.secondRoot.id, { width: 120, height: 60 }); const coordinates = handler.newTreeCoordinates(); @@ -271,8 +298,8 @@ 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 }; + const { handler, nodes, zoom, sizes } = makeMap(view); + sizes.set(nodes.secondRoot.id, { width: 2000, height: 1000 }); handler.selectNode = jest.fn(); const { x, y } = handler.newTreeCoordinates(); @@ -284,12 +311,12 @@ describe('newTreeCoordinates with a viewport', () => { 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(root)); }); }); 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(); @@ -311,7 +338,7 @@ describe('addTree', () => { const root = handler.addTree(); expect(selectNode).toHaveBeenCalledWith(root.id); - expect(zoom.panIntoView).toHaveBeenCalledWith(nodeBounds(root)); + expect(zoom.panIntoView).toHaveBeenCalledWith(handler.boundsOf(root)); }); }); 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 147790a2..842a9453 100644 --- a/packages/mmp/src/map/handlers/node-image.spec.ts +++ b/packages/mmp/src/map/handlers/node-image.spec.ts @@ -1,7 +1,5 @@ -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'; /** @@ -11,7 +9,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,33 +40,36 @@ 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: { emit: 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 map = create('map', ref, { resolveImageUrl }); + // jsdom lays nothing out; d3-zoom reads the extent from the view box. + map.dom.svg.attr('viewBox', '0 0 800 600'); + map.instance.new([ + { + ...DefaultRootNodeValues, + id: 'root', + parent: null, + k: 1, + isRoot: true, + image: { src, size: 60 }, + }, + ]); + return map; } -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, false, false, 'root'); } +const drawnImage = (map: MmpMap) => + map.dom.g.node()?.querySelector('image') ?? null; + describe('node images', () => { const originalImage = globalThis.Image; @@ -80,32 +80,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 +108,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); - 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, false, false, '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', false, false, '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 10cfd454..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: { emit: 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..1ae667ef --- /dev/null +++ b/packages/mmp/src/map/handlers/node-marks.spec.ts @@ -0,0 +1,95 @@ +import * as d3 from 'd3'; +import type { MapNodeDimensions } from '@teammapper/shared'; +import Node, { NodeProperties } from '../models/node.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. + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function makeNode(properties: Partial = {}): Node { + return new Node({ id: 'node', parent: null, k: 1, ...properties }); +} + +/** Draw and finish every mark of the node at the given size. */ +function drawMarks( + node: Node, + dimensions: MapNodeDimensions = { width: 100, height: 40 }, + group = document.createElementNS(SVG_NS, 'g') +): SVGGElement { + const context: MarkContext = { + textExtentOf: () => ({ width: 60, height: 20 }), + dimensionsOf: () => dimensions, + ringOf: () => null, + imageOf: () => null, + isEditing: () => false, + fontFamily: 'sans-serif', + showLinktext: false, + }; + const groups = d3.select(group).datum(node); + + 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 flag 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 node = makeNode({ protected: true }); + const group = drawMarks(node); + + node.colors.name = '#ff0000'; + drawMarks(node, { 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 node = makeNode({ protected: true }); + const group = drawMarks(node); + + node.protected = false; + drawMarks(node, 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..e49804b9 --- /dev/null +++ b/packages/mmp/src/map/handlers/node-marks.ts @@ -0,0 +1,261 @@ +import * as d3 from 'd3'; +import DOMPurify from 'dompurify'; +import { isSafeLinkHref, type MapNodeDimensions } from '@teammapper/shared'; +import type Node from '../models/node.js'; + +/** The drawn nodes, each carrying its `Node`. */ +export type NodeGroups = d3.Selection; + +/** 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. Marks never measure the page. */ +export interface MarkContext { + /** The size of the node's name. */ + textExtentOf(node: Node): MapNodeDimensions; + /** The size of the node's box: the name and its padding. */ + dimensionsOf(node: Node): MapNodeDimensions; + /** The color of the ring around the node, null for none. */ + ringOf(node: Node): string | null; + /** The node's image once it has loaded, else null. */ + imageOf(node: Node): LoadedImage | null; + /** True while the person edits the node's name. */ + isEditing(node: Node): 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: (node: Node) => boolean = () => true +) { + return groups + .selectChildren(`${tag}.${classes.split(' ')[0]}`) + .data(node => (shown(node) ? [node] : [])) + .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', node => node.colors.background) + .style('stroke-width', 3) + .style('stroke', node => context.ringOf(node)); + }, + finish(groups, context) { + select(groups, 'path.background').attr('d', node => + backgroundShape(node, context.dimensionsOf(node)) + ); + }, +}; + +function backgroundShape(node: Node, { 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(node => [node]) + .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', node => node.font.size + 'px') + .style('font-style', node => node.font.style) + .style('font-weight', node => node.font.weight) + .style('color', node => node.colors.name) + .each((node, i, divs) => { + // Keep the text the person is typing. + if (context.isEditing(node)) return; + const html = DOMPurify.sanitize(node.name); + if (divs[i].innerHTML !== html) divs[i].innerHTML = html; + }); + }, + finish(groups, context) { + select(groups, 'foreignObject.name').each( + (node, i, objects) => { + const { width, height } = context.textExtentOf(node); + 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', + node => context.imageOf(node) !== null + ) + .attr('href', node => context.imageOf(node)?.url ?? null) + .attr('clip-path', 'inset(0% round 15px)'); + }, + finish(groups, context) { + select(groups, 'image.image').each((node, i, images) => { + const height = node.image.size, + width = height * (context.imageOf(node)?.ratio ?? 1); + d3.select(images[i]) + .attr('height', height) + .attr('width', width) + .attr('x', -width / 2) + .attr('y', -(height + context.dimensionsOf(node).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', node => + isSafeLinkHref(node.link.href) + ) + .attr('href', node => node.link.href) + .attr('target', '_self') + .selectChildren('text') + .data(node => [node]) + .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(node => (context.showLinktext ? linkText(node.link.href) : 'link')) + .style('text-decoration', () => + context.showLinktext ? 'underline' : null + ) + .style('font-style', () => (context.showLinktext ? 'italic' : null)) + .style('fill', node => node.colors.link) + .attr('text-anchor', 'middle'); + }, + finish(groups, context) { + select(groups, 'a.link') + .selectChildren('text') + .attr('y', node => context.dimensionsOf(node).height); + }, +}; + +/** + * A material icon above the node, where `x` places it from the node's width. + */ +function icon( + className: string, + glyph: string, + shown: (node: Node) => boolean, + x: (width: number) => number +): NodeMark { + return { + draw(groups) { + join(groups, 'text', `${className} material-icons`, shown) + .text(glyph) + .style('fill', node => node.colors.name); + }, + finish(groups, context) { + select(groups, 'text.' + className).each( + (node, i, icons) => { + const { width, height } = context.dimensionsOf(node); + 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', + node => node.hasHiddenChildNodes, + () => -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', + node => node.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 a2793a10..7cbc1427 100644 --- a/packages/mmp/src/map/handlers/node-protection.spec.ts +++ b/packages/mmp/src/map/handlers/node-protection.spec.ts @@ -2,6 +2,7 @@ import CopyPaste from './copy-paste.js'; import Drag from './drag.js'; import Draw from './draw.js'; import Nodes from './nodes.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import MmpMap from '../map.js'; import Node, { NodeProperties } from '../models/node.js'; import { DefaultNodeValues } from '../options.js'; @@ -25,20 +26,13 @@ interface DragInternals { 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({ + return 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; } /** root -> a -> b, root -> c, with `a` protected. */ @@ -48,14 +42,7 @@ function makeMap() { id: 'map', rootId: 'root', options: { defaultNode: DefaultNodeValues }, - draw: { - update: jest.fn(), - clear: jest.fn(), - updateNodeShapes: jest.fn(), - updateProtectionIcon: jest.fn(), - drawBranch: jest.fn(() => null), - renderNodeProperty: jest.fn(), - }, + draw: fakeDraw(), history: { save: jest.fn() }, events, } as unknown as MmpMap; @@ -244,11 +231,11 @@ describe('local edits inside a protected branch', () => { it('refuses editing the name', () => { const { map, tree, 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); - expect(tree.b.getNameDOM().getAttribute('contenteditable')).toBeNull(); expect(refusals(events)).toEqual(['b']); }); }); @@ -308,41 +295,3 @@ describe('drag', () => { 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); - - tree.a.protected = false; - draw.updateProtectionIcon(tree.a); - - expect(tree.a.getProtectionIconDOM()).toBeNull(); - }); -}); diff --git a/packages/mmp/src/map/handlers/nodes.ts b/packages/mmp/src/map/handlers/nodes.ts index 2291a8a9..6e92686c 100644 --- a/packages/mmp/src/map/handlers/nodes.ts +++ b/packages/mmp/src/map/handlers/nodes.ts @@ -143,6 +143,29 @@ export default class Nodes { parentId?: string | null, overwriteId?: string ): Node => { + const node = this.insertNode(userProperties, parentId, overwriteId); + + this.map.draw.update(); + + if (updateHistory) { + this.map.history.save(); + } + + if (notifyWithEvent) { + this.map.events.emit('nodeCreate', this.getNodeProperties(node)); + } + return node; + }; + + /** + * Add a node like addNode, without drawing it, saving history or emitting + * an event. A caller that adds several nodes draws once after the last. + */ + public insertNode( + userProperties?: UserNodeProperties, + parentId?: string | null, + overwriteId?: string + ): Node { const parentNode = this.resolveParent(parentId); const properties: NodeProperties = Utils.mergeObjects( this.map.options.defaultNode, @@ -177,17 +200,8 @@ export default class Nodes { node.coordinates = this.calculateCoordinates(node); } - this.map.draw.update(); - - if (updateHistory) { - this.map.history.save(); - } - - if (notifyWithEvent) { - this.map.events.emit('nodeCreate', this.getNodeProperties(node)); - } return node; - }; + } /** * The parent a node added through addNode gets: none for an explicit null, @@ -210,11 +224,14 @@ export default class Nodes { * @param {boolean} updateHistory */ public addNodes = (nodes: ExportNodeProperties[], updateHistory = true) => { + let added = false; nodes.forEach(node => { if (!this.existNode(node.id)) { - this.addNode(node, false, false, node.parent || null, node.id); + this.insertNode(node, node.parent || null, node.id); + added = true; } }); + if (added) this.map.draw.update(); if (updateHistory) { this.map.history.save(); @@ -236,14 +253,12 @@ export default class Nodes { if (!this.nodeSelectionTo(id)) { const node = this.store.get(id); if (node) { - const background = node.getBackgroundDOM(); + const color = this.map.draw.ringColor(node); - const color = this.ringColor(node); - - if (color && background.style.stroke !== color) { + if (color && this.map.draw.ringOf(node) !== color) { this.releaseSelection(node); - background.style.stroke = color; + this.map.draw.setRing(node, color); this.announceSelection(node); } @@ -263,19 +278,8 @@ export default class Nodes { public redrawSelectionRing() { if (!this.selectedNode) return; - const color = this.ringColor(this.selectedNode); - if (color) this.selectedNode.getBackgroundDOM().style.stroke = color; - } - - /** - * 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(this.selectedNode); + if (color) this.map.draw.setRing(this.selectedNode, color); } /** @@ -297,19 +301,18 @@ export default class Nodes { const previous = this.selectedNode; 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(); + this.map.draw.blurName(previous); } // The blur runs first: the name editor's onblur commits the name through @@ -329,7 +332,7 @@ export default class Nodes { if (!node) Log.error('The node id is not correct'); if (!v.is(CssColorSchema, color)) return; - node.getBackgroundDOM().style.stroke = color; + this.map.draw.setRing(node, color); }; /** @@ -819,7 +822,11 @@ 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 trees = treeBounds( + this.getNodes(), + node => this.getTreeRoot(node), + this.boundsOf + ); return findClearSpot(start, footprint, trees, NEW_TREE_GAP); }; @@ -827,7 +834,7 @@ 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 + * and its isRoot attribute is false. 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 @@ -840,15 +847,14 @@ export default class Nodes { null ); this.selectNode(root.id); - this.map.zoom.panIntoView(nodeBounds(root)); + this.map.zoom.panIntoView(this.boundsOf(root)); return root; }; private rightOfEveryTree(): MapNodeCoordinates { const rightEdge = this.getNodes().reduce( - (edge, node) => - Math.max(edge, node.coordinates.x + node.dimensions.width / 2), + (edge, node) => Math.max(edge, this.boundsOf(node).maxX), -Infinity ); @@ -1035,7 +1041,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); @@ -1054,16 +1060,16 @@ export default class Nodes { * transaction that the distribute event triggers. */ public distributeNodes = (notifyWithEvent = true) => { - const layout = computeMapLayout(this.toLayoutInput()); + const layout = computeMapLayout( + this.toLayoutInput(), + 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.map.draw.redrawBranches(); this.map.draw.update(); this.map.history.save(); @@ -1072,18 +1078,25 @@ export default class Nodes { } }; - /** Move one node, leaving the branch redraw to the caller. */ + /** Move one node, leaving the drawing to the caller. */ private moveNodeTo(id: string, coordinates: MapNodeCoordinates): void { const node = this.store.get(id); if (!node) return; node.coordinates = { x: coordinates.x, y: coordinates.y }; - node.dom?.setAttribute( - 'transform', - 'translate(' + [coordinates.x, coordinates.y] + ')' - ); } + /** + * The bounding box of the node as it is drawn, or as it will be drawn + * before the renderer has measured it. + * @param {Node} node + */ + public boundsOf = (node: Node): Bounds => + nodeBounds({ + coordinates: node.coordinates, + dimensions: this.map.draw.dimensionsOf(node), + }); + private toLayoutInput(): LayoutInputNode[] { return this.store.all().map(node => ({ id: node.id, @@ -1092,7 +1105,7 @@ export default class Nodes { name: node.name, font: node.font, coordinates: node.coordinates, - dimensions: node.dimensions, + dimensions: this.map.draw.dimensionsOf(node), })); } diff --git a/packages/mmp/src/map/handlers/paste-coordinates.spec.ts b/packages/mmp/src/map/handlers/paste-coordinates.spec.ts index 56334d84..0afcd063 100644 --- a/packages/mmp/src/map/handlers/paste-coordinates.spec.ts +++ b/packages/mmp/src/map/handlers/paste-coordinates.spec.ts @@ -1,6 +1,7 @@ import CopyPaste from './copy-paste.js'; import Nodes from './nodes.js'; import MmpMap from '../map.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import Node, { NodeProperties } from '../models/node.js'; import type { ExportNodeProperties, @@ -49,7 +50,7 @@ function makeHandler(liveNodes: Node[]): { handler: CopyPasteInternals; nodes: Nodes; } { - const map = { rootId: ROOT.id } as unknown as MmpMap; + const map = { rootId: ROOT.id, draw: fakeDraw() } as unknown as MmpMap; const nodes = new Nodes(map); map.nodes = nodes; [ROOT, ...liveNodes].forEach(node => nodes.setNode(node)); diff --git a/packages/mmp/src/map/handlers/root-selection.spec.ts b/packages/mmp/src/map/handlers/root-selection.spec.ts index f1d7c9e3..f02e297e 100644 --- a/packages/mmp/src/map/handlers/root-selection.spec.ts +++ b/packages/mmp/src/map/handlers/root-selection.spec.ts @@ -1,5 +1,6 @@ import * as d3 from 'd3'; import Nodes from './nodes.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import History from './history.js'; import MmpMap from '../map.js'; import Options, { @@ -8,37 +9,19 @@ import Options, { } 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. + * A map stub around the real node handler and history, with a renderer that + * keeps the rings. */ function makeMap() { const events = { emit: 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 }, + draw: fakeDraw(), zoom: { center: jest.fn() }, events, export: { asJSON: () => [] }, @@ -94,7 +77,7 @@ describe('a map load', () => { map.history.new(snapshot('root'), false); const root = map.nodes.getRoot(); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + expect(map.draw.ringOf(root)).toBe(ring('#f0f6f5')); expect(map.nodes.getSelectedNode()).toBe(root); }); @@ -118,7 +101,7 @@ describe('a map load', () => { map.history.new(snapshot('root'), false); const root = map.nodes.getRoot(); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + expect(map.draw.ringOf(root)).toBe(ring('#f0f6f5')); expect(firedEvents(events)).toEqual(['nodeSelect']); }); @@ -131,7 +114,7 @@ describe('a map load', () => { const root = map.nodes.getRoot(); expect(root.id).toBe('other-root'); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + expect(map.draw.ringOf(root)).toBe(ring('#f0f6f5')); expect(firedEvents(events)).toEqual(['nodeSelect']); expect(events.emit.mock.calls[0][1].id).toBe('other-root'); }); @@ -142,7 +125,7 @@ describe('a map load', () => { map.history.new(undefined, false); const root = map.nodes.getRoot(); - expect(root.getBackgroundDOM().style.stroke).toBe( + expect(map.draw.ringOf(root)).toBe( ring(DefaultRootNodeValues.colors.background) ); expect(events.emit).toHaveBeenCalledWith( @@ -162,7 +145,7 @@ describe('a map load', () => { }); describe('an edit mode change after a map load', () => { - it('draws the ring on the new DOM of the selected root', () => { + it('draws the ring on the selected root again', () => { const { map } = makeMap(); map.history.new(snapshot('root'), false); @@ -170,12 +153,12 @@ describe('an edit mode change after a map load', () => { const root = map.nodes.getRoot(); expect(map.nodes.getSelectedNode()).toBe(root); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + 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', () => { + it('draws the ring on the selected root again', () => { const { map } = makeMap(); map.history.new(snapshot('root'), false); @@ -183,7 +166,7 @@ describe('removing a node after a map load', () => { const root = map.nodes.getRoot(); expect(map.nodes.getSelectedNode()).toBe(root); - expect(root.getBackgroundDOM().style.stroke).toBe(ring('#f0f6f5')); + expect(map.draw.ringOf(root)).toBe(ring('#f0f6f5')); }); }); diff --git a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts index a39f82ba..ca061cd3 100644 --- a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts +++ b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts @@ -1,5 +1,6 @@ import CopyPaste from './copy-paste.js'; import Nodes from './nodes.js'; +import { fakeDraw } from '../../test/fake-draw.js'; import MmpMap from '../map.js'; import Node, { NodeProperties } from '../models/node.js'; import { DefaultNodeValues } from '../options.js'; @@ -8,6 +9,7 @@ import { NEW_TREE_GAP, treeBounds } from './tree-placement.js'; import type { ExportNodeProperties, MapNodeCoordinates, + MapNodeDimensions, } from '@teammapper/shared'; /** @@ -39,13 +41,14 @@ function makeNode(properties: Partial & { id: string }): Node { */ function makeMap(view: Bounds | null = null) { const events = { emit: jest.fn() }; + const sizes = new Map(); // `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() }, + draw: fakeDraw(node => sizes.get(node.id) ?? { width: 0, height: 0 }), history: { save: jest.fn() }, events, zoom, @@ -90,7 +93,7 @@ function makeMap(view: Bounds | null = null) { const clipboard = new CopyPaste(map); - return { nodes, clipboard, tree, events, zoom, selectNode }; + return { nodes, clipboard, tree, events, zoom, selectNode, sizes }; } function ids(nodes: Node[]): string[] { @@ -314,7 +317,7 @@ 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); @@ -344,17 +347,24 @@ describe('pasteTree', () => { // 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.nodes + .getNodes() + .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]); + pasted.forEach(node => context.sizes.set(node.id, { ...size })); + const [pastedTree] = treeBounds( + pasted, + () => pasted[0], + context.nodes.boundsOf + ); const others = treeBounds( context.nodes.getNodes().filter(node => !pasted.includes(node)), - node => context.nodes.getTreeRoot(node) + node => context.nodes.getTreeRoot(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/map.ts b/packages/mmp/src/map/map.ts index 64fa12e4..775be708 100644 --- a/packages/mmp/src/map/map.ts +++ b/packages/mmp/src/map/map.ts @@ -82,6 +82,7 @@ export default class MmpMap { private destroy = () => { d3.select(window).on('resize.' + this.id, null); this.events.unsubscribeAll(); + this.draw.destroy(); this.dom.svg.remove(); const instanceRecord = this.instance as unknown as Record; diff --git a/packages/mmp/src/map/mmp-instance.spec.ts b/packages/mmp/src/map/mmp-instance.spec.ts index 39a89765..d914091d 100644 --- a/packages/mmp/src/map/mmp-instance.spec.ts +++ b/packages/mmp/src/map/mmp-instance.spec.ts @@ -42,9 +42,17 @@ function exported(map: MmpMap, id: string): ExportNodeProperties { 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 === id) + .node(); +} + function nodeDom(id: string): SVGGElement { - const dom = document.getElementById(id); - if (!(dom instanceof SVGGElement)) throw new Error('no dom for ' + id); + const dom = drawnElement('g.node', id); + if (!dom) throw new Error('no dom for ' + id); return dom; } @@ -107,7 +115,8 @@ const PROPERTY_CASES: PropertyCase[] = [ property: 'branchColor', value: '#00ff00', read: n => n.colors?.branch, - rendered: id => document.getElementById(id + '_branch')?.style.stroke, + rendered: id => + drawnElement('path.branch', id)?.style.stroke, }, { property: 'nameColor', diff --git a/packages/mmp/src/map/models/node.ts b/packages/mmp/src/map/models/node.ts index 93975dd7..445d2c2d 100644 --- a/packages/mmp/src/map/models/node.ts +++ b/packages/mmp/src/map/models/node.ts @@ -1,7 +1,6 @@ import * as d3 from 'd3'; import type { MapNodeCoordinates, - MapNodeDimensions, MapNodeColors, MapNodeFont, MapNodeImage, @@ -29,20 +28,18 @@ export default class Node implements NodeProperties { 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. + * Initialize the node properties and the k coefficient. * @param {NodeProperties} properties */ constructor(properties: NodeProperties) { @@ -71,10 +68,6 @@ export default class Node implements NodeProperties { this.hidden = Boolean(properties.hidden); this.hasHiddenChildNodes = Boolean(properties.hasHiddenChildNodes); - this.dimensions = { - width: 0, - height: 0, - }; this.k = properties.k || d3.randomUniform(-20, 20)(); } @@ -93,58 +86,6 @@ export default class Node implements NodeProperties { 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 { diff --git a/packages/mmp/src/test/fake-draw.ts b/packages/mmp/src/test/fake-draw.ts new file mode 100644 index 00000000..526f232d --- /dev/null +++ b/packages/mmp/src/test/fake-draw.ts @@ -0,0 +1,34 @@ +import type { MapNodeDimensions, MapNodeFont } from '@teammapper/shared'; +import Draw from '../map/handlers/draw.js'; +import { estimateNodeExtent } from '../map/handlers/node-geometry.js'; +import type Node from '../map/models/node.js'; + +/** + * Stands in for the renderer in specs that drive the node handler without a + * DOM. It keeps the rings 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: Node) => MapNodeDimensions = () => ({ width: 0, height: 0 }) +) { + const rings = new Map(); + + return { + update: jest.fn(), + clear: jest.fn(() => rings.clear()), + renderNodeProperty: jest.fn(), + renderPositions: jest.fn(), + enableNodeNameEditing: jest.fn(), + blurName: jest.fn(), + isEditing: () => false, + ringColor: (node: Node) => Draw.prototype.ringColor(node), + ringOf: (node: Node) => rings.get(node.id) ?? null, + setRing: (node: Node, color: string | null) => { + if (color) rings.set(node.id, color); + else rings.delete(node.id); + }, + dimensionsOf: sizeOf, + estimateExtent: (name: string, font: MapNodeFont) => + estimateNodeExtent(name, font.size ?? 16), + }; +} 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/teammapper-frontend/e2e/app.spec.ts b/teammapper-frontend/e2e/app.spec.ts index e6a4fd4a..402b827c 100644 --- a/teammapper-frontend/e2e/app.spec.ts +++ b/teammapper-frontend/e2e/app.spec.ts @@ -40,7 +40,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/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/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-snapshots/node-after-drag-webkit-linux.png b/teammapper-frontend/e2e/node-editing.spec.ts-snapshots/node-after-drag-webkit-linux.png index 20078d95922a86f9ab8664b247b5d602742b2190..7a1326345f6dc23bc5685e67f04f3b759ed36ca8 100644 GIT binary patch literal 35987 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
-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 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/src/app/core/services/mmp/mmp.service.spec.ts b/teammapper-frontend/src/app/core/services/mmp/mmp.service.spec.ts index a6b1edd3..56354bfb 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,5 +1,5 @@ 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'; @@ -147,6 +147,14 @@ describe('MmpService', () => { ); }); + it('draws node names in the font the app ships', async () => { + await service.create('test-id', document.createElement('div')); + 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() }); 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 e583e69c..f63ab861 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -38,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'; @@ -113,6 +119,7 @@ export class MmpService implements OnDestroy { options?: OptionParameters ) { const map: MmpMap = create(id, ref, { + fontFamily: NODE_FONT_FAMILY, ...options, resolveImageUrl: reference => this.imageHandlers?.resolveUrl(reference) ?? null, @@ -331,7 +338,8 @@ export class MmpService implements OnDestroy { /** * 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, and select it. The root has no parent and its isRoot + * attribute is false. */ public addTree() { this.map.instance.addTree(); From 8c66c74e0527a4af88c4fa28b0c7fbb0f8cc0f2d Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Sun, 4 Oct 2026 15:24:15 +0200 Subject: [PATCH 05/25] refactor: remove mmp history as yjs already holds the state (#1449) --- .../map/handlers/branch-visibility.spec.ts | 3 - packages/mmp/src/map/handlers/copy-paste.ts | 1 - .../src/map/handlers/distribute-nodes.spec.ts | 12 +- packages/mmp/src/map/handlers/drag.ts | 4 +- .../src/map/handlers/empty-selection.spec.ts | 16 +- packages/mmp/src/map/handlers/export.ts | 7 +- .../src/map/handlers/legacy-detached.spec.ts | 47 ++-- .../mmp/src/map/handlers/map-loader.spec.ts | 224 ++++++++++++++++++ .../handlers/{history.ts => map-loader.ts} | 182 ++++++-------- .../mmp/src/map/handlers/multi-tree.spec.ts | 60 ++--- .../mmp/src/map/handlers/node-image.spec.ts | 6 +- .../src/map/handlers/node-protection.spec.ts | 12 +- packages/mmp/src/map/handlers/nodes.ts | 47 +--- .../src/map/handlers/root-selection.spec.ts | 32 +-- .../src/map/handlers/tree-clipboard.spec.ts | 1 - packages/mmp/src/map/map.ts | 14 +- packages/mmp/src/map/mmp-instance.spec.ts | 83 +++---- .../services/map-sync/yjs-sync.service.ts | 2 +- .../app/core/services/mmp/mmp.service.spec.ts | 8 +- .../src/app/core/services/mmp/mmp.service.ts | 12 +- 20 files changed, 437 insertions(+), 336 deletions(-) create mode 100644 packages/mmp/src/map/handlers/map-loader.spec.ts rename packages/mmp/src/map/handlers/{history.ts => map-loader.ts} (51%) diff --git a/packages/mmp/src/map/handlers/branch-visibility.spec.ts b/packages/mmp/src/map/handlers/branch-visibility.spec.ts index dc5c4865..cbc385a1 100644 --- a/packages/mmp/src/map/handlers/branch-visibility.spec.ts +++ b/packages/mmp/src/map/handlers/branch-visibility.spec.ts @@ -22,7 +22,6 @@ function makeTree(): { const map = { options: { defaultNode: DefaultNodeValues }, draw: { update: jest.fn(), renderNodeProperty: jest.fn() }, - history: { save: jest.fn() }, } as unknown as MmpMap; const handler = new Nodes(map); @@ -145,7 +144,6 @@ describe('addNode', () => { const added = handler.addNode( { coordinates: { x: 200, y: 200 } }, false, - false, nodes.root.id ); @@ -158,7 +156,6 @@ describe('addNode', () => { const added = handler.addNode( { coordinates: { x: 200, y: 200 } }, false, - false, nodes.root.id ); diff --git a/packages/mmp/src/map/handlers/copy-paste.ts b/packages/mmp/src/map/handlers/copy-paste.ts index b64837ed..33eb207d 100644 --- a/packages/mmp/src/map/handlers/copy-paste.ts +++ b/packages/mmp/src/map/handlers/copy-paste.ts @@ -165,7 +165,6 @@ export default class CopyPaste { 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.emit('nodePaste', pasted); diff --git a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts index b7dce097..e78f017e 100644 --- a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts +++ b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts @@ -10,7 +10,6 @@ function stubMap() { id: 'test-map', // Every node measured at 100 by 30. draw: fakeDraw(() => ({ width: 100, height: 30 })), - history: { save: jest.fn() }, events: { emit: jest.fn() }, }; } @@ -90,14 +89,6 @@ describe('distributeNodes', () => { expect(new Set(hiddenYs).size).toBe(4); }); - it('records the whole redistribution as a single history entry', () => { - const { handler, map } = handlerWith(aiShapedNodes()); - - handler.distributeNodes(); - - expect(map.history.save).toHaveBeenCalledTimes(1); - }); - it('redraws the map once rather than once per node', () => { const { handler, map } = handlerWith(aiShapedNodes()); @@ -129,6 +120,7 @@ describe('distributeNodes', () => { handler.distributeNodes(); - expect(map.history.save).not.toHaveBeenCalled(); + expect(map.draw.update).not.toHaveBeenCalled(); + expect(map.events.emit).not.toHaveBeenCalled(); }); }); diff --git a/packages/mmp/src/map/handlers/drag.ts b/packages/mmp/src/map/handlers/drag.ts index e0d2403e..094332c8 100644 --- a/packages/mmp/src/map/handlers/drag.ts +++ b/packages/mmp/src/map/handlers/drag.ts @@ -112,13 +112,13 @@ export default class Drag { } /** - * If the node was actually dragged change the state of dragging and save the snapshot. + * If the node was actually dragged, end the drag and announce the new + * coordinates of every moved node. * @param {Node} node */ private ended(_event: D3DragEvent, node: Node) { if (this.dragging) { this.dragging = false; - this.map.history.save(); // The drag moved each node many times, so no single previous value // describes the change. diff --git a/packages/mmp/src/map/handlers/empty-selection.spec.ts b/packages/mmp/src/map/handlers/empty-selection.spec.ts index 2a8dc523..e8c7d57e 100644 --- a/packages/mmp/src/map/handlers/empty-selection.spec.ts +++ b/packages/mmp/src/map/handlers/empty-selection.spec.ts @@ -22,13 +22,11 @@ function makeNode(properties: Partial & { id: string }): Node { function makeMap() { const events = { emit: jest.fn() }; - const history = { save: jest.fn() }; const map = { rootId: 'root', options: { defaultNode: DefaultNodeValues }, draw: fakeDraw(), events, - history, } as unknown as MmpMap; const handler = new Nodes(map); @@ -48,7 +46,7 @@ function makeMap() { }); for (const node of [root, branch, other]) handler.setNode(node); - return { map, handler, events, history, nodes: { root, branch, other } }; + return { map, handler, events, nodes: { root, branch, other } }; } /** The event names mmp fired, in order. */ @@ -154,21 +152,21 @@ describe('selectRootNode', () => { describe('operations on the selected node with nothing selected', () => { it('updates no node', () => { - const { handler, history, nodes } = makeMap(); + const { handler, events, nodes } = makeMap(); handler.updateNode('fontWeight', 'bold'); expect(nodes.branch.font.weight).not.toBe('bold'); - expect(history.save).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); }); it('removes no node', () => { - const { handler, history } = makeMap(); + const { handler, events } = makeMap(); handler.removeNode(); expect(handler.getNodes()).toHaveLength(3); - expect(history.save).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); }); it('lists no children', () => { @@ -180,7 +178,7 @@ describe('operations on the selected node with nothing selected', () => { it('throws when nothing is selected and no parent is named', () => { const { handler } = makeMap(); - expect(() => handler.addNode({}, false, false)).toThrow( + expect(() => handler.addNode({}, false)).toThrow( 'There is no selected node' ); expect(handler.getNodes()).toHaveLength(3); @@ -189,7 +187,7 @@ describe('operations on the selected node with nothing selected', () => { it('adds a child to a named parent', () => { const { handler, nodes } = makeMap(); - const added = handler.addNode({}, false, false, nodes.branch.id); + const added = handler.addNode({}, false, nodes.branch.id); expect(added.parent).toBe(nodes.branch); }); diff --git a/packages/mmp/src/map/handlers/export.ts b/packages/mmp/src/map/handlers/export.ts index 658e355b..e1a7677b 100644 --- a/packages/mmp/src/map/handlers/export.ts +++ b/packages/mmp/src/map/handlers/export.ts @@ -20,13 +20,14 @@ export default class Export { } /** - * Return the snapshot (json) of the current map. + * Return the properties of every node, read from the node store 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(); + const nodes = this.map.nodes; - return Utils.cloneObject(snapshot); + return nodes.getNodes().map(node => nodes.getNodeProperties(node)); }; /** diff --git a/packages/mmp/src/map/handlers/legacy-detached.spec.ts b/packages/mmp/src/map/handlers/legacy-detached.spec.ts index f943b833..96adb990 100644 --- a/packages/mmp/src/map/handlers/legacy-detached.spec.ts +++ b/packages/mmp/src/map/handlers/legacy-detached.spec.ts @@ -1,6 +1,6 @@ import Nodes from './nodes.js'; import { fakeDraw } from '../../test/fake-draw.js'; -import History from './history.js'; +import MapLoader from './map-loader.js'; import MmpMap from '../map.js'; import { DefaultNodeValues, DefaultRootNodeValues } from '../options.js'; import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; @@ -28,7 +28,7 @@ function legacyNode( } as ExportNodeProperties; } -const LEGACY_SNAPSHOT: MapSnapshot = [ +const LEGACY_MAP: MapSnapshot = [ legacyNode('root', '', { ...DefaultRootNodeValues, isRoot: true, @@ -39,7 +39,7 @@ const LEGACY_SNAPSHOT: MapSnapshot = [ legacyNode('pasted', 'note', { coordinates: { x: 700, y: -400 } }), ]; -/** A map stub around the real node handler and history. */ +/** A map stub around the real node handler and map loader. */ function makeMap(): MmpMap { const map = { rootId: '', @@ -50,22 +50,22 @@ function makeMap(): MmpMap { export: { asJSON: () => [] }, } as unknown as MmpMap; map.nodes = new Nodes(map); - map.history = new History(map); + map.loader = new MapLoader(map); return map; } -function loadLegacySnapshot(): MmpMap { +function loadLegacyMap(): MmpMap { const map = makeMap(); - map.history.new( - LEGACY_SNAPSHOT.map(node => ({ ...node })), + map.loader.load( + LEGACY_MAP.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 map = loadLegacyMap(); expect( map.nodes @@ -76,7 +76,7 @@ describe('a snapshot that still carries the detached key', () => { }); it('loads a former detached node as a root at its stored position', () => { - const note = loadLegacySnapshot().nodes.getNode('note'); + const note = loadLegacyMap().nodes.getNode('note'); expect({ parent: note?.parent, @@ -90,13 +90,13 @@ 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 pasted = loadLegacyMap().nodes.getNode('pasted'); expect(pasted?.parent?.id).toBe('note'); }); it('keeps the main root as the only node with isRoot set', () => { - const map = loadLegacySnapshot(); + const map = loadLegacyMap(); expect( map.nodes @@ -107,15 +107,15 @@ describe('a snapshot that still carries the detached key', () => { }); it('adds a child to a former detached node', () => { - const map = loadLegacySnapshot(); + const map = loadLegacyMap(); - const added = map.nodes.addNode({ name: 'new' }, false, false, 'note'); + const added = map.nodes.addNode({ name: 'new' }, false, 'note'); expect(added.parent?.id).toBe('note'); }); it('exports no detached key', () => { - const map = loadLegacySnapshot(); + const map = loadLegacyMap(); const exported = map.nodes .getNodes() @@ -124,18 +124,15 @@ describe('a snapshot that still carries the detached key', () => { }); it('adds a synced former detached node as a root whatever is selected', () => { - const map = loadLegacySnapshot(); + const map = loadLegacyMap(); map.nodes.selectRootNode(); - map.nodes.addNodes( - [ - legacyNode('synced', '', { - detached: true, - coordinates: { x: 5, y: 5 }, - }), - ], - false - ); + map.nodes.addNodes([ + legacyNode('synced', '', { + detached: true, + coordinates: { x: 5, y: 5 }, + }), + ]); expect(map.nodes.getNode('synced')?.parent).toBeNull(); }); 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..74e57be8 --- /dev/null +++ b/packages/mmp/src/map/handlers/map-loader.spec.ts @@ -0,0 +1,224 @@ +import { create } from '../../index.js'; +import MmpMap from '../map.js'; +import type { MapSnapshot, OldMmpNode } from '@teammapper/shared'; + +/** + * A map load replaces every node of the map, and `exportAsJSON` reads them + * back. These specs drive both through `MmpInstance`, as the frontend does. + */ + +function makeMap(): MmpMap { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map = create('map', ref); + // jsdom lays nothing out; d3-zoom reads the extent from the view box. + map.dom.svg.attr('viewBox', '0 0 800 600'); + return map; +} + +/** A loaded map: the main root with a styled child and a grandchild. */ +function makeLoadedMap(): { map: MmpMap; child: string } { + const map = makeMap(); + map.instance.new(); + const child = map.instance.addNode({ + name: 'child', + colors: { background: '#ff0000' }, + font: { size: 18, weight: 'bold' }, + }); + if (!child) throw new Error('addNode added no child'); + map.instance.addNode({ name: 'grandchild' }, true, child.id); + map.instance.updateNode('linkHref', 'https://example.com/', true, child.id); + return { map, 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 before the first map load', () => { + expect(makeMap().instance.exportAsJSON()).toEqual([]); + }); + + it('returns the properties of every node in the order they were added', () => { + const { map, child } = makeLoadedMap(); + const root = map.instance.exportRootProperties().id; + + expect(map.instance.exportAsJSON()).toMatchObject([ + { id: root, parent: '', isRoot: true, name: 'Root node' }, + { + id: child, + parent: root, + isRoot: false, + hidden: 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, 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(map.nodes.getNode(child)?.name).toBe('child'); + }); + + it('includes a change applied without an event', () => { + const { map, child } = makeLoadedMap(); + + map.instance.updateNode('name', 'Remote', false, child); + + 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' }, false); + if (!added) throw new Error('addNode added no node'); + + map.instance.removeNode(child, false); + + 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('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: '', + 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('hands the previous map to create listeners', () => { + const { map } = makeLoadedMap(); + const previous = map.instance.exportAsJSON(); + const payloads: (MapSnapshot | undefined)[] = []; + map.instance.on('create', event => payloads.push(event.previousMapData)); + + map.instance.new(LEGACY_MAP as unknown as MapSnapshot); + + expect(payloads).toEqual([previous]); + }); + + it('refuses a map that is not a list of nodes and keeps the current map', () => { + const { map } = makeLoadedMap(); + const before = map.instance.exportAsJSON(); + const created = jest.fn(); + map.instance.on('create', created); + + expect(() => + map.instance.new({ id: 'x' } as unknown as MapSnapshot) + ).toThrow('The snapshot is not correct'); + expect(map.instance.exportAsJSON()).toStrictEqual(before); + expect(created).not.toHaveBeenCalled(); + }); + + describe('with an empty node list', () => { + it('keeps the current map and fires no create event', () => { + const { map } = makeLoadedMap(); + const before = map.instance.exportAsJSON(); + const created = jest.fn(); + map.instance.on('create', created); + + expect(() => map.instance.new([])).toThrow( + 'There was an error importing the map; changes have been rolled back.' + ); + expect(map.instance.exportAsJSON()).toStrictEqual(before); + expect(map.nodes.getSelectedNode()?.id).toBe(before[0].id); + expect(created).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/mmp/src/map/handlers/history.ts b/packages/mmp/src/map/handlers/map-loader.ts similarity index 51% rename from packages/mmp/src/map/handlers/history.ts rename to packages/mmp/src/map/handlers/map-loader.ts index 9927c979..9d969669 100644 --- a/packages/mmp/src/map/handlers/history.ts +++ b/packages/mmp/src/map/handlers/map-loader.ts @@ -17,106 +17,92 @@ import type { } from '@teammapper/shared'; /** - * A node of a snapshot the map accepts. Older maps carry no link and no - * isRoot attribute. + * A node of a map the loader accepts. Older maps carry no link and no isRoot + * attribute. */ -const SnapshotNodeSchema = v.object({ +const LoadedNodeSchema = v.object({ ...NodeSchema.entries, link: v.optional(LinkSchema), isRoot: v.optional(v.boolean()), }); /** - * Hold the snapshot of the current map and rebuild the map from one. + * 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 hides again the branches this client hid. */ -export default class History { +export default class MapLoader { private map: Map; - private snapshot: MapSnapshot; - /** - * Get the associated map instance and start with an empty snapshot. + * Get the associated map instance. * @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 + * 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 + * @param {boolean} notifyWithEvent */ - public new = (input?: MapSnapshot, notifyWithEvent = true) => { - // The conversions below write to the snapshot, and the caller keeps its own. - const snapshot = input === undefined ? undefined : Utils.cloneObject(input); - - 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.emit('create', {}); - } else if (this.checkSnapshotStructure(snapshot)) { - const previousData = this.map.export.asJSON(); - - this.reapplyHiddenState(previousData, snapshot); + public load = (input?: MapSnapshot, notifyWithEvent = true) => { + if (input === undefined) { + this.loadEmptyMap(notifyWithEvent); + return; + } - this.redraw(snapshot); + // 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 snapshot is not correct'); + } + if (nodes.length === 0) { + Log.error( + 'There was an error importing the map; changes have been rolled back.' + ); + } - this.map.zoom.center('position', 0); + const previousData = this.map.export.asJSON(); + this.reapplyHiddenState(previousData, nodes); + this.replaceNodes(nodes); + 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.emit('create', { previousMapData: previousData }); - } - } - } else { - Log.error('The snapshot is not correct'); + if (notifyWithEvent) { + this.map.events.emit('create', { previousMapData: previousData }); } }; /** - * Save the current snapshot of the mind map. + * Replace the map with a main root alone. + * @param {boolean} notifyWithEvent */ - public save() { - this.snapshot = this.getSnapshot(); + private loadEmptyMap(notifyWithEvent: boolean) { + this.map.nodes.clear(); + + this.map.draw.clear(); + this.map.draw.update(); + + this.map.nodes.addRootNode(); + + this.map.zoom.center('position', 0); + + if (notifyWithEvent) this.map.events.emit('create', {}); } /** - * Redraw the map with a new snapshot. - * @param {MapSnapshot} snapshot + * Replace every node of the map with `nodes` and draw the map again. + * @param {MapSnapshot} nodes */ - private redraw(snapshot: MapSnapshot) { + private replaceNodes(nodes: 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 + nodes.forEach((property: ExportNodeProperties) => { + // A map exported by an older release may lack a property; the defaults + // fill it in. const mergedProperty = { ...DefaultNodeValues, ...property, @@ -154,47 +140,35 @@ export default class History { } /** - * 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 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 checkSnapshotStructure(snapshot: MapSnapshot): boolean { - if (!Array.isArray(snapshot)) { + private isValidMap(nodes: MapSnapshot): boolean { + if (!Array.isArray(nodes)) { return false; } - const firstNode = snapshot[0] as unknown; + const firstNode = nodes[0] as unknown; if ( firstNode && typeof firstNode === 'object' && 'key' in firstNode && 'value' in firstNode ) { - this.convertOldMmp(snapshot as unknown as OldMmpNode[]); + this.convertOldMmp(nodes as unknown as OldMmpNode[]); } - return snapshot.every(node => v.is(SnapshotNodeSchema, node)); + return nodes.every(node => v.is(LoadedNodeSchema, node)); } /** - * Convert the old mmp (version: 0.1.7) snapshot to new. - * @param {OldMmpNode[]} snapshot + * Convert the nodes of a map that mmp 0.1.7 exported to the current format. + * @param {OldMmpNode[]} nodes */ - private convertOldMmp(snapshot: OldMmpNode[]) { - for (const node of snapshot) { + private convertOldMmp(nodes: OldMmpNode[]) { + for (const node of nodes) { const oldNode = Utils.cloneObject(node); const target = node as unknown as Record; Utils.clearObject(target); @@ -232,39 +206,41 @@ export default class History { } /** - * Find nodes that were previously hidden locally and re-apply attributes + * Hide again the branches this client hid in the previous map: set + * hasHiddenChildNodes on each such node in `nodes` and hide its + * descendants. * @param {MapSnapshot} previousData - * @param {MapSnapshot} snapshot + * @param {MapSnapshot} nodes */ private reapplyHiddenState( previousData: MapSnapshot, - snapshot: MapSnapshot + nodes: 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 + // Hide every descendant of the node with `parentId`. const hideChildNodes = (parentId: string) => - snapshot + nodes .filter(node => node.parent === parentId) .forEach(node => { node.hidden = true; hideChildNodes(node.id); }); - snapshot.forEach(snapshotNode => { + nodes.forEach(loadedNode => { const nodeWithHiddenChildren = nodesWithHiddenChildren.find( - x => snapshotNode.id === x.id + x => loadedNode.id === x.id ); if (nodeWithHiddenChildren) { - snapshotNode.hasHiddenChildNodes = true; + loadedNode.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) + // The children come from `nodes`, because the node store does not + // hold the loaded nodes yet. + nodes + .filter(node => node.parent === loadedNode.id) .forEach(node => { node.hidden = true; hideChildNodes(node.id); diff --git a/packages/mmp/src/map/handlers/multi-tree.spec.ts b/packages/mmp/src/map/handlers/multi-tree.spec.ts index 7f3c2d5d..aa6d2f26 100644 --- a/packages/mmp/src/map/handlers/multi-tree.spec.ts +++ b/packages/mmp/src/map/handlers/multi-tree.spec.ts @@ -53,13 +53,11 @@ function makeMap(view: Bounds | null = null): { handler: Nodes; internals: NodesInternals; nodes: Record; - history: { save: jest.Mock }; zoom: { visibleArea: jest.Mock; panIntoView: jest.Mock }; sizes: Map; draw: ReturnType; } { const sizes = new Map(); - const history = { save: jest.fn() }; const zoom = { visibleArea: jest.fn(() => view), panIntoView: jest.fn() }; const draw = fakeDraw(node => sizes.get(node.id) ?? { width: 0, height: 0 }); const map = { @@ -67,7 +65,6 @@ function makeMap(view: Bounds | null = null): { options: { defaultNode: DefaultNodeValues }, draw, events: { emit: jest.fn() }, - history, zoom, } as unknown as MmpMap; @@ -90,14 +87,14 @@ function makeMap(view: Bounds | null = null): { for (const node of Object.values(nodes)) handler.store.set(node); internals.selectedNode = branch; - return { handler, internals, nodes, history, zoom, sizes, draw }; + return { handler, internals, nodes, zoom, sizes, draw }; } describe('addNodes', () => { it('keeps a root parentless whatever node is selected', () => { const { handler, nodes } = makeMap(); - handler.addNodes([exported('third-root', '', { x: 2000, y: 0 })], false); + handler.addNodes([exported('third-root', '', { x: 2000, y: 0 })]); expect(handler.getNode('third-root')?.parent).toBeNull(); expect(handler.getChildren(nodes.branch)).toEqual([]); @@ -106,14 +103,11 @@ describe('addNodes', () => { 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 - ); + handler.addNodes([ + exported('third-root', '', { x: 2000, y: 0 }), + exported('child', 'third-root', { x: 1800, y: -120 }), + exported('grandchild', 'child', { x: 1600, y: -240 }), + ]); expect(handler.getNode('third-root')?.parent).toBeNull(); expect(handler.getNode('child')?.parent?.id).toBe('third-root'); @@ -124,13 +118,10 @@ describe('addNodes', () => { it('draws the map once for all added nodes', () => { const { handler, draw } = makeMap(); - handler.addNodes( - [ - exported('third-root', '', { x: 2000, y: 0 }), - exported('child', 'third-root', { x: 1800, y: -120 }), - ], - false - ); + handler.addNodes([ + exported('third-root', '', { x: 2000, y: 0 }), + exported('child', 'third-root', { x: 1800, y: -120 }), + ]); expect(draw.update).toHaveBeenCalledTimes(1); }); @@ -143,7 +134,6 @@ describe('addNode', () => { const added = handler.addNode( { coordinates: { x: 2000, y: 0 } }, false, - false, null ); @@ -157,7 +147,6 @@ describe('addNode', () => { const added = handler.addNode( { coordinates: { x: 2000, y: 0 } }, false, - false, null ); @@ -167,7 +156,7 @@ describe('addNode', () => { it('attaches a child to a second root', () => { const { handler, nodes } = makeMap(); - const added = handler.addNode({}, false, false, nodes.secondRoot.id); + const added = handler.addNode({}, false, nodes.secondRoot.id); expect(added.parent).toBe(nodes.secondRoot); expect(added.coordinates).toEqual({ x: 800, y: -120 }); @@ -202,11 +191,10 @@ describe('newTreeCoordinates', () => { const root = handler.addNode( { coordinates: handler.newTreeCoordinates() }, false, - false, null ); - const child = handler.addNode({}, false, false, root.id); + const child = handler.addNode({}, false, root.id); sizes.set(child.id, { width: 120, height: 30 }); expect(child.coordinates.x).toBeLessThan(root.coordinates.x); @@ -236,7 +224,7 @@ describe('newTreeCoordinates', () => { const { handler } = makeMap(); const coordinates = handler.newTreeCoordinates(); - const added = handler.addNode({ coordinates }, false, false, null); + const added = handler.addNode({ coordinates }, false, null); expect(added.coordinates).toEqual(coordinates); expect(added.parent).toBeNull(); @@ -451,30 +439,18 @@ describe('updateNode branchColor', () => { const { handler, nodes } = makeMap(); expect(() => - handler.updateNode( - 'branchColor', - '#ff0000', - false, - false, - nodes.secondRoot.id - ) + handler.updateNode('branchColor', '#ff0000', false, nodes.secondRoot.id) ).toThrow('A root node has no branches'); expect(nodes.secondRoot.colors.branch).toBe(''); }); it('accepts the unchanged branch color of a root, as a colors sync sends it', () => { - const { handler, nodes, history } = makeMap(); + const { handler, nodes, draw } = makeMap(); nodes.secondRoot.colors.branch = '#577a96'; - handler.updateNode( - 'branchColor', - '#577a96', - false, - true, - nodes.secondRoot.id - ); + handler.updateNode('branchColor', '#577a96', false, nodes.secondRoot.id); expect(nodes.secondRoot.colors.branch).toBe('#577a96'); - expect(history.save).not.toHaveBeenCalled(); + expect(draw.renderNodeProperty).not.toHaveBeenCalled(); }); }); diff --git a/packages/mmp/src/map/handlers/node-image.spec.ts b/packages/mmp/src/map/handlers/node-image.spec.ts index 842a9453..f0fd4466 100644 --- a/packages/mmp/src/map/handlers/node-image.spec.ts +++ b/packages/mmp/src/map/handlers/node-image.spec.ts @@ -64,7 +64,7 @@ function makeMap( } function setImage(map: MmpMap, src: string) { - map.instance.updateNode('imageSrc', src, false, false, 'root'); + map.instance.updateNode('imageSrc', src, false, 'root'); } const drawnImage = (map: MmpMap) => @@ -134,7 +134,7 @@ describe('node images', () => { const map = makeMap(REFERENCE, () => '/api/maps/m/images/i'); lastImage().fail(); - map.instance.updateNode('imageSize', 90, false, false, 'root'); + map.instance.updateNode('imageSize', 90, false, 'root'); expect(map.instance.exportRootProperties().image).toEqual({ src: REFERENCE, @@ -147,7 +147,7 @@ describe('node images', () => { const map = makeMap(DATA_URL); lastImage().fail(); - map.instance.updateNode('nameColor', '#ff0000', false, false, 'root'); + map.instance.updateNode('nameColor', '#ff0000', false, 'root'); expect(FakeImage.created).toHaveLength(1); }); diff --git a/packages/mmp/src/map/handlers/node-protection.spec.ts b/packages/mmp/src/map/handlers/node-protection.spec.ts index 7cbc1427..0781f1f4 100644 --- a/packages/mmp/src/map/handlers/node-protection.spec.ts +++ b/packages/mmp/src/map/handlers/node-protection.spec.ts @@ -43,7 +43,6 @@ function makeMap() { rootId: 'root', options: { defaultNode: DefaultNodeValues }, draw: fakeDraw(), - history: { save: jest.fn() }, events, } as unknown as MmpMap; @@ -152,7 +151,7 @@ describe('local edits inside a protected branch', () => { it('refuses a rename and leaves the name unchanged', () => { const { nodes, tree, events } = makeMap(); - nodes.updateNode('name', 'new', true, true, 'b'); + nodes.updateNode('name', 'new', true, 'b'); expect(tree.b.name).toBe(''); expect(refusals(events)).toEqual(['b']); @@ -161,7 +160,7 @@ describe('local edits inside a protected branch', () => { it('refuses a style change', () => { const { nodes, tree } = makeMap(); - nodes.updateNode('fontWeight', 'bold', true, true, 'a'); + nodes.updateNode('fontWeight', 'bold', true, 'a'); expect(tree.a.font.weight).toBe('normal'); }); @@ -169,7 +168,7 @@ describe('local edits inside a protected branch', () => { it('allows hiding and showing', () => { const { nodes, tree, events } = makeMap(); - nodes.updateNode('hidden', true, true, true, 'b'); + nodes.updateNode('hidden', true, true, 'b'); expect(tree.b.hidden).toBe(true); expect(refusals(events)).toEqual([]); @@ -191,7 +190,7 @@ describe('local edits inside a protected branch', () => { it('refuses adding a child', () => { const { nodes, events } = makeMap(); - const added = nodes.addNodeUnlessProtected({}, true, true, 'b'); + const added = nodes.addNodeUnlessProtected({}, true, 'b'); expect(added).toBeNull(); expect(nodes.getNodes()).toHaveLength(4); @@ -204,7 +203,6 @@ describe('local edits inside a protected branch', () => { const added = nodes.addNodeUnlessProtected( { coordinates: { x: -400, y: 0 } }, true, - true, 'c' ); @@ -244,7 +242,7 @@ describe('changes that stay allowed', () => { it('applies a remote rename inside a protected branch', () => { const { nodes, tree, events } = makeMap(); - nodes.updateNode('name', 'remote', false, false, 'b'); + nodes.updateNode('name', 'remote', false, 'b'); expect(tree.b.name).toBe('remote'); expect(refusals(events)).toEqual([]); diff --git a/packages/mmp/src/map/handlers/nodes.ts b/packages/mmp/src/map/handlers/nodes.ts index 6e92686c..ec7ff05c 100644 --- a/packages/mmp/src/map/handlers/nodes.ts +++ b/packages/mmp/src/map/handlers/nodes.ts @@ -120,7 +120,7 @@ export default class Nodes { public addNodeUnlessProtected = ( ...args: Parameters ): Node | null => { - const [, notifyWithEvent = true, , parentId] = args; + const [, notifyWithEvent = true, parentId] = args; const parentNode = this.resolveParent(parentId); if (parentNode && this.refusesLocalChange(parentNode, notifyWithEvent)) { return null; @@ -139,7 +139,6 @@ export default class Nodes { public addNode = ( userProperties?: UserNodeProperties, notifyWithEvent = true, - updateHistory = true, parentId?: string | null, overwriteId?: string ): Node => { @@ -147,10 +146,6 @@ export default class Nodes { this.map.draw.update(); - if (updateHistory) { - this.map.history.save(); - } - if (notifyWithEvent) { this.map.events.emit('nodeCreate', this.getNodeProperties(node)); } @@ -158,8 +153,8 @@ export default class Nodes { }; /** - * Add a node like addNode, without drawing it, saving history or emitting - * an event. A caller that adds several nodes draws once after the last. + * Add a node like addNode, without drawing it or emitting an event. A + * caller that adds several nodes draws once after the last. */ public insertNode( userProperties?: UserNodeProperties, @@ -218,12 +213,11 @@ export default class Nodes { } /** - * Adds multiple nodes at once and saves one snapshot to history. A node - * with an empty parent becomes a root, whatever node is selected. + * Adds multiple nodes at once and draws once. 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) => { + public addNodes = (nodes: ExportNodeProperties[]) => { let added = false; nodes.forEach(node => { if (!this.existNode(node.id)) { @@ -232,10 +226,6 @@ export default class Nodes { } }); if (added) this.map.draw.update(); - - if (updateHistory) { - this.map.history.save(); - } }; /** @@ -378,7 +368,6 @@ export default class Nodes { this.applyHiddenStateToDescendants(this.selectedNode); this.map.draw.update(); - this.map.history.save(); }; /** @@ -392,7 +381,7 @@ export default class Nodes { this.getDescendants(node).forEach(descendant => { const parent = descendant.parent; const hidden = parent ? this.hidesChildNodes(parent) : false; - this.updateNode('hidden', hidden, false, false, descendant.id); + this.updateNode('hidden', hidden, false, descendant.id); }); }; @@ -441,7 +430,6 @@ export default class Nodes { property: NodeProperty | string, value: NodePropertyValue | unknown, notifyWithEvent = true, - updateHistory = true, id?: string ) => { const node = this.getTargetNode(id); @@ -462,10 +450,6 @@ export default class Nodes { this.writeProperty(node, property, nextValue); this.view.renderNodeProperty(node, property); - if (updateHistory) { - this.map.history.save(); - } - if (notifyWithEvent) { this.map.events.emit('nodeUpdate', { nodeProperties: this.getNodeProperties(node), @@ -552,8 +536,6 @@ export default class Nodes { this.map.draw.clear(); this.map.draw.update(); - this.map.history.save(); - if (notifyWithEvent) { this.map.events.emit('nodeRemove', this.getNodeProperties(node)); } @@ -647,10 +629,9 @@ export default class Nodes { this.getDescendants(node) .filter(descendant => descendant.protected) .forEach(descendant => - this.updateNode('protected', false, true, false, descendant.id) + this.updateNode('protected', false, true, descendant.id) ); - this.updateNode('protected', true, true, false, node.id); - this.map.history.save(); + this.updateNode('protected', true, true, node.id); }; /** @@ -662,7 +643,7 @@ export default class Nodes { const protecting = this.protectingNode(id); if (protecting === null) return; - this.updateNode('protected', false, true, true, protecting); + this.updateNode('protected', false, true, protecting); }; /** @@ -843,7 +824,6 @@ export default class Nodes { const root = this.addNode( { name: '', coordinates: this.newTreeCoordinates() }, true, - true, null ); this.selectNode(root.id); @@ -1055,9 +1035,9 @@ 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. + * sizes, discarding manual positioning. The distribute event makes the + * frontend write the result in one Y.Doc transaction, so one undo reverts + * the whole rewrite. */ public distributeNodes = (notifyWithEvent = true) => { const layout = computeMapLayout( @@ -1071,7 +1051,6 @@ export default class Nodes { } this.map.draw.update(); - this.map.history.save(); if (notifyWithEvent) { this.map.events.emit('distribute', undefined); diff --git a/packages/mmp/src/map/handlers/root-selection.spec.ts b/packages/mmp/src/map/handlers/root-selection.spec.ts index f02e297e..d8a56b6f 100644 --- a/packages/mmp/src/map/handlers/root-selection.spec.ts +++ b/packages/mmp/src/map/handlers/root-selection.spec.ts @@ -1,7 +1,7 @@ import * as d3 from 'd3'; import Nodes from './nodes.js'; import { fakeDraw } from '../../test/fake-draw.js'; -import History from './history.js'; +import MapLoader from './map-loader.js'; import MmpMap from '../map.js'; import Options, { DefaultNodeValues, @@ -10,7 +10,7 @@ import Options, { import type { ExportNodeProperties, MapSnapshot } from '@teammapper/shared'; /** - * A map stub around the real node handler and history, with a renderer that + * A map stub around the real node handler and map loader, with a renderer that * keeps the rings. */ function makeMap() { @@ -27,7 +27,7 @@ function makeMap() { export: { asJSON: () => [] }, } as unknown as MmpMap; map.nodes = new Nodes(map); - map.history = new History(map); + map.loader = new MapLoader(map); return { map, events }; } @@ -46,7 +46,7 @@ function node( } as ExportNodeProperties; } -function snapshot(rootId: string, background = '#f0f6f5'): MapSnapshot { +function mapNodes(rootId: string, background = '#f0f6f5'): MapSnapshot { return [ node(rootId, '', { ...DefaultRootNodeValues, @@ -74,7 +74,7 @@ describe('a map load', () => { it('draws the ring on the root and selects it', () => { const { map } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); const root = map.nodes.getRoot(); expect(map.draw.ringOf(root)).toBe(ring('#f0f6f5')); @@ -84,7 +84,7 @@ describe('a map load', () => { it('fires nodeSelect for the root although it fires no create event', () => { const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); expect(events.emit).toHaveBeenCalledWith( 'nodeSelect', @@ -95,10 +95,10 @@ describe('a map load', () => { it('rings the new root DOM when the same map loads twice', () => { const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); events.emit.mockClear(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); const root = map.nodes.getRoot(); expect(map.draw.ringOf(root)).toBe(ring('#f0f6f5')); @@ -107,10 +107,10 @@ describe('a map load', () => { it('selects the new root when a map with another root loads', () => { const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); events.emit.mockClear(); - map.history.new(snapshot('other-root'), false); + map.loader.load(mapNodes('other-root'), false); const root = map.nodes.getRoot(); expect(root.id).toBe('other-root'); @@ -119,10 +119,10 @@ describe('a map load', () => { expect(events.emit.mock.calls[0][1].id).toBe('other-root'); }); - it('rings the root it creates when no snapshot is given', () => { + it('rings the root it creates when no nodes are given', () => { const { map, events } = makeMap(); - map.history.new(undefined, false); + map.loader.load(undefined, false); const root = map.nodes.getRoot(); expect(map.draw.ringOf(root)).toBe( @@ -137,7 +137,7 @@ describe('a map load', () => { it('selects a root without a background colour', () => { const { map, events } = makeMap(); - map.history.new(snapshot('root', ''), false); + map.loader.load(mapNodes('root', ''), false); expect(map.nodes.getSelectedNode()?.id).toBe('root'); expect(firedEvents(events)).toEqual(['nodeSelect']); @@ -147,7 +147,7 @@ describe('a map load', () => { describe('an edit mode change after a map load', () => { it('draws the ring on the selected root again', () => { const { map } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); new Options({}, map).update('edit', false); @@ -160,7 +160,7 @@ describe('an edit mode change after a map load', () => { describe('removing a node after a map load', () => { it('draws the ring on the selected root again', () => { const { map } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); map.nodes.removeNode('child', false); @@ -173,7 +173,7 @@ describe('removing a node after a map load', () => { describe('selectRootNode', () => { it('fires nodeSelect once when called twice', () => { const { map, events } = makeMap(); - map.history.new(snapshot('root'), false); + map.loader.load(mapNodes('root'), false); map.nodes.deselectNode(); events.emit.mockClear(); diff --git a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts index ca061cd3..82bd2ce0 100644 --- a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts +++ b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts @@ -49,7 +49,6 @@ function makeMap(view: Bounds | null = null) { rootId: 'root', options: { defaultNode: DefaultNodeValues }, draw: fakeDraw(node => sizes.get(node.id) ?? { width: 0, height: 0 }), - history: { save: jest.fn() }, events, zoom, } as unknown as MmpMap; diff --git a/packages/mmp/src/map/map.ts b/packages/mmp/src/map/map.ts index 775be708..47d52f10 100644 --- a/packages/mmp/src/map/map.ts +++ b/packages/mmp/src/map/map.ts @@ -3,7 +3,7 @@ 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'; @@ -27,7 +27,7 @@ export default class MmpMap { public rootId: string; public options: Options; - public history: History; + public loader: MapLoader; public events: Events; public zoom: Zoom; public draw: Draw; @@ -50,7 +50,7 @@ export default class MmpMap { this.events = new Events(); this.options = new Options(options, this); this.zoom = new Zoom(this); - this.history = new History(this); + this.loader = new MapLoader(this); this.drag = new Drag(this); this.draw = new Draw(this, ref); this.nodes = new Nodes(this); @@ -70,8 +70,6 @@ export default class MmpMap { this.dom.svg.call(this.zoom.getZoomBehavior()); } - this.history.save(); - this.createMmpInstance(); } @@ -114,7 +112,7 @@ export default class MmpMap { exportAsJSON: this.export.asJSON, exportRootProperties: this.nodes.exportRootProperties, highlightNode: this.nodes.highlightNodeWithColor, - new: this.history.new, + new: this.loader.load, nodeChildren: this.nodes.nodeChildren, on: this.events.on, pasteNode: this.copyPaste.paste, @@ -137,11 +135,10 @@ export interface MmpInstance { addNode: ( userProperties?: UserNodeProperties, notifyWithEvent?: boolean, - updateHistory?: boolean, parentId?: string | null, overwriteId?: string ) => Node | null; - addNodes: (nodes: ExportNodeProperties[], updateHistory?: boolean) => void; + addNodes: (nodes: ExportNodeProperties[]) => void; addTree: () => Node; center: (type?: 'zoom' | 'position', duration?: number) => void; copyNode: (id?: string) => void; @@ -172,7 +169,6 @@ export interface MmpInstance { 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 index d914091d..c1d69701 100644 --- a/packages/mmp/src/map/mmp-instance.spec.ts +++ b/packages/mmp/src/map/mmp-instance.spec.ts @@ -174,7 +174,7 @@ describe('updateNode', () => { it('writes the value to the model', () => { const { map, child } = makeMapWithChild(); - map.instance.updateNode(property, value, true, true, child); + map.instance.updateNode(property, value, true, child); expect(read(exported(map, child))).toEqual(value); }); @@ -185,7 +185,7 @@ describe('updateNode', () => { const updates: NodeUpdateEvent[] = []; map.instance.on('nodeUpdate', event => updates.push(event)); - map.instance.updateNode(property, value, true, true, child); + map.instance.updateNode(property, value, true, child); expect(updates).toHaveLength(1); expect(updates[0].changedProperty).toBe(property); @@ -195,11 +195,11 @@ describe('updateNode', () => { it('announces nothing when the value stays the same', () => { const { map, child } = makeMapWithChild(); - map.instance.updateNode(property, value, true, true, child); + map.instance.updateNode(property, value, true, child); const listener = jest.fn(); map.instance.on('nodeUpdate', listener); - map.instance.updateNode(property, value, true, true, child); + map.instance.updateNode(property, value, true, child); expect(listener).not.toHaveBeenCalled(); }); @@ -209,7 +209,7 @@ describe('updateNode', () => { const listener = jest.fn(); map.instance.on('nodeUpdate', listener); - map.instance.updateNode(property, value, false, true, child); + map.instance.updateNode(property, value, false, child); expect(listener).not.toHaveBeenCalled(); expect(read(exported(map, child))).toEqual(value); @@ -222,7 +222,7 @@ describe('updateNode', () => { it('draws the value', () => { const { map, child } = makeMapWithChild(); - map.instance.updateNode(property, value, true, true, child); + map.instance.updateNode(property, value, true, child); expect(rendered?.(child)).toEqual(RENDERED[property]); }); @@ -231,9 +231,9 @@ describe('updateNode', () => { it('sets the image size of a node with an image', () => { const { map, child } = makeMapWithChild(); - map.instance.updateNode('imageSrc', IMAGE_REFERENCE, true, true, child); + map.instance.updateNode('imageSrc', IMAGE_REFERENCE, true, child); - map.instance.updateNode('imageSize', 40, true, true, child); + map.instance.updateNode('imageSize', 40, true, child); expect(exported(map, child).image?.size).toBe(40); }); @@ -242,7 +242,7 @@ describe('updateNode', () => { const { map, child } = makeMapWithChild(); expect(() => - map.instance.updateNode('imageSize', 40, true, true, child) + map.instance.updateNode('imageSize', 40, true, child) ).toThrow(); }); @@ -259,7 +259,7 @@ describe('updateNode', () => { const root = map.instance.exportRootProperties().id; expect(() => - map.instance.updateNode('branchColor', '#00ff00', true, true, root) + map.instance.updateNode('branchColor', '#00ff00', true, root) ).toThrow(); }); @@ -267,7 +267,7 @@ describe('updateNode', () => { const { map, child } = makeMapWithChild(); expect(() => - map.instance.updateNode('shadow', '1px', true, true, child) + map.instance.updateNode('shadow', '1px', true, child) ).toThrow(); }); @@ -288,25 +288,19 @@ describe('updateNode', () => { const before = exported(map, child); expect(() => - map.instance.updateNode(property, value, false, false, child) + map.instance.updateNode(property, value, false, 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', - true, - true, - child - ); - map.instance.updateNode('imageSrc', IMAGE_REFERENCE, true, true, child); + map.instance.updateNode('linkHref', 'https://example.com', true, child); + map.instance.updateNode('imageSrc', IMAGE_REFERENCE, true, child); - map.instance.updateNode('backgroundColor', '', true, true, child); - map.instance.updateNode('linkHref', '', true, true, child); - map.instance.updateNode('imageSrc', '', true, true, child); + map.instance.updateNode('backgroundColor', '', true, child); + map.instance.updateNode('linkHref', '', true, child); + map.instance.updateNode('imageSrc', '', true, child); const node = exported(map, child); expect(node.colors?.background).toBe(''); @@ -321,12 +315,7 @@ describe('protection', () => { 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' }, - true, - true, - parent.id - ); + const child = map.instance.addNode({ name: 'child' }, true, parent.id); if (!child) throw new Error('addNode added no child'); map.instance.protectBranch(parent.id); return { map, parent: parent.id, child: child.id }; @@ -344,7 +333,7 @@ describe('protection', () => { const refused: ExportNodeProperties[] = []; map.instance.on('nodeProtected', node => refused.push(node)); - map.instance.updateNode(property, value, true, true, child); + map.instance.updateNode(property, value, true, child); expect(read(exported(map, child))).toEqual(before); expect(refused.map(node => node.id)).toEqual([child]); @@ -354,7 +343,7 @@ describe('protection', () => { it('applies a remote change below it', () => { const { map, child } = makeProtectedMap(); - map.instance.updateNode('name', 'Remote', false, true, child); + map.instance.updateNode('name', 'Remote', false, child); expect(exported(map, child).name).toBe('Remote'); }); @@ -362,7 +351,7 @@ describe('protection', () => { it('still lets a node below it hide', () => { const { map, child } = makeProtectedMap(); - map.instance.updateNode('hidden', true, true, true, child); + map.instance.updateNode('hidden', true, true, child); expect(exported(map, child).hidden).toBe(true); }); @@ -372,7 +361,7 @@ describe('protection', () => { const refused = jest.fn(); map.instance.on('nodeProtected', refused); - expect(map.instance.addNode({ name: 'x' }, true, true, parent)).toBeNull(); + expect(map.instance.addNode({ name: 'x' }, true, parent)).toBeNull(); map.instance.removeNode(child); expect(map.instance.existNode(child)).toBe(true); @@ -405,13 +394,7 @@ describe('events', () => { const node = map.instance.addNode({ name: 'a' }); if (!node) throw new Error('addNode added no node'); - map.instance.updateNode( - 'backgroundColor', - '#ff0000', - false, - false, - node.id - ); + map.instance.updateNode('backgroundColor', '#ff0000', false, node.id); map.instance.selectNode(node.id); map.instance.removeNode(node.id); @@ -470,24 +453,24 @@ describe('destroy', () => { }); }); -describe('new with a snapshot', () => { - it('leaves the passed snapshot unchanged when it re-hides a branch', () => { +describe('new with nodes', () => { + it('leaves the passed nodes unchanged when it re-hides a branch', () => { const { map, child } = makeMapWithChild(); const root = map.instance.exportRootProperties().id; map.instance.selectNode(root); map.instance.toggleBranchVisibility(); - const snapshot = map.instance + const nodes = map.instance .exportAsJSON() .map(node => ({ ...node, hidden: false, hasHiddenChildNodes: false })); - const copy = JSON.parse(JSON.stringify(snapshot)); + const copy = JSON.parse(JSON.stringify(nodes)); - map.instance.new(deepFreeze(snapshot)); + map.instance.new(deepFreeze(nodes)); - expect(snapshot).toEqual(copy); + expect(nodes).toEqual(copy); expect(exported(map, child).hidden).toBe(true); }); - it('leaves a legacy snapshot unchanged', () => { + it('leaves a legacy map unchanged', () => { const map = makeMap(); const legacy: OldMmpNode[] = [ { @@ -510,13 +493,13 @@ describe('new with a snapshot', () => { expect(map.instance.exportAsJSON().map(n => n.name)).toEqual(['Legacy']); }); - it('refuses a snapshot whose node carries an invalid color', () => { + it('refuses a map whose node carries an invalid color', () => { const map = makeMap(); - const snapshot = map.instance + const nodes = map.instance .exportAsJSON() .map(node => ({ ...node, colors: { ...node.colors, name: 'red' } })); - expect(() => map.instance.new(snapshot)).toThrow( + expect(() => map.instance.new(nodes)).toThrow( 'The snapshot is not correct' ); }); 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 0d370c39..b001df05 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 @@ -720,7 +720,7 @@ export class YjsSyncService { value: unknown ): void { for (const update of resolveMmpPropertyUpdate(yjsKey, value)) { - this.mmpService.updateNode(update.prop, update.val, false, false, nodeId); + this.mmpService.updateNode(update.prop, update.val, false, nodeId); } } 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 56354bfb..0e599cc9 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 @@ -211,7 +211,6 @@ describe('MmpService', () => { expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '' }, true, - true, 'selected', undefined ); @@ -223,7 +222,6 @@ describe('MmpService', () => { expect(mockMap.instance.addNode).toHaveBeenCalledWith( props, true, - true, 'selected', '123' ); @@ -247,7 +245,6 @@ describe('MmpService', () => { expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '' }, true, - true, 'second-root', undefined ); @@ -262,7 +259,6 @@ describe('MmpService', () => { expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '', parent: 'named' }, true, - true, 'named', undefined ); @@ -275,7 +271,6 @@ describe('MmpService', () => { expect(mockMap.instance.addNode).toHaveBeenCalledWith( { name: '', parent: '' }, true, - true, 'selected', undefined ); @@ -639,7 +634,6 @@ describe('MmpService', () => { 'imageSrc', REFERENCE, true, - true, 'node-a' ); }); @@ -658,7 +652,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][3]).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 f63ab861..23514b7e 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -330,7 +330,6 @@ export class MmpService implements OnDestroy { this.map.instance.addNode( newProps, notifyWithEvent, - true, parent.id, properties?.id ); @@ -406,17 +405,10 @@ export class MmpService implements OnDestroy { property: NodeProperty | string, value?: NodePropertyValue | ArrayBuffer | unknown, notifyWithEvent?: boolean, - updateHistory?: boolean, id?: string ) { try { - this.map.instance.updateNode( - property, - value, - notifyWithEvent, - updateHistory, - id - ); + this.map.instance.updateNode(property, value, notifyWithEvent, id); } catch { const genericErrorMessage = await this.utilsService.translate( 'TOASTS.ERRORS.NODE_UPDATE_GENERIC' @@ -662,7 +654,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, true, node.id); } catch (error) { await this.showImageUploadError(error); } From b686d671080cb05def296f63b9862e2e78e6cd5c Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Sun, 4 Oct 2026 17:40:18 +0200 Subject: [PATCH 06/25] refactor: move view state out of data model (#1450) --- docs/glossary.md | 16 +- .../map/handlers/branch-visibility.spec.ts | 277 ++++++++++-------- .../src/map/handlers/distribute-nodes.spec.ts | 21 +- packages/mmp/src/map/handlers/draw.ts | 15 +- .../src/map/handlers/empty-selection.spec.ts | 2 + packages/mmp/src/map/handlers/events.ts | 1 + .../mmp/src/map/handlers/map-loader.spec.ts | 20 +- packages/mmp/src/map/handlers/map-loader.ts | 52 +--- .../mmp/src/map/handlers/node-marks.spec.ts | 1 + packages/mmp/src/map/handlers/node-marks.ts | 15 +- .../src/map/handlers/node-protection.spec.ts | 12 +- packages/mmp/src/map/handlers/nodes.ts | 81 +++-- .../src/map/handlers/root-selection.spec.ts | 2 + .../src/map/handlers/tree-clipboard.spec.ts | 2 + .../mmp/src/map/handlers/view-state.spec.ts | 149 ++++++++++ packages/mmp/src/map/handlers/view-state.ts | 90 ++++++ packages/mmp/src/map/map.ts | 10 + packages/mmp/src/map/mmp-instance.spec.ts | 81 +++-- packages/mmp/src/map/models/node.ts | 4 - packages/mmp/src/map/options.ts | 3 - .../shared/src/cache/normalizeMapData.spec.ts | 28 +- packages/shared/src/cache/normalizeMapData.ts | 4 - packages/shared/src/models/index.ts | 14 +- packages/shared/src/schemas/index.ts | 3 - packages/shared/src/schemas/schemas.spec.ts | 26 +- .../src/jobs/seedMapData.job.ts | 2 - .../src/map/utils/clientServerMapping.spec.ts | 2 - .../src/map/utils/clientServerMapping.ts | 2 - .../e2e/hide-child-nodes.spec.ts | 54 ++++ .../core/services/import/import.service.ts | 1 - .../map-sync/map-sync.service.spec.ts | 1 - .../core/services/map-sync/server-types.ts | 1 - .../map-sync/yjs-undo-manager.spec.ts | 1 - .../core/services/map-sync/yjs-utils.spec.ts | 2 - .../app/core/services/mmp/mmp.service.spec.ts | 20 ++ .../src/app/core/services/mmp/mmp.service.ts | 8 + .../components/toolbar/toolbar.component.html | 4 +- .../toolbar/toolbar.component.spec.ts | 19 +- .../components/toolbar/toolbar.component.ts | 6 +- teammapper-frontend/src/test/mocks/mmp.ts | 1 - 40 files changed, 697 insertions(+), 356 deletions(-) create mode 100644 packages/mmp/src/map/handlers/view-state.spec.ts create mode 100644 packages/mmp/src/map/handlers/view-state.ts create mode 100644 teammapper-frontend/e2e/hide-child-nodes.spec.ts diff --git a/docs/glossary.md b/docs/glossary.md index b4fa03eb..2f6a0489 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -122,11 +122,21 @@ 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 + +How one person looks at a mind map: which nodes have their child nodes hidden, +which node is selected, and the rings around selected nodes. mmp keeps the view +state apart from the map data, so the undo stack never records it. The +frontend sends no hidden child nodes to the server or to a peer, and stores +them nowhere, so a reload shows them again. Peers see the selection through Yjs +awareness. The view state starts empty each time the app opens a map. + ### Map settings What a single mind map remembers about how the app draws it, chiefly the font @@ -293,7 +303,7 @@ Whether this client reaches the server. Losing the connection raises the ### Attribute A value a data object holds, such as a node's `isRoot`, `protected` or -`hidden`. A boolean attribute is still an attribute: write "the `isRoot` +`name`. A boolean attribute is still an attribute: write "the `isRoot` attribute is true", not "the node carries the root flag". ### Flag diff --git a/packages/mmp/src/map/handlers/branch-visibility.spec.ts b/packages/mmp/src/map/handlers/branch-visibility.spec.ts index cbc385a1..54cd236c 100644 --- a/packages/mmp/src/map/handlers/branch-visibility.spec.ts +++ b/packages/mmp/src/map/handlers/branch-visibility.spec.ts @@ -1,164 +1,195 @@ -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'; /** - * 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. */ -interface NodesInternals { - selectedNode: Node; +interface Tree { + map: MmpMap; + 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(), renderNodeProperty: 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 map = create('map', ref); + // jsdom lays nothing out; d3-zoom reads the extent from the view box. + map.dom.svg.attr('viewBox', '0 0 800 600'); + map.instance.new(); + + const root = map.instance.exportRootProperties().id; + const first = map.instance.addNode({ name: 'first' }, false, root); + const second = map.instance.addNode({ name: 'second' }, false, root); + if (!first || !second) throw new Error('addNode added no child'); + const grandchild = map.instance.addNode( + { name: 'grandchild' }, + false, + first.id + ); + if (!grandchild) throw new Error('addNode added no grandchild'); + map.instance.selectNode(root); + + return { + map, + root, + first: first.id, + second: second.id, + grandchild: grandchild.id, + }; +} - const nodes = { root, first, second, grandchild }; - for (const node of Object.values(nodes)) handler.store.set(node); +function nodeGroup(id: string): SVGGElement { + const group = d3 + .selectAll('g.node') + .filter(node => node.id === id) + .node(); + if (!group) throw new Error('no group for ' + id); + return group; +} + +function visibility(id: string): string { + return nodeGroup(id).style.visibility; +} + +function hasEyeIcon(id: string): boolean { + return nodeGroup(id).querySelector('text.hidden-icon') !== null; +} - return { handler, internals, nodes }; +/** 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(); - handler.toggleBranchVisibility(); - handler.toggleBranchVisibility(); + toggle(map, root); + toggle(map, root); - 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(first)).toBe('visible'); + expect(visibility(second)).toBe('visible'); + expect(visibility(grandchild)).toBe('visible'); }); - 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 }, - }); - handler.store.set(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('hides a node a peer adds below a node whose child nodes are hidden', () => { + const { map, root } = makeTree(); + toggle(map, root); + + map.instance.addNodes([ + { + id: 'late', + parent: root, + name: 'late', + k: 1, + coordinates: { x: 200, y: 200 }, + }, + ]); + + expect(visibility('late')).toBe('hidden'); }); - it('leaves a branch hidden further down hidden', () => { - const { handler, internals, nodes } = makeTree(); + 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); + }); - internals.selectedNode = nodes.first; - handler.toggleBranchVisibility(); + it('does nothing to a node without child nodes', () => { + const { map, second } = makeTree(); + const listener = jest.fn(); + map.instance.on('viewStateChange', listener); - internals.selectedNode = nodes.root; - handler.toggleBranchVisibility(); - handler.toggleBranchVisibility(); + toggle(map, second); - expect(nodes.first.hidden).toBe(false); - expect(nodes.second.hidden).toBe(false); - expect(nodes.first.hasHiddenChildNodes).toBe(true); - expect(nodes.grandchild.hidden).toBe(true); + expect(map.instance.childNodesHidden(second)).toBe(false); + expect(listener).not.toHaveBeenCalled(); }); - it('does nothing to a node without children', () => { - const { handler, internals, nodes } = makeTree(); - internals.selectedNode = nodes.second; + it('reports no hidden child nodes once a peer removes the last child node', () => { + const { map, first, grandchild } = makeTree(); + toggle(map, first); - handler.toggleBranchVisibility(); + map.instance.removeNode(grandchild, false); - expect(nodes.second.hasHiddenChildNodes).toBe(false); + expect(map.instance.childNodesHidden(first)).toBe(false); + expect(map.instance.exportViewState().nodesWithHiddenChildren).toEqual([ + first, + ]); }); -}); -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(); + const listener = jest.fn(); + map.instance.on('viewStateChange', listener); + map.nodes.deselectNode(); - const added = handler.addNode( - { coordinates: { x: 200, y: 200 } }, - false, - nodes.root.id - ); + map.instance.toggleBranchVisibility(); - expect(added.hidden).toBe(true); + expect(map.instance.exportViewState().nodesWithHiddenChildren).toEqual([]); + expect(listener).not.toHaveBeenCalled(); }); +}); - it('shows a node added to a branch nobody hid', () => { - const { handler, nodes } = makeTree(); +describe('the hidden eye icon', () => { + it('shows on a node whose child nodes are hidden only', () => { + const { map, root, first, second } = makeTree(); - const added = handler.addNode( - { coordinates: { x: 200, y: 200 } }, - false, - nodes.root.id - ); + 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, first, grandchild } = makeTree(); + toggle(map, first); + + map.instance.removeNode(grandchild, false); + expect(hasEyeIcon(first)).toBe(false); + + map.instance.addNodes([ + { + id: 'late', + parent: first, + name: 'late', + k: 1, + coordinates: { x: 9, y: 9 }, + }, + ]); + expect(hasEyeIcon(first)).toBe(true); }); }); diff --git a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts index e78f017e..bbf6429d 100644 --- a/packages/mmp/src/map/handlers/distribute-nodes.spec.ts +++ b/packages/mmp/src/map/handlers/distribute-nodes.spec.ts @@ -25,7 +25,6 @@ function liveNode( parent, name: id, isRoot: false, - hidden: false, coordinates: { x: 0, y: 0 }, ...overrides, } as unknown as Node; @@ -69,24 +68,22 @@ describe('distributeNodes', () => { 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. + 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 root = liveNode('root', null, { isRoot: true }); - const collapsed = liveNode('collapsed', root); - const nodes = [root, collapsed]; + const parent = liveNode('parent', root); + const children: Node[] = []; for (let i = 1; i <= 4; i++) { - nodes.push(liveNode(`c${i}`, collapsed, { hidden: true })); + children.push(liveNode(`c${i}`, parent)); } - const { handler } = handlerWith(nodes); + const { handler } = handlerWith([root, parent, ...children]); 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); + const childYs = children.map(node => node.coordinates.y); + expect(new Set(childYs).size).toBe(4); }); it('redraws the map once rather than once per node', () => { diff --git a/packages/mmp/src/map/handlers/draw.ts b/packages/mmp/src/map/handlers/draw.ts index 78b1ea60..cf5ac815 100644 --- a/packages/mmp/src/map/handlers/draw.ts +++ b/packages/mmp/src/map/handlers/draw.ts @@ -122,7 +122,7 @@ export default class Draw implements NodeView { /** * Redraw the whole map: draw new nodes, remove deleted ones and redraw the - * rest. A hidden node stays drawn but invisible. + * rest. A node the view state hides stays drawn but invisible. */ public update() { const nodes = this.map.nodes.getNodes(); @@ -158,9 +158,6 @@ export default class Draw implements NodeView { * @param {NodeProperty} property */ public renderNodeProperty(node: Node, property: NodeProperty) { - // The caller hides and shows nodes with a full update. - if (property === 'hidden') return; - // The selection ring darkens along with the background. if (property === 'backgroundColor' && this.rings.has(node.id)) { this.setRing(node, this.ringColor(node)); @@ -356,16 +353,17 @@ export default class Draw implements NodeView { */ private render(groups: NodeGroups, branches: BranchPaths) { const context = this.markContext(); + const hidden = this.map.viewState.hiddenNodeIds(); + const visibilityOf = (node: Node) => + hidden.has(node.id) ? 'hidden' : 'visible'; - groups - .attr('transform', translate) - .style('visibility', node => (node.hidden ? 'hidden' : 'visible')); + groups.attr('transform', translate).style('visibility', visibilityOf); NODE_MARKS.forEach(mark => mark.draw(groups, context)); this.observe(groups); branches .style('fill', node => node.colors.branch) .style('stroke', node => node.colors.branch) - .style('visibility', node => (node.hidden ? 'hidden' : 'visible')); + .style('visibility', visibilityOf); this.measure(groups); @@ -433,6 +431,7 @@ export default class Draw implements NodeView { ringOf: node => this.ringOf(node), imageOf: node => this.imageOf(node), isEditing: node => this.editingId === node.id, + hidesChildren: node => this.map.nodes.childNodesHidden(node.id), fontFamily: this.map.options.fontFamily, showLinktext: this.map.options.showLinktext, }; diff --git a/packages/mmp/src/map/handlers/empty-selection.spec.ts b/packages/mmp/src/map/handlers/empty-selection.spec.ts index e8c7d57e..47d627f8 100644 --- a/packages/mmp/src/map/handlers/empty-selection.spec.ts +++ b/packages/mmp/src/map/handlers/empty-selection.spec.ts @@ -1,6 +1,7 @@ import Nodes from './nodes.js'; import { fakeDraw } from '../../test/fake-draw.js'; import CopyPaste from './copy-paste.js'; +import ViewState from './view-state.js'; import Node, { NodeProperties } from '../models/node.js'; import { DefaultNodeValues } from '../options.js'; import MmpMap from '../map.js'; @@ -31,6 +32,7 @@ function makeMap() { const handler = new Nodes(map); map.nodes = handler; + map.viewState = new ViewState(map); map.copyPaste = new CopyPaste(map); const root = makeNode({ id: 'root', isRoot: true }); diff --git a/packages/mmp/src/map/handlers/events.ts b/packages/mmp/src/map/handlers/events.ts index e03c7afb..d7f51c98 100644 --- a/packages/mmp/src/map/handlers/events.ts +++ b/packages/mmp/src/map/handlers/events.ts @@ -19,6 +19,7 @@ const EVENT_TYPES: readonly MmpEventType[] = [ 'nodeRemove', 'distribute', 'nodeProtected', + 'viewStateChange', ]; /** diff --git a/packages/mmp/src/map/handlers/map-loader.spec.ts b/packages/mmp/src/map/handlers/map-loader.spec.ts index 74e57be8..2608b6b9 100644 --- a/packages/mmp/src/map/handlers/map-loader.spec.ts +++ b/packages/mmp/src/map/handlers/map-loader.spec.ts @@ -83,7 +83,6 @@ describe('exportAsJSON', () => { id: child, parent: root, isRoot: false, - hidden: false, protected: false, name: 'child', colors: { background: '#ff0000' }, @@ -182,6 +181,25 @@ describe('new', () => { ]); }); + it('ignores the hidden attributes an older export carries', () => { + const { map, child } = makeLoadedMap(); + const legacy = map.instance + .exportAsJSON() + .map(node => ({ ...node, hidden: true, hasHiddenChildNodes: true })); + + const target = makeMap(); + target.instance.new(legacy); + + const loaded = target.instance.exportAsJSON(); + expect(loaded.every(node => !('hidden' in node))).toBe(true); + expect(loaded.every(node => !('hasHiddenChildNodes' in node))).toBe(true); + const group = target.dom.g + .selectAll('g.node') + .filter(node => node.id === child) + .node(); + expect(group?.style.visibility).toBe('visible'); + }); + it('hands the previous map to create listeners', () => { const { map } = makeLoadedMap(); const previous = map.instance.exportAsJSON(); diff --git a/packages/mmp/src/map/handlers/map-loader.ts b/packages/mmp/src/map/handlers/map-loader.ts index 9d969669..f6ea6a29 100644 --- a/packages/mmp/src/map/handlers/map-loader.ts +++ b/packages/mmp/src/map/handlers/map-loader.ts @@ -28,8 +28,9 @@ const LoadedNodeSchema = v.object({ /** * 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 hides again the branches this client hid. + * new main root. The loader checks the nodes and converts the format mmp + * 0.1.7 exported. ViewState keeps its set apart from the node store, so a + * load keeps hidden child nodes hidden. */ export default class MapLoader { private map: Map; @@ -67,7 +68,6 @@ export default class MapLoader { } const previousData = this.map.export.asJSON(); - this.reapplyHiddenState(previousData, nodes); this.replaceNodes(nodes); this.map.zoom.center('position', 0); @@ -122,8 +122,6 @@ export default class MapLoader { 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, }; @@ -204,48 +202,4 @@ export default class MapLoader { }; } } - - /** - * Hide again the branches this client hid in the previous map: set - * hasHiddenChildNodes on each such node in `nodes` and hide its - * descendants. - * @param {MapSnapshot} previousData - * @param {MapSnapshot} nodes - */ - private reapplyHiddenState( - previousData: MapSnapshot, - nodes: MapSnapshot - ): void { - const nodesWithHiddenChildren = previousData.filter( - node => node.hasHiddenChildNodes - ); - - // Hide every descendant of the node with `parentId`. - const hideChildNodes = (parentId: string) => - nodes - .filter(node => node.parent === parentId) - .forEach(node => { - node.hidden = true; - hideChildNodes(node.id); - }); - - nodes.forEach(loadedNode => { - const nodeWithHiddenChildren = nodesWithHiddenChildren.find( - x => loadedNode.id === x.id - ); - - if (nodeWithHiddenChildren) { - loadedNode.hasHiddenChildNodes = true; - - // The children come from `nodes`, because the node store does not - // hold the loaded nodes yet. - nodes - .filter(node => node.parent === loadedNode.id) - .forEach(node => { - node.hidden = true; - hideChildNodes(node.id); - }); - } - }); - } } diff --git a/packages/mmp/src/map/handlers/node-marks.spec.ts b/packages/mmp/src/map/handlers/node-marks.spec.ts index 1ae667ef..e91987e9 100644 --- a/packages/mmp/src/map/handlers/node-marks.spec.ts +++ b/packages/mmp/src/map/handlers/node-marks.spec.ts @@ -26,6 +26,7 @@ function drawMarks( ringOf: () => null, imageOf: () => null, isEditing: () => false, + hidesChildren: () => false, fontFamily: 'sans-serif', showLinktext: false, }; diff --git a/packages/mmp/src/map/handlers/node-marks.ts b/packages/mmp/src/map/handlers/node-marks.ts index e49804b9..33fac55e 100644 --- a/packages/mmp/src/map/handlers/node-marks.ts +++ b/packages/mmp/src/map/handlers/node-marks.ts @@ -24,6 +24,8 @@ export interface MarkContext { imageOf(node: Node): LoadedImage | null; /** True while the person edits the node's name. */ isEditing(node: Node): boolean; + /** True when the view state hides the child nodes the node has. */ + hidesChildren(node: Node): boolean; fontFamily: string; showLinktext: boolean; } @@ -209,12 +211,17 @@ const link: NodeMark = { function icon( className: string, glyph: string, - shown: (node: Node) => boolean, + shown: (node: Node, context: MarkContext) => boolean, x: (width: number) => number ): NodeMark { return { - draw(groups) { - join(groups, 'text', `${className} material-icons`, shown) + draw(groups, context) { + join( + groups, + 'text', + `${className} material-icons`, + node => shown(node, context) + ) .text(glyph) .style('fill', node => node.colors.name); }, @@ -235,7 +242,7 @@ function icon( const hiddenChildren = icon( 'hidden-icon', 'visibility_off', - node => node.hasHiddenChildNodes, + (node, context) => context.hidesChildren(node), () => -60 ); diff --git a/packages/mmp/src/map/handlers/node-protection.spec.ts b/packages/mmp/src/map/handlers/node-protection.spec.ts index 0781f1f4..44abb94b 100644 --- a/packages/mmp/src/map/handlers/node-protection.spec.ts +++ b/packages/mmp/src/map/handlers/node-protection.spec.ts @@ -2,6 +2,7 @@ import CopyPaste from './copy-paste.js'; import Drag from './drag.js'; import Draw from './draw.js'; import Nodes from './nodes.js'; +import ViewState from './view-state.js'; import { fakeDraw } from '../../test/fake-draw.js'; import MmpMap from '../map.js'; import Node, { NodeProperties } from '../models/node.js'; @@ -48,6 +49,7 @@ function makeMap() { const nodes = new Nodes(map); map.nodes = nodes; + map.viewState = new ViewState(map); nodes.fixCoordinates = (coordinates: MapNodeCoordinates) => coordinates; nodes.selectNode = jest.fn(); nodes.redrawSelectionRing = jest.fn(); @@ -165,12 +167,14 @@ describe('local edits inside a protected branch', () => { expect(tree.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, tree, events } = makeMap(); + (nodes as unknown as { selectedNode: Node }).selectedNode = tree.a; - nodes.updateNode('hidden', true, true, 'b'); + nodes.toggleBranchVisibility(); - expect(tree.b.hidden).toBe(true); + expect(map.viewState.hidesChildren(tree.a)).toBe(true); + expect(calls(events, 'viewStateChange')).toHaveLength(1); expect(refusals(events)).toEqual([]); }); diff --git a/packages/mmp/src/map/handlers/nodes.ts b/packages/mmp/src/map/handlers/nodes.ts index ec7ff05c..11b08703 100644 --- a/packages/mmp/src/map/handlers/nodes.ts +++ b/packages/mmp/src/map/handlers/nodes.ts @@ -48,7 +48,6 @@ export const PropertyMapping = { fontStyle: ['font', 'style'], fontSize: ['font', 'size'], nameColor: ['colors', 'name'], - hidden: ['hidden'], } as const satisfies Record; const isNodeProperty = (property: string): property is NodeProperty => @@ -92,7 +91,6 @@ export default class Nodes { }, id: rootId, parent: null, - hidden: false, isRoot: true, }) as unknown as NodeProperties; @@ -177,12 +175,6 @@ export default class Nodes { 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.store.set(node); @@ -352,47 +344,41 @@ export default class Nodes { }; /** - * 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 announce the new view state. 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. */ public toggleBranchVisibility = () => { - if (!this.selectedNode) return; - - const children = this.getChildren(this.selectedNode); - - // 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); + const node = this.selectedNode; + if (!node) return; - this.applyHiddenStateToDescendants(this.selectedNode); + const viewState = this.map.viewState; + if (!viewState.hidesChildren(node) && this.getChildren(node).length === 0) { + return; + } + viewState.toggle(node); this.map.draw.update(); + this.map.events.emit('viewStateChange', viewState.export()); }; /** - * 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, descendant.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 eye 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); + if (!node) return false; + return ( + this.map.viewState.hidesChildren(node) && + this.getChildren(node).length > 0 + ); + }; /** * Deselect the current selected node, the main root included, and leave @@ -439,8 +425,8 @@ export default class Nodes { Log.error('The property does not exist'); } - // Hiding and changing the protection itself stay allowed. - const guarded = property !== 'protected' && property !== 'hidden'; + // Changing the protection itself stays allowed. + const guarded = property !== 'protected'; if (guarded && this.refusesLocalChange(node, notifyWithEvent)) return; const previousValue = Utils.get(node, PropertyMapping[property]); @@ -527,11 +513,9 @@ export default class Nodes { if (this.refusesLocalRemoval(node, notifyWithEvent)) return; if (!node.isRoot) { - this.store.delete(node.id); - - this.getDescendants(node).forEach((node: Node) => { - this.store.delete(node.id); - }); + const removed = [node, ...this.getDescendants(node)]; + removed.forEach(node => this.store.delete(node.id)); + const forgotten = this.map.viewState.forget(removed.map(node => node.id)); this.map.draw.clear(); this.map.draw.update(); @@ -539,6 +523,9 @@ export default class Nodes { if (notifyWithEvent) { this.map.events.emit('nodeRemove', this.getNodeProperties(node)); } + if (forgotten) { + this.map.events.emit('viewStateChange', this.map.viewState.export()); + } // Deselect only when the removal deleted the selected node or one of // its ancestors. @@ -681,8 +668,6 @@ export default class Nodes { link: Utils.cloneObject(node.link) as MapNodeLink, protected: node.protected, isRoot: node.isRoot, - hidden: node.hidden, - hasHiddenChildNodes: node.hasHiddenChildNodes, k: node.k, }; } diff --git a/packages/mmp/src/map/handlers/root-selection.spec.ts b/packages/mmp/src/map/handlers/root-selection.spec.ts index d8a56b6f..87f5dbb5 100644 --- a/packages/mmp/src/map/handlers/root-selection.spec.ts +++ b/packages/mmp/src/map/handlers/root-selection.spec.ts @@ -2,6 +2,7 @@ import * as d3 from 'd3'; import Nodes from './nodes.js'; import { fakeDraw } from '../../test/fake-draw.js'; import MapLoader from './map-loader.js'; +import ViewState from './view-state.js'; import MmpMap from '../map.js'; import Options, { DefaultNodeValues, @@ -27,6 +28,7 @@ function makeMap() { export: { asJSON: () => [] }, } as unknown as MmpMap; map.nodes = new Nodes(map); + map.viewState = new ViewState(map); map.loader = new MapLoader(map); return { map, events }; } diff --git a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts index 82bd2ce0..8344a5b4 100644 --- a/packages/mmp/src/map/handlers/tree-clipboard.spec.ts +++ b/packages/mmp/src/map/handlers/tree-clipboard.spec.ts @@ -1,5 +1,6 @@ import CopyPaste from './copy-paste.js'; import Nodes from './nodes.js'; +import ViewState from './view-state.js'; import { fakeDraw } from '../../test/fake-draw.js'; import MmpMap from '../map.js'; import Node, { NodeProperties } from '../models/node.js'; @@ -55,6 +56,7 @@ function makeMap(view: Bounds | null = null) { const nodes = new Nodes(map); map.nodes = nodes; + map.viewState = new ViewState(map); // No zoom transform applies in these tests, so `fixCoordinates` returns its // input. nodes.fixCoordinates = (coordinates: MapNodeCoordinates) => coordinates; 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..8e893b28 --- /dev/null +++ b/packages/mmp/src/map/handlers/view-state.spec.ts @@ -0,0 +1,149 @@ +import * as d3 from 'd3'; +import { create } from '../../index.js'; +import MmpMap from '../map.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`. + */ + +/** root -> a -> b -> c, with the root selected. */ +function makeChain() { + const ref = document.createElement('div'); + document.body.appendChild(ref); + const map: MmpMap = create('map', ref); + // jsdom lays nothing out; d3-zoom reads the extent from the view box. + map.dom.svg.attr('viewBox', '0 0 800 600'); + map.instance.new(); + + const root = map.instance.exportRootProperties().id; + const a = map.instance.addNode({ name: 'a' }, false, root); + if (!a) throw new Error('addNode added no node'); + const b = map.instance.addNode({ name: 'b' }, false, a.id); + if (!b) throw new Error('addNode added no node'); + const c = map.instance.addNode({ name: 'c' }, false, b.id); + if (!c) throw new Error('addNode added no node'); + + return { map, root, a, b, c }; +} + +function visibility(id: string): string | undefined { + return d3 + .selectAll('g.node') + .filter(node => node.id === 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(); + + map.instance.restoreViewState({ nodesWithHiddenChildren: [a.id] }); + + expect([...map.viewState.hiddenNodeIds()].sort()).toEqual( + [b.id, c.id].sort() + ); + expect(visibility(root)).toBe('visible'); + expect(visibility(c.id)).toBe('hidden'); + }); + + it('terminates on an ancestor cycle', () => { + const { map, a, b, c } = makeChain(); + map.instance.restoreViewState({ nodesWithHiddenChildren: [b.id] }); + a.parent = b; + + // The walk from b reaches a, whose child b closes the cycle. + expect([...map.viewState.hiddenNodeIds()].sort()).toEqual( + [a.id, c.id].sort() + ); + }); + + it('forgets the removed nodes and announces the new view state', () => { + const { map, a, b } = makeChain(); + map.instance.restoreViewState({ nodesWithHiddenChildren: [a.id, b.id] }); + const listener = jest.fn(); + map.instance.on('viewStateChange', listener); + + map.instance.removeNode(b.id, false); + + expect(map.instance.exportViewState()).toEqual({ + nodesWithHiddenChildren: [a.id], + }); + expect(listener).toHaveBeenCalledWith({ nodesWithHiddenChildren: [a.id] }); + }); + + it('announces nothing when a removal leaves the view state as it was', () => { + const { map, a, c } = makeChain(); + map.instance.restoreViewState({ nodesWithHiddenChildren: [a.id] }); + const listener = jest.fn(); + map.instance.on('viewStateChange', listener); + + map.instance.removeNode(c.id, false); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('exports the ids of nodes the map lacks too', () => { + const { map, a } = makeChain(); + + map.instance.restoreViewState({ + nodesWithHiddenChildren: [a.id, 'not-arrived'], + }); + + expect(map.instance.exportViewState()).toEqual({ + nodesWithHiddenChildren: [a.id, 'not-arrived'], + }); + }); + + it('keeps the ids of nodes yet to arrive across a toggle', () => { + const { map, b } = makeChain(); + map.instance.restoreViewState({ + nodesWithHiddenChildren: ['not-arrived'], + }); + + map.instance.selectNode(b.id); + map.instance.toggleBranchVisibility(); + + expect(map.instance.exportViewState()).toEqual({ + nodesWithHiddenChildren: ['not-arrived', b.id], + }); + }); + + it('exports what it restored', () => { + const { map, a, b } = makeChain(); + const state = { nodesWithHiddenChildren: [a.id, b.id] }; + + map.instance.restoreViewState(state); + + expect(map.instance.exportViewState()).toEqual(state); + }); + + it('redraws on restore and announces nothing', () => { + const { map, b, c } = makeChain(); + const listener = jest.fn(); + map.instance.on('viewStateChange', listener); + map.instance.on('nodeUpdate', listener); + + map.instance.restoreViewState({ nodesWithHiddenChildren: [b.id] }); + + expect(visibility(b.id)).toBe('visible'); + expect(visibility(c.id)).toBe('hidden'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('keeps the view state across a load of the same nodes', () => { + const { map, a, c } = makeChain(); + map.instance.restoreViewState({ nodesWithHiddenChildren: [a.id] }); + + map.instance.new(map.instance.exportAsJSON(), false); + + expect(map.instance.exportViewState()).toEqual({ + nodesWithHiddenChildren: [a.id], + }); + 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..9f2445ac --- /dev/null +++ b/packages/mmp/src/map/handlers/view-state.ts @@ -0,0 +1,90 @@ +import type { MapViewState } from '@teammapper/shared'; +import type Node from '../models/node.js'; +import type MmpMap from '../map.js'; + +/** + * 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 mmp sends no hidden child nodes to a + * peer, the server or the undo stack. + * + * The set may hold the id of a node that has not arrived yet, such as a node + * a peer added before the first sync. Such an id hides nothing until its node + * exists. `Nodes.removeNode` deletes the ids of the nodes it removes. + */ +export default class ViewState { + private map: MmpMap; + private nodesWithHiddenChildren = new Set(); + + constructor(map: MmpMap) { + this.map = map; + } + + /** + * True when this person hid the child nodes of the node. + * @param {Node} node + */ + public hidesChildren(node: Node): boolean { + return this.nodesWithHiddenChildren.has(node.id); + } + + /** + * The ids of the nodes below a node whose child nodes this person hid. A + * walk down an ancestor cycle stops at the node it started from. + */ + public hiddenNodeIds(): Set { + const childrenOf = new Map(); + for (const node of this.map.nodes.getNodes()) { + if (!node.parent) continue; + const children = childrenOf.get(node.parent.id) ?? []; + children.push(node); + childrenOf.set(node.parent.id, children); + } + + const hidden = new Set(); + for (const id of this.nodesWithHiddenChildren) { + const visited = new Set([id]); + const pending = [...(childrenOf.get(id) ?? [])]; + for (let node = pending.pop(); node; node = pending.pop()) { + if (visited.has(node.id)) continue; + visited.add(node.id); + hidden.add(node.id); + pending.push(...(childrenOf.get(node.id) ?? [])); + } + } + return hidden; + } + + /** + * Hide the child nodes of the node, or show them when they are hidden. + * @param {Node} node + */ + public toggle(node: Node) { + if (!this.nodesWithHiddenChildren.delete(node.id)) { + this.nodesWithHiddenChildren.add(node.id); + } + } + + /** + * Delete the ids of removed nodes. Return true when the set held one. + * @param {string[]} ids + */ + public forget(ids: string[]): boolean { + return ids.filter(id => this.nodesWithHiddenChildren.delete(id)).length > 0; + } + + /** Return every id in the set, the ids of nodes the store lacks included. */ + public export(): MapViewState { + return { nodesWithHiddenChildren: [...this.nodesWithHiddenChildren] }; + } + + /** + * Replace the view state and redraw the map when it holds nodes. Emits no + * event. + * @param {MapViewState} state + */ + public restore(state: MapViewState) { + this.nodesWithHiddenChildren = new Set(state.nodesWithHiddenChildren); + if (this.map.nodes.getNodes().length > 0) this.map.draw.update(); + } +} diff --git a/packages/mmp/src/map/map.ts b/packages/mmp/src/map/map.ts index 47d52f10..0b0f4014 100644 --- a/packages/mmp/src/map/map.ts +++ b/packages/mmp/src/map/map.ts @@ -8,10 +8,12 @@ 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 ViewState from './handlers/view-state.js'; import Node from './models/node.js'; import type { ExportNodeProperties, MapSnapshot, + MapViewState, MmpEventType, NodeProperty, NodePropertyValue, @@ -35,6 +37,7 @@ export default class MmpMap { public nodes: Nodes; public export: Export; public copyPaste: CopyPaste; + public viewState: ViewState; public instance!: MmpInstance; @@ -50,6 +53,7 @@ export default class MmpMap { this.events = new Events(); this.options = new Options(options, this); this.zoom = new Zoom(this); + this.viewState = new ViewState(this); this.loader = new MapLoader(this); this.drag = new Drag(this); this.draw = new Draw(this, ref); @@ -107,10 +111,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, + exportViewState: () => this.viewState.export(), + restoreViewState: state => this.viewState.restore(state), highlightNode: this.nodes.highlightNodeWithColor, new: this.loader.load, nodeChildren: this.nodes.nodeChildren, @@ -148,10 +155,13 @@ export interface MmpInstance { getSelectedNode: () => Node | 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; + exportViewState: () => MapViewState; + restoreViewState: (state: MapViewState) => void; highlightNode: (id: string, color: string) => void; new: (snapshot?: MapSnapshot, notifyWithEvent?: boolean) => void; nodeChildren: (id?: string) => ExportNodeProperties[]; diff --git a/packages/mmp/src/map/mmp-instance.spec.ts b/packages/mmp/src/map/mmp-instance.spec.ts index c1d69701..009ebe54 100644 --- a/packages/mmp/src/map/mmp-instance.spec.ts +++ b/packages/mmp/src/map/mmp-instance.spec.ts @@ -4,11 +4,32 @@ import MmpMap from './map.js'; import type { ExportNodeProperties, MapSnapshot, + MapViewState, + MmpEventType, NodeProperty, NodeUpdateEvent, OldMmpNode, } from '@teammapper/shared'; +/** + * Every event but `viewStateChange`. The `satisfies` clause fails the + * typecheck when `MmpEventType` gains an event this record lacks. + */ +const OTHER_EVENTS = Object.keys({ + create: true, + nodeSelect: true, + nodeDeselect: true, + nodeUpdate: true, + nodeCreate: true, + nodePaste: true, + nodeRemove: true, + distribute: true, + nodeProtected: true, +} satisfies Record, true>) as Exclude< + MmpEventType, + 'viewStateChange' +>[]; + /** * The frontend reaches the library through `MmpInstance` alone, so these specs * drive a real map through it: every node property, the protection and the @@ -142,11 +163,6 @@ const PROPERTY_CASES: PropertyCase[] = [ read: n => n.font?.size, rendered: id => nameDom(id).style.fontSize, }, - { - property: 'hidden', - value: true, - read: n => n.hidden, - }, { property: 'protected', value: true, @@ -280,7 +296,6 @@ describe('updateNode', () => { ['fontSize', 'big'], ['fontWeight', 'x'.repeat(1000)], ['name', 42], - ['hidden', 'yes'], ['protected', 1], ['coordinates', { x: 'left', y: 0 }], ])('refuses %s %p and keeps the model', (property, value) => { @@ -321,11 +336,7 @@ describe('protection', () => { return { map, parent: parent.id, child: child.id }; } - it.each( - PROPERTY_CASES.filter( - c => c.property !== 'hidden' && c.property !== 'protected' - ) - )( + it.each(PROPERTY_CASES.filter(c => c.property !== 'protected'))( 'refuses a local $property change below it', ({ property, value, read }) => { const { map, child } = makeProtectedMap(); @@ -348,14 +359,6 @@ describe('protection', () => { expect(exported(map, child).name).toBe('Remote'); }); - it('still lets a node below it hide', () => { - const { map, child } = makeProtectedMap(); - - map.instance.updateNode('hidden', true, true, child); - - expect(exported(map, child).hidden).toBe(true); - }); - it('refuses a local child and a local removal', () => { const { map, parent, child } = makeProtectedMap(); const refused = jest.fn(); @@ -425,6 +428,24 @@ describe('events', () => { expect(listener).toHaveBeenCalledTimes(1); }); + it('announces the view state alone when it hides child nodes', () => { + const { map, child } = makeMapWithChild(); + const root = map.instance.exportRootProperties().id; + map.instance.selectNode(root); + const viewStates: MapViewState[] = []; + const shared = jest.fn(); + OTHER_EVENTS.forEach(event => map.instance.on(event, shared)); + map.instance.on('viewStateChange', state => viewStates.push(state)); + + map.instance.toggleBranchVisibility(); + + expect(viewStates).toEqual([{ nodesWithHiddenChildren: [root] }]); + expect(map.instance.exportViewState()).toEqual(viewStates[0]); + expect(map.instance.childNodesHidden()).toBe(true); + expect(map.instance.childNodesHidden(child)).toBe(false); + expect(shared).not.toHaveBeenCalled(); + }); + it('refuses an event it does not know', () => { const map = makeMap(); @@ -454,20 +475,32 @@ describe('destroy', () => { }); describe('new with nodes', () => { - it('leaves the passed nodes unchanged when it re-hides a branch', () => { + it('keeps the view state and leaves the passed nodes unchanged', () => { const { map, child } = makeMapWithChild(); const root = map.instance.exportRootProperties().id; map.instance.selectNode(root); map.instance.toggleBranchVisibility(); - const nodes = map.instance - .exportAsJSON() - .map(node => ({ ...node, hidden: false, hasHiddenChildNodes: false })); + const nodes = map.instance.exportAsJSON(); const copy = JSON.parse(JSON.stringify(nodes)); map.instance.new(deepFreeze(nodes)); expect(nodes).toEqual(copy); - expect(exported(map, child).hidden).toBe(true); + expect(map.instance.exportViewState()).toEqual({ + nodesWithHiddenChildren: [root], + }); + expect(nodeDom(child).style.visibility).toBe('hidden'); + }); + + it('exports nodes without view state', () => { + const { map } = makeMapWithChild(); + map.instance.selectNode(map.instance.exportRootProperties().id); + 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', () => { diff --git a/packages/mmp/src/map/models/node.ts b/packages/mmp/src/map/models/node.ts index 445d2c2d..adfe6e1c 100644 --- a/packages/mmp/src/map/models/node.ts +++ b/packages/mmp/src/map/models/node.ts @@ -35,8 +35,6 @@ export default class Node implements NodeProperties { public link: NodeLink; public protected: boolean; public isRoot: boolean; - public hidden: boolean; - public hasHiddenChildNodes: boolean; /** * Initialize the node properties and the k coefficient. @@ -65,8 +63,6 @@ export default class Node implements NodeProperties { 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.k = properties.k || d3.randomUniform(-20, 20)(); } diff --git a/packages/mmp/src/map/options.ts b/packages/mmp/src/map/options.ts index 2d974ab4..4dfa45c5 100644 --- a/packages/mmp/src/map/options.ts +++ b/packages/mmp/src/map/options.ts @@ -136,7 +136,6 @@ export const DefaultNodeValues: DefaultNodeProperties = { style: 'normal', weight: 'normal', }, - hidden: false, isRoot: false, }; @@ -165,7 +164,6 @@ export const DefaultRootNodeValues: DefaultNodeProperties = { weight: 'normal', }, isRoot: true, - hidden: false, }; export interface DefaultNodeProperties { @@ -176,7 +174,6 @@ export interface DefaultNodeProperties { colors: NodeColors; font: NodeFont; isRoot: boolean; - hidden: boolean; } /** 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 fe4fb521..03902d53 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 { @@ -182,8 +180,7 @@ export type NodeProperty = | 'fontWeight' | 'fontStyle' | 'fontSize' - | 'nameColor' - | 'hidden'; + | 'nameColor'; export type NodePropertyValue = string | number | boolean | MapNodeCoordinates | null | undefined; @@ -194,6 +191,14 @@ export interface NodeUpdateEvent { changedProperty: NodeProperty; } +/** + * The view state of one client's map: which nodes hide their child nodes. + * It stays local to the client and never reaches the map data. + */ +export interface MapViewState { + nodesWithHiddenChildren: string[]; +} + export interface MapCreateEvent { previousMapData?: MapSnapshot; } @@ -258,6 +263,7 @@ export interface MmpEventPayloadMap { nodeRemove: ExportNodeProperties; distribute: void; nodeProtected: ExportNodeProperties; + viewStateChange: MapViewState; } export type MmpEventType = keyof MmpEventPayloadMap; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index d45f1783..479ae2da 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -79,8 +79,6 @@ 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. */ @@ -97,7 +95,6 @@ export const NodePropertySchemas = { fontStyle: FontSchema.entries.style, fontSize: FontSchema.entries.size, nameColor: CssColorSchema, - hidden: v.boolean(), } satisfies Record; export const MapOptionsSchema = v.partial( diff --git a/packages/shared/src/schemas/schemas.spec.ts b/packages/shared/src/schemas/schemas.spec.ts index 7728ad9d..be961a9e 100644 --- a/packages/shared/src/schemas/schemas.spec.ts +++ b/packages/shared/src/schemas/schemas.spec.ts @@ -31,7 +31,6 @@ describe('Shared Validation Schemas', () => { ['fontStyle', 'italic'], ['fontSize', 16], ['nameColor', null], - ['hidden', false], ])('accepts %s %p', (property, value) => { expect(v.safeParse(NodePropertySchemas[property], value).success).toBe( true @@ -47,7 +46,6 @@ describe('Shared Validation Schemas', () => { ['backgroundColor', 'red'], ['fontWeight', 'x'.repeat(1000)], ['fontSize', 'big'], - ['hidden', 1], ])('refuses %s %p', (property, value) => { expect(v.safeParse(NodePropertySchemas[property], value).success).toBe( false @@ -212,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/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/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..64fc1411 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 = ( 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..6184537b --- /dev/null +++ b/teammapper-frontend/e2e/hide-child-nodes.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, Page } from '@playwright/test'; +import { createMap } from './helpers'; + +/** + * Adds a child below the node labelled `parent` through the floating add + * button 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(); +} + +// 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/src/app/core/services/import/import.service.ts b/teammapper-frontend/src/app/core/services/import/import.service.ts index 18abfb1c..721638f0 100644 --- a/teammapper-frontend/src/app/core/services/import/import.service.ts +++ b/teammapper-frontend/src/app/core/services/import/import.service.ts @@ -236,7 +236,6 @@ export class ImportService { 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.service.spec.ts b/teammapper-frontend/src/app/core/services/map-sync/map-sync.service.spec.ts index db65582a..adb51c0d 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 @@ -33,7 +33,6 @@ function createMockNode( colors: { branch: '#000000' }, font: { size: 14, style: 'normal', weight: 'normal' }, protected: false, - hidden: false, coordinates: undefined, image: undefined, link: undefined, 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 8122d748..851c4435 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 @@ -49,7 +49,6 @@ const ReversePropertyMapping = { style: 'fontStyle', size: 'fontSize', }, - hidden: 'hidden', } as const; export { ServerMap, ServerMapInfo, PrivateServerMap, ReversePropertyMapping }; 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..3a434037 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, 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..c7e9139e 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 @@ -28,7 +28,6 @@ jest.mock('@teammapper/mmp', () => ({ fontStyle: ['font', 'style'], fontSize: ['font', 'size'], nameColor: ['colors', 'name'], - hidden: ['hidden'], }, })); @@ -47,7 +46,6 @@ function createMockNode( colors: { branch: '#000000' }, font: { size: 14, style: 'normal', weight: 'normal' }, protected: false, - hidden: false, coordinates: undefined, image: undefined, link: undefined, 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 0e599cc9..a5eb7ee7 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 @@ -61,6 +61,7 @@ describe('MmpService', () => { pasteNode: jest.fn(), pasteTree: jest.fn(), toggleBranchVisibility: jest.fn(), + childNodesHidden: jest.fn(), distributeNodes: jest.fn(), nodeChildren: jest.fn(), addTree: jest.fn(), @@ -604,6 +605,25 @@ describe('MmpService', () => { }); }); + describe('view state', () => { + it('reports no hidden child nodes before create', () => { + expect(service.childNodesHidden()).toBe(false); + }); + + describe('after create', () => { + beforeEach(async () => { + await service.create('test-id', document.createElement('div')); + }); + + 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; 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 23514b7e..6f475efd 100644 --- a/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts +++ b/teammapper-frontend/src/app/core/services/mmp/mmp.service.ts @@ -552,6 +552,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. */ 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..90c3a837 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 @@ -172,7 +172,9 @@ [disabled]="nodeActionsDisabled || !canHideNodes" color="primary" mat-icon-button> - {{ hasHiddenNodes ? 'visibility_off' : 'visibility' }} + {{ + childNodesHidden ? 'visibility_off' : 'visibility' + }}