diff --git a/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs b/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs index 0a134ff8..3ea889b1 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs @@ -69,7 +69,7 @@ impl DocumentRepository for SqlxDocumentRepository { r#"SELECT d.* FROM documents d WHERE d.workspace_id = $1 AND {archived_condition} - ORDER BY d.updated_at DESC LIMIT 100"#, + ORDER BY d.updated_at DESC"#, ); sqlx::query(&sql) .bind(workspace_id) diff --git a/app/.gitignore b/app/.gitignore index 029f7fba..92dd5f8a 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -2,6 +2,7 @@ node_modules .DS_Store dist dist-ssr +dev-dist *.local count.txt .env diff --git a/app/eslint.config.js b/app/eslint.config.js index 7d8e5dc9..a16a28b7 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -31,6 +31,14 @@ export default [ }, settings: { // Keep path groups like before; resolver TS optional + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts', '.css'], + }, + typescript: { + project: './tsconfig.json', + }, + }, 'boundaries/elements': [ { type: 'shared', pattern: 'src/shared/**' }, { type: 'entities', pattern: 'src/entities/**' }, @@ -38,9 +46,12 @@ export default [ { type: 'widgets', pattern: 'src/widgets/**' }, { type: 'processes', pattern: 'src/processes/**' }, { type: 'routes', pattern: 'src/routes/**' }, - { type: 'assets', pattern: 'src/**/*.{svg,png,jpg,jpeg,gif,webp}' }, + { type: 'routes', pattern: 'src/router.tsx', mode: 'file' }, + { type: 'routes', pattern: 'src/routeTree.gen.ts', mode: 'file' }, + { type: 'assets', pattern: 'src/**/*.{css,svg,png,jpg,jpeg,gif,webp}' }, ], 'boundaries/ignore': [ + '**/*.css', '**/*.svg', '**/*.png', '**/*.jpg', @@ -75,7 +86,7 @@ export default [ { default: 'disallow', rules: [ - { from: ['routes'], allow: ['widgets', 'features', 'entities', 'shared', 'processes', 'assets'] }, + { from: ['routes'], allow: ['routes', 'widgets', 'features', 'entities', 'shared', 'processes', 'assets'] }, { from: ['widgets'], allow: ['features', 'entities', 'shared'] }, { from: ['features'], allow: ['entities', 'shared'] }, { from: ['processes'], allow: ['features', 'entities', 'shared'] }, diff --git a/app/package-lock.json b/app/package-lock.json index c210a8dd..008da57f 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -41,6 +41,7 @@ "morphdom": "^2.7.7", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", "satori": "^0.18.3", "sonner": "^2.0.7", @@ -68,7 +69,8 @@ "@typescript-eslint/parser": "^8.8.1", "@vitejs/plugin-react": "^5.0.4", "eslint": "^9.12.0", - "eslint-plugin-boundaries": "^4.2.2", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-boundaries": "^5.3.1", "eslint-plugin-import": "^2.31.0", "jsdom": "^27.0.0", "prettier": "^3.5.3", @@ -1683,7 +1685,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1734,6 +1735,23 @@ "node": ">=6.9.0" } }, + "node_modules/@boundaries/elements": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-1.1.2.tgz", + "integrity": "sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.8", + "is-core-module": "2.16.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", @@ -2873,6 +2891,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2886,6 +2905,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2895,6 +2915,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2904,52 +2925,62 @@ "node": ">= 8" } }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, "node_modules/@oozcitak/dom": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz", - "integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", "license": "MIT", "dependencies": { - "@oozcitak/infra": "1.0.8", - "@oozcitak/url": "1.0.4", - "@oozcitak/util": "8.3.8" + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/infra": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz", - "integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", "license": "MIT", "dependencies": { - "@oozcitak/util": "8.3.8" + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/url": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz", - "integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", "license": "MIT", "dependencies": { - "@oozcitak/infra": "1.0.8", - "@oozcitak/util": "8.3.8" + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/util": { - "version": "8.3.8", - "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz", - "integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", "license": "MIT", "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@parcel/watcher": { @@ -4284,6 +4315,24 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -5444,17 +5493,18 @@ } }, "node_modules/@tanstack/directive-functions-plugin": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/directive-functions-plugin/-/directive-functions-plugin-1.132.31.tgz", - "integrity": "sha512-u6TaLhTmllnvINZAoc1r7TbZ0H1IgnqGpoN0pUvWrqpKuunAugZO7fwD1TeYApGyB/RmSWarHMMkNbDffvlJvQ==", + "version": "1.141.0", + "resolved": "https://registry.npmjs.org/@tanstack/directive-functions-plugin/-/directive-functions-plugin-1.141.0.tgz", + "integrity": "sha512-Ca8ylyh2c100Kn9nFUA4Gao95eISBGLbff+4unJ6MF+t+/FR3awIsIC5gBxeEVu+nv6HPaY9ZeD0/Ehh4OsXpQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", - "@tanstack/router-utils": "1.132.31", + "@tanstack/router-utils": "1.141.0", "babel-dead-code-elimination": "^1.0.10", + "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "engines": { @@ -5469,9 +5519,9 @@ } }, "node_modules/@tanstack/history": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.132.31.tgz", - "integrity": "sha512-UCHM2uS0t/uSszqPEo+SBSSoQVeQ+LlOWAVBl5SA7+AedeAbKafIPjFn8huZCXNLAYb0WKV2+wETr7lDK9uz7g==", + "version": "1.141.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.141.0.tgz", + "integrity": "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==", "license": "MIT", "engines": { "node": ">=12" @@ -5551,14 +5601,14 @@ } }, "node_modules/@tanstack/react-router": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.132.31.tgz", - "integrity": "sha512-bgYgffI9TQhi8Zc/I5DMQEO4WOcDNtSll66Eb3/+k3iuI59ovVB/CiVCGjqdT8+2YBBj2x0saRDjsF00vj5+Yg==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.8.tgz", + "integrity": "sha512-kPHeS3dF2kBBvFglRpGrHWKnlu6wmUpa7C6aKakI10d7vP+l42XNqe6ARl+9KwX5ujMgCvZ7lgknbZy5L2wiFA==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.132.31", - "@tanstack/react-store": "^0.7.0", - "@tanstack/router-core": "1.132.31", + "@tanstack/history": "1.141.0", + "@tanstack/react-store": "^0.8.0", + "@tanstack/router-core": "1.141.8", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" @@ -5621,18 +5671,18 @@ } }, "node_modules/@tanstack/react-start": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/react-start/-/react-start-1.132.31.tgz", - "integrity": "sha512-dv2XjvVQdFodMqjh7fIcTdYX75IdISpsz5Qd7KoKbmIZbLzRfFH3Tuas8gBB2aWAFTM14pLgx1uQ71Bbp9qDnA==", - "license": "MIT", - "dependencies": { - "@tanstack/react-router": "1.132.31", - "@tanstack/react-start-client": "1.132.31", - "@tanstack/react-start-server": "1.132.31", - "@tanstack/router-utils": "^1.132.31", - "@tanstack/start-client-core": "1.132.31", - "@tanstack/start-plugin-core": "1.132.31", - "@tanstack/start-server-core": "1.132.31", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-start/-/react-start-1.141.8.tgz", + "integrity": "sha512-nfTUdXcUa5GERfwf+iYAPykTYLSc3L8cix4gOoIfV/IV+xSmsN/R5KUw57FKa8i25wCpVqtEGpj8QorIZ4PnMw==", + "license": "MIT", + "dependencies": { + "@tanstack/react-router": "1.141.8", + "@tanstack/react-start-client": "1.141.8", + "@tanstack/react-start-server": "1.141.8", + "@tanstack/router-utils": "^1.141.0", + "@tanstack/start-client-core": "1.141.8", + "@tanstack/start-plugin-core": "1.141.8", + "@tanstack/start-server-core": "1.141.8", "pathe": "^2.0.3" }, "engines": { @@ -5649,14 +5699,14 @@ } }, "node_modules/@tanstack/react-start-client": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/react-start-client/-/react-start-client-1.132.31.tgz", - "integrity": "sha512-yop9TMqLZ2GHLgjw7i5r/jye4PzOgsHMlJYw/wCvwnWiDv3wLDnfdNJaW4YEhaAsUvBvGgje/KuYPiCMhinNjA==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-start-client/-/react-start-client-1.141.8.tgz", + "integrity": "sha512-TDLLhwUxrFx3kiepmuFil+BRXc5z+6yTHQ1rNqn08CP/qWZTpwPXtbtAWkLiWyJvltb6fUAjhEk/Dikfyq+23A==", "license": "MIT", "dependencies": { - "@tanstack/react-router": "1.132.31", - "@tanstack/router-core": "1.132.31", - "@tanstack/start-client-core": "1.132.31", + "@tanstack/react-router": "1.141.8", + "@tanstack/router-core": "1.141.8", + "@tanstack/start-client-core": "1.141.8", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, @@ -5673,16 +5723,16 @@ } }, "node_modules/@tanstack/react-start-server": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/react-start-server/-/react-start-server-1.132.31.tgz", - "integrity": "sha512-LYjYaR4SeapahBquP1RYL/kWL/BoFfTdIwGqxMT+ZjPmNST169k1vD1yUrcGiPL28bYL6Sw75lBRylDfQmNNnQ==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-start-server/-/react-start-server-1.141.8.tgz", + "integrity": "sha512-ZK3ov4qeId0T/Us0rJt4HtvS5tlOXTV9pWa7lBJred5z5eI0lgS9blfTBtqtY1fLoplnGgjw7lBIUbSDcWmtPQ==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.132.31", - "@tanstack/react-router": "1.132.31", - "@tanstack/router-core": "1.132.31", - "@tanstack/start-client-core": "1.132.31", - "@tanstack/start-server-core": "1.132.31" + "@tanstack/history": "1.141.0", + "@tanstack/react-router": "1.141.8", + "@tanstack/router-core": "1.141.8", + "@tanstack/start-client-core": "1.141.8", + "@tanstack/start-server-core": "1.141.8" }, "engines": { "node": ">=22.12.0" @@ -5697,13 +5747,13 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", - "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.8.0.tgz", + "integrity": "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.7.7", - "use-sync-external-store": "^1.5.0" + "@tanstack/store": "0.8.0", + "use-sync-external-store": "^1.6.0" }, "funding": { "type": "github", @@ -5732,16 +5782,16 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.132.31.tgz", - "integrity": "sha512-74W+J5N1NuPcuWDwsBAjCgK4ahtIRaB51KdegYrD1AeSNqiV4u8KzOzHKAAZD01UipQApUbpJbzFrHq0XQ9BHw==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.141.8.tgz", + "integrity": "sha512-/wuEk6/FzzpSC3hkWFE0SU3+eunmLQdzp91MqrPQOOLKnJJb/KgUH8nn0UA3RPdr4y7vLmnzvsUOzrjrEpwYAA==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.132.31", - "@tanstack/store": "^0.7.0", + "@tanstack/history": "1.141.0", + "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", - "seroval": "^1.3.2", - "seroval-plugins": "^1.3.2", + "seroval": "^1.4.1", + "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, @@ -5753,6 +5803,27 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/router-core/node_modules/seroval": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.1.tgz", + "integrity": "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@tanstack/router-core/node_modules/seroval-plugins": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.4.0.tgz", + "integrity": "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/@tanstack/router-devtools-core": { "version": "1.132.31", "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.132.31.tgz", @@ -5784,14 +5855,14 @@ } }, "node_modules/@tanstack/router-generator": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.132.31.tgz", - "integrity": "sha512-6Ys47sBR3jxet3CaqnF/ykV44R8HLQoT5ZbDqi6f2At6TXYe/+VELRSApC+cq1yjVJwp6Ot5Hm6mYWewh69bdQ==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.141.8.tgz", + "integrity": "sha512-vFgfJT12CIFL6Iv2niC+2OeLooth6rgUJ4V/C6EZi5uOZR5kamMw41NgcINTDhNt5USsqYc0vmCM62oroFcMbA==", "license": "MIT", "dependencies": { - "@tanstack/router-core": "1.132.31", - "@tanstack/router-utils": "1.132.31", - "@tanstack/virtual-file-routes": "1.132.31", + "@tanstack/router-core": "1.141.8", + "@tanstack/router-utils": "1.141.0", + "@tanstack/virtual-file-routes": "1.141.0", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", @@ -5807,9 +5878,9 @@ } }, "node_modules/@tanstack/router-plugin": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.132.31.tgz", - "integrity": "sha512-5/n6VxA6tFLFyewjl1+Av0Qsxmr/WpnAR2UlccS7ZaYli3bvNPJSZd3dy9EphEAXeSbqvFT29nQ/ox8EmGTonQ==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.141.8.tgz", + "integrity": "sha512-kdm2CJb/HFgXiZG2qdV2XMB+RqcYypXBKmaVtz7uC6qpeglPcioxGcsASCLz0h8JGnq2ajFRPNVIxmRnwKREYA==", "license": "MIT", "dependencies": { "@babel/core": "^7.27.7", @@ -5818,10 +5889,10 @@ "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", - "@tanstack/router-core": "1.132.31", - "@tanstack/router-generator": "1.132.31", - "@tanstack/router-utils": "1.132.31", - "@tanstack/virtual-file-routes": "1.132.31", + "@tanstack/router-core": "1.141.8", + "@tanstack/router-generator": "1.141.8", + "@tanstack/router-utils": "1.141.0", + "@tanstack/virtual-file-routes": "1.141.0", "babel-dead-code-elimination": "^1.0.10", "chokidar": "^3.6.0", "unplugin": "^2.1.2", @@ -5836,9 +5907,9 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", - "@tanstack/react-router": "^1.132.31", + "@tanstack/react-router": "^1.141.8", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", - "vite-plugin-solid": "^2.11.8", + "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "peerDependenciesMeta": { @@ -5877,9 +5948,9 @@ } }, "node_modules/@tanstack/router-utils": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.132.31.tgz", - "integrity": "sha512-uf8mQ3wV58K8TL5XXBoWhkYxmCV7LLWbbf6AvcxdhnCnBNmXBGlY+T8RdsRnXyI2Iyp2HfHaVZ+8H3CEQedXfw==", + "version": "1.141.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.141.0.tgz", + "integrity": "sha512-/eFGKCiix1SvjxwgzrmH4pHjMiMxc+GA4nIbgEkG2RdAJqyxLcRhd7RPLG0/LZaJ7d0ad3jrtRqsHLv2152Vbw==", "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", @@ -5888,8 +5959,8 @@ "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", - "fast-glob": "^3.3.3", - "pathe": "^2.0.3" + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" }, "engines": { "node": ">=12" @@ -5900,9 +5971,9 @@ } }, "node_modules/@tanstack/server-functions-plugin": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/server-functions-plugin/-/server-functions-plugin-1.132.31.tgz", - "integrity": "sha512-XjkW0cE6bJXswJwncM8DImjqBqNG8v4GSR+ZFuYkmasXs26dNyS5cucMU4+egS3YC0sNyksCBdD35XrnGlRWLQ==", + "version": "1.141.3", + "resolved": "https://registry.npmjs.org/@tanstack/server-functions-plugin/-/server-functions-plugin-1.141.3.tgz", + "integrity": "sha512-yHgVvw6mYwINyv2wGjCnk9Dw5yfsyGu5bAIptr3v6E9dByRVo3KexXhtxNM3vj++YEHYMQSbgCoxiVKp9cu5Iw==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.27.1", @@ -5912,7 +5983,7 @@ "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", - "@tanstack/directive-functions-plugin": "1.132.31", + "@tanstack/directive-functions-plugin": "1.141.0", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" }, @@ -5925,14 +5996,14 @@ } }, "node_modules/@tanstack/start-client-core": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/start-client-core/-/start-client-core-1.132.31.tgz", - "integrity": "sha512-Zf2f2uUFr5bN8+AGp4pG8lws1tqkANjzjcKzsLGi+uyoe4R9+SavjlDLtqmRLkUqDFZvkVEDNumwCVMxKPeVBw==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/start-client-core/-/start-client-core-1.141.8.tgz", + "integrity": "sha512-WQNoCHtbv0wISR5O30MQyLl4hCAdB41LSwSvqX1ruZ6sJIdCz5716seDuFCCXjZ/H7K9fZd/+vTflIQsPGgHYg==", "license": "MIT", "dependencies": { - "@tanstack/router-core": "1.132.31", - "@tanstack/start-storage-context": "1.132.31", - "seroval": "^1.3.2", + "@tanstack/router-core": "1.141.8", + "@tanstack/start-storage-context": "1.141.8", + "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, @@ -5944,31 +6015,41 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/start-client-core/node_modules/seroval": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.1.tgz", + "integrity": "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@tanstack/start-plugin-core": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/start-plugin-core/-/start-plugin-core-1.132.31.tgz", - "integrity": "sha512-JyRI0AgNDoY14KZeNLBj1gdX0uKdvY1bt0bBoZjDt8i7LEf5KKt0uA9s4To9KKHR8V0KORO9cNyrgNwxp7hwhg==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/start-plugin-core/-/start-plugin-core-1.141.8.tgz", + "integrity": "sha512-7EO+eqBtQ7AoRmvBF3bFWS2nCRpQ6DG0gfITLPVeL3/00pTKXGHF4L4cHz84jRqx+7dvB1WnUq47/vIG/aCmOg==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.26.2", "@babel/core": "^7.26.8", "@babel/types": "^7.26.8", "@rolldown/pluginutils": "1.0.0-beta.40", - "@tanstack/router-core": "1.132.31", - "@tanstack/router-generator": "1.132.31", - "@tanstack/router-plugin": "1.132.31", - "@tanstack/router-utils": "1.132.31", - "@tanstack/server-functions-plugin": "1.132.31", - "@tanstack/start-server-core": "1.132.31", + "@tanstack/router-core": "1.141.8", + "@tanstack/router-generator": "1.141.8", + "@tanstack/router-plugin": "1.141.8", + "@tanstack/router-utils": "1.141.0", + "@tanstack/server-functions-plugin": "1.141.3", + "@tanstack/start-client-core": "1.141.8", + "@tanstack/start-server-core": "1.141.8", "babel-dead-code-elimination": "^1.0.9", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", - "srvx": "^0.8.2", + "srvx": "^0.9.8", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", - "xmlbuilder2": "^3.1.1", + "xmlbuilder2": "^4.0.0", "zod": "^3.24.2" }, "engines": { @@ -5997,17 +6078,17 @@ } }, "node_modules/@tanstack/start-server-core": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/start-server-core/-/start-server-core-1.132.31.tgz", - "integrity": "sha512-Lujmr1mPRrXsXYgEbaOj5kxRYW6QKhma7w9/+7IoQxl8QTVHtIZ+b40BAt18rDZDn9c1M+fp7MGYmCTGDCMtOw==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/start-server-core/-/start-server-core-1.141.8.tgz", + "integrity": "sha512-mfrTzmVAC0XFdWtyfkfJMiYosMcvTsJiqNRg9/WhSM41+Mx5wOeV4QoveJv5ufjojiQQW3+/YSDdlfkYi3sfzA==", "license": "MIT", "dependencies": { - "@tanstack/history": "1.132.31", - "@tanstack/router-core": "1.132.31", - "@tanstack/start-client-core": "1.132.31", - "@tanstack/start-storage-context": "1.132.31", - "h3-v2": "npm:h3@2.0.0-beta.4", - "seroval": "^1.3.2", + "@tanstack/history": "1.141.0", + "@tanstack/router-core": "1.141.8", + "@tanstack/start-client-core": "1.141.8", + "@tanstack/start-storage-context": "1.141.8", + "h3-v2": "npm:h3@2.0.0-beta.5", + "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" }, "engines": { @@ -6018,13 +6099,22 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/start-server-core/node_modules/seroval": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.1.tgz", + "integrity": "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@tanstack/start-storage-context": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/start-storage-context/-/start-storage-context-1.132.31.tgz", - "integrity": "sha512-CH+/SI+lHijZZw6P1VnOeoKDeUVzVZ1Wmhx4QrH8UYQNkTARFdkWOuLDItevDs91BrgtywV/N+Vs4YsOnNnjYw==", + "version": "1.141.8", + "resolved": "https://registry.npmjs.org/@tanstack/start-storage-context/-/start-storage-context-1.141.8.tgz", + "integrity": "sha512-cuV8Mn9aiIBH47PlvSHYKyHx0+kYL5+JTzQ6JBEf5gsh5Wzhb2G23V2EDhoiJm0Itv/1+TvkaaGGarTssnr75Q==", "license": "MIT", "dependencies": { - "@tanstack/router-core": "1.132.31" + "@tanstack/router-core": "1.141.8" }, "engines": { "node": ">=22.12.0" @@ -6035,9 +6125,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz", - "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.8.0.tgz", + "integrity": "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==", "license": "MIT", "funding": { "type": "github", @@ -6055,9 +6145,9 @@ } }, "node_modules/@tanstack/virtual-file-routes": { - "version": "1.132.31", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.132.31.tgz", - "integrity": "sha512-rxS8Cm2nIXroLqkm9pE/8X2lFNuvcTIIiFi5VH4PwzvKscAuaW3YRMN1WmaGDI2mVEn+GLaoY6Kc3jOczL5i4w==", + "version": "1.141.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.141.0.tgz", + "integrity": "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A==", "license": "MIT", "engines": { "node": ">=12" @@ -6534,128 +6624,397 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vercel/nft": { - "version": "0.30.2", - "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.30.2.tgz", - "integrity": "sha512-pquXF3XZFg/T3TBor08rUhIGgOhdSilbn7WQLVP/aVSSO+25Rs4H/m3nxNDQ2x3znX7Z3yYjryN8xaLwypcwQg==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^2.0.0", - "@rollup/pluginutils": "^5.1.3", - "acorn": "^8.6.0", - "acorn-import-attributes": "^1.9.5", - "async-sema": "^3.1.1", - "bindings": "^1.4.0", - "estree-walker": "2.0.2", - "glob": "^10.4.5", - "graceful-fs": "^4.2.9", - "node-gyp-build": "^4.2.2", - "picomatch": "^4.0.2", - "resolve-from": "^5.0.0" - }, - "bin": { - "nft": "out/cli.js" - }, - "engines": { - "node": ">=18" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@vercel/nft/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@vercel/nft/node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@vercel/nft/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@vitejs/plugin-react": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", - "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.4", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.38", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.38", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", - "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vercel/nft": { + "version": "0.30.2", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.30.2.tgz", + "integrity": "sha512-pquXF3XZFg/T3TBor08rUhIGgOhdSilbn7WQLVP/aVSSO+25Rs4H/m3nxNDQ2x3znX7Z3yYjryN8xaLwypcwQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^10.4.5", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vercel/nft/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/nft/node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/@vercel/nft/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", + "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.38", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true }, "vite": { "optional": true @@ -7029,7 +7388,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -7819,6 +8177,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clipboardy": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", @@ -8650,8 +9014,32 @@ "node": ">=0.3.1" } }, - "node_modules/doctrine": { - "version": "2.1.0", + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, + "node_modules/dnd-multi-backend": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dnd-multi-backend/-/dnd-multi-backend-9.0.0.tgz", + "integrity": "sha512-BCUFes4x0LA2bZyEZFHeQzZ1CBZo6PB40zMOG/gNgICxjAZfN2jHgISowqkR1isdx/msUNzscxEb17SP7yc4KQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/LouisBrunner" + }, + "peerDependencies": { + "dnd-core": "^16.0.1" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, @@ -9224,10 +9612,45 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", - "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -9253,51 +9676,25 @@ } }, "node_modules/eslint-plugin-boundaries": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-4.2.2.tgz", - "integrity": "sha512-cjwpZqkCXgfz953bc74uDetOtGVxwgMgNZ7hAKi6Oxck+x4oY6Z/9DzgPqAYhtQdSNHFVg+vhft/lSL+snPMQg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-5.3.1.tgz", + "integrity": "sha512-91StsOYtDyrna1fyRJ+1Ps5CnrfyFLbdCouPZ3E/o2cllLxJke3OoScdqjpBSl7pNEYbojhpNlurQAr30sf9Bg==", "dev": true, "license": "MIT", "dependencies": { + "@boundaries/elements": "1.1.2", "chalk": "4.1.2", "eslint-import-resolver-node": "0.3.9", - "eslint-module-utils": "2.8.1", - "micromatch": "4.0.7" + "eslint-module-utils": "2.12.1", + "micromatch": "4.0.8" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.18" }, "peerDependencies": { "eslint": ">=6.0.0" } }, - "node_modules/eslint-plugin-boundaries/node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/eslint-plugin-boundaries/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -9342,24 +9739,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -9587,7 +9966,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -9601,6 +9979,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -9648,6 +10027,7 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -10059,9 +10439,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -10242,15 +10622,15 @@ }, "node_modules/h3-v2": { "name": "h3", - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/h3/-/h3-2.0.0-beta.4.tgz", - "integrity": "sha512-/JdwHUGuHjbBXAVxQN7T7QeI9cVlhsqMKVNFHebZVs9RoEYH85Ogh9O1DEy/1ZiJkmMwa1gNg6bBcGhc1Itjdg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/h3/-/h3-2.0.0-beta.5.tgz", + "integrity": "sha512-ApIkLH+nTxzCC0Nq/GN1v6jkvu2eOLfdTnTs6ghiuG1EYHWJBDLzhk5tn7SZMEUNsLUjG4qfmqzBx2LG9I7Q/w==", "license": "MIT", "dependencies": { "cookie-es": "^2.0.0", - "fetchdts": "^0.1.6", - "rou3": "^0.7.3", - "srvx": "^0.8.7" + "fetchdts": "^0.1.7", + "rou3": "^0.7.7", + "srvx": "^0.8.9" }, "engines": { "node": ">=20.11.1" @@ -10264,6 +10644,18 @@ } } }, + "node_modules/h3-v2/node_modules/srvx": { + "version": "0.8.16", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.16.tgz", + "integrity": "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==", + "license": "MIT", + "bin": { + "srvx": "bin/srvx.mjs" + }, + "engines": { + "node": ">=20.16.0" + } + }, "node_modules/h3/node_modules/cookie-es": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", @@ -10419,6 +10811,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -10610,6 +11017,12 @@ "license": "MIT", "optional": true }, + "node_modules/immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10789,6 +11202,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -11330,10 +11766,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12075,7 +12510,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -12112,6 +12546,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -12203,6 +12649,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -12212,6 +12659,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -12225,6 +12673,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -12429,6 +12878,22 @@ "license": "MIT", "optional": true }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -12664,9 +13129,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -12791,6 +13256,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -13410,6 +13884,23 @@ "dev": true, "license": "MIT" }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -13448,6 +13939,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -13502,6 +13994,21 @@ "destr": "^2.0.3" } }, + "node_modules/rdndmb-html5-to-touch": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/rdndmb-html5-to-touch/-/rdndmb-html5-to-touch-8.1.2.tgz", + "integrity": "sha512-efi3MaXYxWaLMd5xzF1bVvmX8erTMhYHSlaMjQe+tynf4IdtgRYfKLwYg+4Z5eq4k7idrjKHQOIMDE6D8LjnOA==", + "license": "MIT", + "dependencies": { + "dnd-multi-backend": "^8.1.2", + "react-dnd-html5-backend": "^16.0.1", + "react-dnd-touch-backend": "^16.0.1" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/LouisBrunner" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -13511,6 +14018,89 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, + "node_modules/react-dnd-multi-backend": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/react-dnd-multi-backend/-/react-dnd-multi-backend-9.0.0.tgz", + "integrity": "sha512-LAKDdyj4oMvVA/k2RiJ8KLIPO9sBiYIjIYtoFCuAgml9qQwIq+oTav2IXGfG4DrP49fBnVO7jjf5ofJMNOlWTA==", + "license": "MIT", + "dependencies": { + "dnd-multi-backend": "^9.0.0", + "react-dnd-preview": "^9.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/LouisBrunner" + }, + "peerDependencies": { + "dnd-core": "^16.0.1", + "react": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0", + "react-dnd": "^16.0.1", + "react-dom": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dnd-preview": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/react-dnd-preview/-/react-dnd-preview-9.0.0.tgz", + "integrity": "sha512-WZTbrrNDlCGYJGrITHN/obI2kpdaKV3AY6Il2LLZcA9ApzG5bbDXBlWSFwuw8eTCMjmCXs5Wcv+p2QCMxX1Afw==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/LouisBrunner" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0", + "react-dnd": "^16.0.1" + } + }, + "node_modules/react-dnd-touch-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz", + "integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -13530,6 +14120,30 @@ "dev": true, "license": "MIT" }, + "node_modules/react-mosaic-component": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-mosaic-component/-/react-mosaic-component-6.1.1.tgz", + "integrity": "sha512-Ivuj6AxRDlo/H8OiEDU1mdgivxuKbwGOa5Ub6Yf+bHcu0JWioT7ttlpCWF63/gKrJBlRMB6fW9/eNOXINg9+Gg==", + "license": "Apache-2.0", + "dependencies": { + "classnames": "^2.3.2", + "immutability-helper": "^3.1.1", + "lodash": "^4.17.21", + "prop-types": "^15.8.1", + "rdndmb-html5-to-touch": "^8.0.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dnd-multi-backend": "^8.0.0", + "react-dnd-touch-backend": "^16.0.1", + "uuid": "^9.0.0" + }, + "funding": { + "url": "https://github.com/nomcopter/react-mosaic?sponsor=1" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -13739,6 +14353,15 @@ "node": ">=4" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13905,6 +14528,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -13984,9 +14608,9 @@ } }, "node_modules/rou3": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.7.tgz", - "integrity": "sha512-z+6o7c3DarUbuBMLIdhzj2CqJLtUWrGk4fZlf07dIMitX3UpBXeInJ3lMD9huxj9yh9eo1RqtXf9aL0YzkDDUA==", + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", "license": "MIT" }, "node_modules/rrweb-cssom": { @@ -14013,6 +14637,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -14514,20 +15139,11 @@ "dev": true, "license": "MIT" }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/srvx": { - "version": "0.8.9", - "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", - "integrity": "sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==", + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.9.8.tgz", + "integrity": "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ==", "license": "MIT", - "dependencies": { - "cookie-es": "^2.0.0" - }, "bin": { "srvx": "bin/srvx.mjs" }, @@ -14535,6 +15151,13 @@ "node": ">=20.16.0" } }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -14962,10 +15585,10 @@ } }, "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", - "license": "ISC", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -15690,6 +16313,41 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/unstorage": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.1.tgz", @@ -15995,13 +16653,26 @@ "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", - "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -16155,12 +16826,469 @@ } } }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "license": "MIT", - "workspaces": [ + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ "tests/deps/*", "tests/projects/*", "tests/projects/workspace/packages/*" @@ -17076,40 +18204,18 @@ } }, "node_modules/xmlbuilder2": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", - "integrity": "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", "license": "MIT", "dependencies": { - "@oozcitak/dom": "1.15.10", - "@oozcitak/infra": "1.0.8", - "@oozcitak/util": "8.3.8", - "js-yaml": "3.14.1" + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" }, "engines": { - "node": ">=12.0" - } - }, - "node_modules/xmlbuilder2/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/xmlbuilder2/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=20.0" } }, "node_modules/xmlchars": { diff --git a/app/package.json b/app/package.json index 65c60166..755cbc19 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,7 @@ "build": "vite build && vite build --config vite.wc.config.ts && tsc", "build:wc": "vite build --config vite.wc.config.ts", "serve": "vite preview", - "test": "vitest run", + "test": "vitest run --passWithNoTests", "lint": "eslint --ext .ts,.tsx src", "gen:openapi": "bash -lc 'cd ../api && cargo run --quiet --bin refmd -- openapi export > openapi.json'", "gen:client": "openapi-ts -i ../api/openapi.json -o src/shared/api/client -c legacy/fetch", @@ -50,6 +50,7 @@ "morphdom": "^2.7.7", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", "satori": "^0.18.3", "sonner": "^2.0.7", @@ -63,6 +64,11 @@ "y-websocket": "^1.5.4", "yjs": "^13.6.27" }, + "overrides": { + "react-dnd-multi-backend": "^9.0.0", + "dnd-multi-backend": "^9.0.0", + "react-dnd-preview": "^9.0.0" + }, "devDependencies": { "@hey-api/openapi-ts": "^0.86.10", "@tanstack/nitro-v2-vite-plugin": "^1.132.31", @@ -77,7 +83,8 @@ "@typescript-eslint/parser": "^8.8.1", "@vitejs/plugin-react": "^5.0.4", "eslint": "^9.12.0", - "eslint-plugin-boundaries": "^4.2.2", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-boundaries": "^5.3.1", "eslint-plugin-import": "^2.31.0", "jsdom": "^27.0.0", "prettier": "^3.5.3", diff --git a/app/src/features/auth/lib/runtime-context.ts b/app/src/features/auth/lib/runtime-context.ts index 13e59133..d216fb74 100644 --- a/app/src/features/auth/lib/runtime-context.ts +++ b/app/src/features/auth/lib/runtime-context.ts @@ -1,24 +1,11 @@ -import { getGlobalStartContext } from '@tanstack/start-client-core' - import type { AuthMiddlewareContext } from './types' let cachedContext: AuthMiddlewareContext | null | undefined function resolveContext(): AuthMiddlewareContext | null { - if (typeof window === 'undefined') { - return null - } - if (cachedContext !== undefined) { - return cachedContext - } - try { - const context = getGlobalStartContext() as { auth?: AuthMiddlewareContext } | undefined - cachedContext = context?.auth ?? null - } catch { - // Swallow transient errors but allow future calls to retry. - cachedContext = undefined - return null - } + if (typeof window === 'undefined') return null + if (cachedContext !== undefined) return cachedContext + cachedContext = null return cachedContext } diff --git a/app/src/features/auth/model/auth-context.tsx b/app/src/features/auth/model/auth-context.tsx index 9df2c30f..331a32b9 100644 --- a/app/src/features/auth/model/auth-context.tsx +++ b/app/src/features/auth/model/auth-context.tsx @@ -1,6 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { getGlobalStartContext } from '@tanstack/start-client-core' import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { ApiError } from '@/shared/api' @@ -53,13 +52,7 @@ type SignInOptions = { const Ctx = createContext(null) function readInitialAuthContext(): AuthMiddlewareContext | null { - if (typeof window === 'undefined') return null - try { - const context = getGlobalStartContext() as { auth?: AuthMiddlewareContext } | undefined - return context?.auth ?? null - } catch { - return null - } + return null } function readStoredWorkspaceId() { diff --git a/app/src/features/edit-document/hooks/useCollaborativeDocument.ts b/app/src/features/edit-document/hooks/useCollaborativeDocument.ts index 3279dece..647ef65e 100644 --- a/app/src/features/edit-document/hooks/useCollaborativeDocument.ts +++ b/app/src/features/edit-document/hooks/useCollaborativeDocument.ts @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query' import * as React from 'react' import { toast } from 'sonner' @@ -13,8 +14,101 @@ import { useAuthContext } from '@/features/auth' export type RealtimeStatus = 'connecting' | 'connected' | 'disconnected' -export function useCollaborativeDocument(id: string, shareToken?: string) { - const { permissions, loading: authLoading } = useAuthContext() +export type UseCollaborativeDocumentOptions = { + enabled?: boolean + contributeToRealtimeContext?: boolean + useUrlShareTokenFallback?: boolean + validateShareToken?: boolean + loadMeta?: boolean + trackAwareness?: boolean + disablePersistence?: boolean +} + +type ConnectionCacheEntry = { + refs: number + connection: YjsConnection | null + promise: Promise | null +} + +const connectionCache = new Map() +const invalidShareTokenToastShown = new Set() +const SHARE_TOKEN_VALIDATION_STALE_MS = 5 * 60 * 1000 +const DOCUMENT_META_STALE_MS = 60 * 1000 + +function buildCollaborativeDocumentConnectionCacheKey(args: { + documentId: string + token: string | undefined + disablePersistence: boolean + workspaceId: string | null | undefined +}) { + const workspaceScope = typeof args.workspaceId === 'string' ? args.workspaceId.trim() : '' + return `${args.documentId}::${args.token ?? ''}::ws:${workspaceScope}::p:${args.disablePersistence ? '0' : '1'}` +} + +function buildCacheKey( + documentId: string, + token: string | undefined, + disablePersistence: boolean, + workspaceId: string | null | undefined, +) { + return buildCollaborativeDocumentConnectionCacheKey({ documentId, token, disablePersistence, workspaceId }) +} + +async function acquireConnection( + documentId: string, + token: string | undefined, + disablePersistence: boolean, + workspaceId: string | null | undefined, +) { + const cacheKey = buildCacheKey(documentId, token, disablePersistence, workspaceId) + const existing = connectionCache.get(cacheKey) + if (existing) { + existing.refs += 1 + if (existing.connection) return { cacheKey, connection: existing.connection } + if (existing.promise) return { cacheKey, connection: await existing.promise } + } + + const entry: ConnectionCacheEntry = { refs: 1, connection: null, promise: null } + entry.promise = createYjsConnection(documentId, { + token: token ?? null, + connect: false, + disablePersistence, + }) + connectionCache.set(cacheKey, entry) + try { + const connection = await entry.promise + entry.connection = connection + entry.promise = null + return { cacheKey, connection } + } catch (error) { + connectionCache.delete(cacheKey) + throw error + } +} + +function releaseConnection(cacheKey: string) { + const entry = connectionCache.get(cacheKey) + if (!entry) return + entry.refs -= 1 + if (entry.refs > 0) return + connectionCache.delete(cacheKey) + destroyYjsConnection(entry.connection) +} + +export function useCollaborativeDocument( + id: string, + shareToken?: string, + options: UseCollaborativeDocumentOptions = {}, +) { + const queryClient = useQueryClient() + const { permissions, loading: authLoading, activeWorkspaceId } = useAuthContext() + const enabled = options.enabled ?? true + const contributeToRealtimeContext = options.contributeToRealtimeContext ?? true + const useUrlShareTokenFallback = options.useUrlShareTokenFallback ?? true + const shouldValidateShareToken = options.validateShareToken ?? true + const shouldLoadMeta = options.loadMeta ?? true + const trackAwareness = options.trackAwareness ?? true + const disablePersistence = options.disablePersistence ?? !contributeToRealtimeContext const { setDocumentId: setRealtimeDocumentId, setDocumentTitle, @@ -22,6 +116,7 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { setDocumentBadge, setDocumentActions, setDocumentPath, + setDocumentPluginId, setShowEditorFeatures, setConnected, setUserCount, @@ -34,29 +129,60 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { const [shareReadOnly, setShareReadOnly] = React.useState(false) const [error, setError] = React.useState(null) const connectionRef = React.useRef(null) + const cacheKeyRef = React.useRef(null) // Validate share token and set readonly. Also set documentId early for attachments. React.useEffect(() => { - setRealtimeDocumentId(id) - const token = resolveShareToken(shareToken) + if (!enabled) return + if (contributeToRealtimeContext) { + setRealtimeDocumentId(id) + } + const token = resolveShareToken(shareToken, useUrlShareTokenFallback) if (!token) { setShareReadOnly(false) return } + if (!shouldValidateShareToken) { + setShareReadOnly(false) + return + } + let cancelled = false ;(async () => { try { - const info = await validateShareToken(token) + const info = await queryClient.fetchQuery({ + queryKey: ['share-token', token], + queryFn: () => validateShareToken(token), + staleTime: SHARE_TOKEN_VALIDATION_STALE_MS, + }) + if (cancelled) return setShareReadOnly(info?.permission !== 'edit') } catch { - toast.error('Invalid or expired share link') + if (cancelled) return + if (!invalidShareTokenToastShown.has(token)) { + invalidShareTokenToastShown.add(token) + toast.error('Invalid or expired share link') + } setShareReadOnly(true) } })() - }, [id, shareToken]) + + return () => { + cancelled = true + } + }, [ + contributeToRealtimeContext, + enabled, + id, + queryClient, + shareToken, + shouldValidateShareToken, + useUrlShareTokenFallback, + ]) React.useEffect(() => { - const token = resolveShareToken(shareToken) + if (!enabled) return + const token = resolveShareToken(shareToken, useUrlShareTokenFallback) if (token) { setIsReadOnly(shareReadOnly || archived) return @@ -65,43 +191,72 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { if (authLoading) return const hasEditPermission = permissions.includes('doc:edit') setIsReadOnly(archived || !hasEditPermission) - }, [authLoading, shareReadOnly, archived, permissions, shareToken]) + }, [archived, authLoading, enabled, permissions, shareReadOnly, shareToken, useUrlShareTokenFallback]) const loadMeta = React.useCallback(async () => { + if (!shouldLoadMeta) return try { - const token = resolveShareToken(shareToken) - const meta = await fetchDocumentMeta(id, token ?? undefined) + const token = resolveShareToken(shareToken, useUrlShareTokenFallback) + const meta = await queryClient.fetchQuery({ + queryKey: ['document-meta', id, token ?? null], + queryFn: () => fetchDocumentMeta(id, token ?? undefined), + staleTime: DOCUMENT_META_STALE_MS, + }) if (meta) { const isDocArchived = Boolean(meta.archived_at) setArchived(isDocArchived) - setDocumentTitle(meta.title) - setDocumentStatus(isDocArchived ? 'Archived document' : undefined) - setDocumentBadge(isDocArchived ? 'Archived' : undefined) - setDocumentActions([]) - setDocumentPath(undefined) - setRealtimeDocumentId(id) - setShowEditorFeatures(true) + if (contributeToRealtimeContext) { + const pluginId = typeof (meta as any).created_by_plugin === 'string' ? String((meta as any).created_by_plugin).trim() : '' + setDocumentTitle(meta.title) + setDocumentStatus(isDocArchived ? 'Archived document' : undefined) + setDocumentBadge(isDocArchived ? 'Archived' : undefined) + setDocumentActions([]) + setDocumentPath(undefined) + setRealtimeDocumentId(id) + setDocumentPluginId(pluginId || undefined) + setShowEditorFeatures(true) + } } } catch { /* ignore meta load failures */ } }, [ id, + queryClient, shareToken, setDocumentTitle, setDocumentStatus, setDocumentBadge, setDocumentActions, setDocumentPath, + setDocumentPluginId, setRealtimeDocumentId, setShowEditorFeatures, + contributeToRealtimeContext, + useUrlShareTokenFallback, + shouldLoadMeta, ]) React.useEffect(() => { + if (!enabled) { + setStatus('disconnected') + setError(null) + if (cacheKeyRef.current) { + releaseConnection(cacheKeyRef.current) + cacheKeyRef.current = null + } + connectionRef.current = null + return () => {} + } + setStatus('connecting') setError(null) connectionRef.current = null + cacheKeyRef.current = null + let cancelled = false + let cleanupProvider: any | null = null + let cleanupCacheKey: string | null = null let onStatus: ((ev: { status: string }) => void) | null = null let onAwareness: (() => void) | null = null let onOnline: (() => void) | null = null @@ -110,50 +265,68 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { ;(async () => { try { - const urlShareToken = resolveShareToken(shareToken) + const urlShareToken = resolveShareToken(shareToken, useUrlShareTokenFallback) - const connection = await createYjsConnection(id, { - token: urlShareToken, - connect: false, - }) + const acquired = await acquireConnection(id, urlShareToken ?? undefined, disablePersistence, activeWorkspaceId) + if (cancelled) { + releaseConnection(acquired.cacheKey) + return + } + const { cacheKey, connection } = acquired + cacheKeyRef.current = cacheKey connectionRef.current = connection + cleanupCacheKey = cacheKey const { provider } = connection + cleanupProvider = provider - const isOnline = typeof navigator === 'undefined' ? true : navigator.onLine - provider.shouldConnect = isOnline - if (isOnline) { - provider.connect() - } else { - setStatus('disconnected') - setConnected(false) - lastStatus = 'disconnected' + const updateStatus = (next: RealtimeStatus) => { + if (cancelled) return + setStatus(next) + if (contributeToRealtimeContext) { + setConnected(next === 'connected') + } + lastStatus = next } onStatus = (ev: { status: string }) => { if (ev.status === 'connected') { - setStatus('connected') - setConnected(true) - lastStatus = 'connected' + updateStatus('connected') } else if (ev.status === 'disconnected') { - setStatus('disconnected') - setConnected(false) - const shouldNotify = typeof navigator === 'undefined' ? true : navigator.onLine - if (shouldNotify && lastStatus !== 'disconnected') toast.error('Disconnected from realtime server') - lastStatus = 'disconnected' + updateStatus('disconnected') + if (contributeToRealtimeContext) { + const shouldNotify = typeof navigator === 'undefined' ? true : navigator.onLine + if (shouldNotify && lastStatus !== 'disconnected') toast.error('Disconnected from realtime server') + } } else { - setStatus('connecting') - lastStatus = 'connecting' + updateStatus('connecting') } } provider.on('status', onStatus) + const isOnline = typeof navigator === 'undefined' ? true : navigator.onLine + provider.shouldConnect = isOnline + const isProviderConnected = (() => { + const anyProvider = provider as any + if (typeof anyProvider?.wsconnected === 'boolean') return anyProvider.wsconnected + const ws = anyProvider?.ws + return Boolean(ws && typeof ws.readyState === 'number' && ws.readyState === 1) + })() + + if (!isOnline) { + updateStatus('disconnected') + } else if (isProviderConnected) { + updateStatus('connected') + } else { + updateStatus('connecting') + provider.connect() + } + onOnline = () => { provider.shouldConnect = true try { provider.connect() - setStatus('connecting') - lastStatus = 'connecting' + updateStatus('connecting') } catch {} } @@ -162,67 +335,80 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { try { provider.disconnect() } catch {} - setStatus('disconnected') - setConnected(false) - lastStatus = 'disconnected' + updateStatus('disconnected') } window.addEventListener('online', onOnline) window.addEventListener('offline', onOffline) - const prevCountRef = { current: userCount } - const lastIdsRef = { current: new Set() } - onAwareness = () => { - const states = provider.awareness.getStates() as Map - const seen = new Map() - states.forEach((st: any, clientId: number) => { - const u = st?.user - if (!u) return - const hasId = typeof u.id === 'string' && u.id.trim().length > 0 - const hasName = typeof u.name === 'string' && u.name.trim().length > 0 - if (!hasId && !hasName) return - const uid = hasId ? String(u.id) : `name:${String(u.name)}` - const name = hasName ? String(u.name) : String(u.id) - const color = typeof u.color === 'string' ? (u.color as string) : undefined - if (!seen.has(uid)) seen.set(uid, { id: uid, name, color, clientId }) - }) - const list = Array.from(seen.values()) - const uniqueCount = list.length - if (uniqueCount !== prevCountRef.current) { - prevCountRef.current = uniqueCount - setUserCount(uniqueCount) - } - const ids = new Set(list.map((u) => u.id)) - let changed = ids.size !== lastIdsRef.current.size - if (!changed) { - for (const id of ids) { - if (!lastIdsRef.current.has(id)) { - changed = true - break + if (trackAwareness || contributeToRealtimeContext) { + const prevCountRef = { current: userCount } + const lastIdsRef = { current: new Set() } + onAwareness = () => { + const states = provider.awareness.getStates() as Map + const seen = new Map() + states.forEach((st: any, clientId: number) => { + const u = st?.user + if (!u) return + const hasId = typeof u.id === 'string' && u.id.trim().length > 0 + const hasName = typeof u.name === 'string' && u.name.trim().length > 0 + if (!hasId && !hasName) return + const uid = hasId ? String(u.id) : `name:${String(u.name)}` + const name = hasName ? String(u.name) : String(u.id) + const color = typeof u.color === 'string' ? (u.color as string) : undefined + if (!seen.has(uid)) seen.set(uid, { id: uid, name, color, clientId }) + }) + const list = Array.from(seen.values()) + const uniqueCount = list.length + if (contributeToRealtimeContext && uniqueCount !== prevCountRef.current) { + prevCountRef.current = uniqueCount + setUserCount(uniqueCount) + } + const ids = new Set(list.map((u) => u.id)) + let changed = ids.size !== lastIdsRef.current.size + if (!changed) { + for (const id of ids) { + if (!lastIdsRef.current.has(id)) { + changed = true + break + } + } + } + if (changed) { + lastIdsRef.current = ids + if (contributeToRealtimeContext) { + setOnlineUsers(list) } } } - if (changed) { - lastIdsRef.current = ids - setOnlineUsers(list) - } + provider.awareness.on('update', onAwareness) } - provider.awareness.on('update', onAwareness) await loadMeta() } catch (err) { console.error('[collaboration] failed to initialise realtime session', id, err) - setStatus('disconnected') - setError('Failed to establish realtime connection. Please reload.') - setConnected(false) - destroyYjsConnection(connectionRef.current) + if (!cancelled) { + setStatus('disconnected') + setError('Failed to establish realtime connection. Please reload.') + if (contributeToRealtimeContext) { + setConnected(false) + } + } + if (cleanupCacheKey) { + releaseConnection(cleanupCacheKey) + cleanupCacheKey = null + } else if (cacheKeyRef.current) { + releaseConnection(cacheKeyRef.current) + cacheKeyRef.current = null + } + cleanupProvider = null connectionRef.current = null } })() return () => { - const connection = connectionRef.current - const provider = connection?.provider + cancelled = true + const provider = cleanupProvider ?? connectionRef.current?.provider if (provider) { try { if (onStatus) provider.off('status', onStatus) @@ -237,26 +423,47 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { if (onOffline) { try { window.removeEventListener('offline', onOffline) } catch {} } - destroyYjsConnection(connectionRef.current) + if (cleanupCacheKey) { + releaseConnection(cleanupCacheKey) + cleanupCacheKey = null + } else if (cacheKeyRef.current) { + releaseConnection(cacheKeyRef.current) + cacheKeyRef.current = null + } + cleanupProvider = null connectionRef.current = null - setShowEditorFeatures(false) - setUserCount(0) - setOnlineUsers([]) - setConnected(false) - setDocumentTitle(undefined) - setDocumentStatus(undefined) - setDocumentBadge(undefined) - setDocumentActions([]) - setDocumentPath(undefined) + if (contributeToRealtimeContext) { + setShowEditorFeatures(false) + setUserCount(0) + setOnlineUsers([]) + setConnected(false) + setDocumentTitle(undefined) + setDocumentStatus(undefined) + setDocumentBadge(undefined) + setDocumentActions([]) + setDocumentPath(undefined) + setDocumentPluginId(undefined) + } setArchived(false) setShareReadOnly(false) setIsReadOnly(false) setError(null) } - }, [id, shareToken, loadMeta]) + }, [ + id, + shareToken, + loadMeta, + contributeToRealtimeContext, + disablePersistence, + enabled, + useUrlShareTokenFallback, + trackAwareness, + activeWorkspaceId, + ]) React.useEffect(() => { if (typeof window === 'undefined') return + if (!enabled) return const handler = (event: Event) => { const detail = (event as CustomEvent<{ id?: string }>).detail if (detail?.id === id) { @@ -267,7 +474,7 @@ export function useCollaborativeDocument(id: string, shareToken?: string) { return () => { window.removeEventListener('refmd:document-archive-change', handler as EventListener) } - }, [id, loadMeta]) + }, [enabled, id, loadMeta]) return { status, @@ -286,11 +493,12 @@ function normalizeShareToken(token?: string | null) { return trimmed.length > 0 ? trimmed : undefined } -function resolveShareToken(explicitToken?: string) { +function resolveShareToken(explicitToken: string | undefined, useUrlFallback: boolean) { const normalized = normalizeShareToken(explicitToken) if (normalized) return normalized if (typeof window === 'undefined') return undefined + if (!useUrlFallback) return undefined try { const candidate = new URLSearchParams(window.location.search).get('token') diff --git a/app/src/features/edit-document/hooks/useScrollSync.ts b/app/src/features/edit-document/hooks/useScrollSync.ts index b26067d7..bc604775 100644 --- a/app/src/features/edit-document/hooks/useScrollSync.ts +++ b/app/src/features/edit-document/hooks/useScrollSync.ts @@ -1,6 +1,10 @@ import type * as monacoNs from 'monaco-editor' import { useCallback, useRef, useState } from 'react' +function isMonacoDisposedError(error: unknown) { + return error instanceof Error && /InstantiationService has been disposed/i.test(error.message) +} + export function useScrollSync(editorRef: React.MutableRefObject) { const isSyncingRef = useRef(false) const [previewScrollPct, setPreviewScrollPct] = useState(undefined) @@ -20,46 +24,51 @@ export function useScrollSync(editorRef: React.MutableRefObject { try { - const height = ed.getScrollHeight?.() ?? 0 - const viewHeight = ed.getLayoutInfo?.().height ?? 0 - const denom = Math.max(1, height - viewHeight) - const top = e?.scrollTop ?? ed.getScrollTop() - const prevDenom = prevDenomRef.current || denom - const prevTop = prevTopRef.current || 0 + try { + const height = ed.getScrollHeight?.() ?? 0 + const viewHeight = ed.getLayoutInfo?.().height ?? 0 + const denom = Math.max(1, height - viewHeight) + const top = e?.scrollTop ?? ed.getScrollTop() + const prevDenom = prevDenomRef.current || denom + const prevTop = prevTopRef.current || 0 - // Heuristic: if content height grew but scrollTop barely changed, - // treat this as content insertion (not user scroll) and anchor - // preview percentage to previous denominator to avoid upward drift. - const denomIncreased = denom > prevDenom + 0.5 - const topUnchanged = Math.abs(top - prevTop) <= 2 - const baselineDenom = (denomIncreased && topUnchanged) ? prevDenom : denom + // Heuristic: if content height grew but scrollTop barely changed, + // treat this as content insertion (not user scroll) and anchor + // preview percentage to previous denominator to avoid upward drift. + const denomIncreased = denom > prevDenom + 0.5 + const topUnchanged = Math.abs(top - prevTop) <= 2 + const baselineDenom = (denomIncreased && topUnchanged) ? prevDenom : denom - // Determine if editor was pinned to bottom as of previous metrics. - const distFromBottomPrev = Math.max(0, prevDenom - prevTop) - const pinnedPrev = distFromBottomPrev <= 4 - pinnedEditorBottomRef.current = pinnedPrev - const now = Date.now() - const locked = lockUntilRef.current > now - // Visible top line for source-anchored sync - let topLine: number | undefined - try { - const vrs = (ed as any).getVisibleRanges?.() || [] - if (vrs && vrs.length > 0) topLine = vrs[0].startLineNumber - else topLine = (ed as any).getPosition?.()?.lineNumber - } catch {} + // Determine if editor was pinned to bottom as of previous metrics. + const distFromBottomPrev = Math.max(0, prevDenom - prevTop) + const pinnedPrev = distFromBottomPrev <= 4 + pinnedEditorBottomRef.current = pinnedPrev + const now = Date.now() + const locked = lockUntilRef.current > now + // Visible top line for source-anchored sync + let topLine: number | undefined + try { + const vrs = (ed as any).getVisibleRanges?.() || [] + if (vrs && vrs.length > 0) topLine = vrs[0].startLineNumber + else topLine = (ed as any).getPosition?.()?.lineNumber + } catch {} - const pct = (pinnedPrev || locked) - ? 1 - : Math.min(1, Math.max(0, top / baselineDenom)) + const pct = (pinnedPrev || locked) + ? 1 + : Math.min(1, Math.max(0, top / baselineDenom)) - // Prefer anchor-line when not pinned/locked; else rely on bottom lock - if (pinnedPrev || locked) setPreviewAnchorLine(undefined) - else if (typeof topLine === 'number' && Number.isFinite(topLine)) setPreviewAnchorLine(topLine) - else setPreviewAnchorLine(undefined) - prevDenomRef.current = denom - prevTopRef.current = top - isSyncingRef.current = true - setPreviewScrollPct(pct) + // Prefer anchor-line when not pinned/locked; else rely on bottom lock + if (pinnedPrev || locked) setPreviewAnchorLine(undefined) + else if (typeof topLine === 'number' && Number.isFinite(topLine)) setPreviewAnchorLine(topLine) + else setPreviewAnchorLine(undefined) + prevDenomRef.current = denom + prevTopRef.current = top + isSyncingRef.current = true + setPreviewScrollPct(pct) + } catch (error) { + if (isMonacoDisposedError(error)) return + throw error + } } finally { setTimeout(() => { isSyncingRef.current = false }, 0) rafRef.current = null @@ -77,7 +86,12 @@ export function useScrollSync(editorRef: React.MutableRefObject= 0.999 ? denom : Math.round(denom * pct) - ed.setScrollTop(target) + try { + ed.setScrollTop(target) + } catch (error) { + if (isMonacoDisposedError(error)) return + throw error + } } finally { isSyncingRef.current = false } diff --git a/app/src/features/edit-document/model/editor-context.tsx b/app/src/features/edit-document/model/editor-context.tsx index 70811713..db7ae6d3 100644 --- a/app/src/features/edit-document/model/editor-context.tsx +++ b/app/src/features/edit-document/model/editor-context.tsx @@ -1,16 +1,35 @@ import type * as monaco from 'monaco-editor' -import React, { createContext, useContext, useMemo, useState } from 'react' +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' type Ctx = { editor: monaco.editor.IStandaloneCodeEditor | null setEditor: (ed: monaco.editor.IStandaloneCodeEditor | null) => void + registerEditor: (ed: monaco.editor.IStandaloneCodeEditor) => () => void } const EditorCtx = createContext(null) export function EditorProvider({ children }: { children: React.ReactNode }) { const [editor, setEditor] = useState(null) - const value = useMemo(() => ({ editor, setEditor }), [editor]) + const editorsRef = useRef>(new Set()) + + const registerEditor = useCallback((ed: monaco.editor.IStandaloneCodeEditor) => { + editorsRef.current.add(ed) + setEditor((current) => current ?? ed) + let released = false + return () => { + if (released) return + released = true + editorsRef.current.delete(ed) + setEditor((current) => { + if (current !== ed) return current + const next = editorsRef.current.values().next().value ?? null + return next + }) + } + }, []) + + const value = useMemo(() => ({ editor, setEditor, registerEditor }), [editor, registerEditor]) return {children} } @@ -19,4 +38,3 @@ export function useEditorContext() { if (!v) throw new Error('useEditorContext must be used within EditorProvider') return v } - diff --git a/app/src/features/edit-document/model/view-context.tsx b/app/src/features/edit-document/model/view-context.tsx index 93fc2344..1e6e1107 100644 --- a/app/src/features/edit-document/model/view-context.tsx +++ b/app/src/features/edit-document/model/view-context.tsx @@ -1,6 +1,5 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { useShortcut } from '@/shared/hooks/use-shortcut' import type { ViewMode } from '@/shared/types/view-mode' const VIEW_MODE_STORAGE_KEY = 'refmd-view-mode' @@ -12,9 +11,6 @@ type Ctx = { setViewMode: (mode: ViewModeSetter) => void viewModeHydrated: boolean hasPersistentViewMode: boolean - showBacklinks: boolean - setShowBacklinks: (v: boolean) => void - toggleBacklinks: () => void // Search request trigger for Header's SearchDialog searchPresetTag: string | null searchNonce: number @@ -24,10 +20,9 @@ type Ctx = { const ViewCtx = createContext(null) export function ViewProvider({ children }: { children: React.ReactNode }) { - const [viewMode, setViewModeState] = useState('split') + const [viewMode, setViewModeState] = useState('editor') const [viewModeHydrated, setViewModeHydrated] = useState(() => typeof window === 'undefined') const [hasPersistentViewMode, setHasPersistentViewMode] = useState(false) - const [showBacklinks, setShowBacklinks] = useState(false) const [searchPresetTag, setSearchPresetTag] = useState(null) const [searchNonce, setSearchNonce] = useState(0) @@ -37,9 +32,13 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { } try { const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY) - if (saved === 'editor' || saved === 'split' || saved === 'preview') { + if (saved === 'editor' || saved === 'preview') { setViewModeState(saved) setHasPersistentViewMode(true) + } else if (saved === 'split') { + setViewModeState('editor') + try { localStorage.setItem(VIEW_MODE_STORAGE_KEY, 'editor') } catch {} + setHasPersistentViewMode(true) } } catch { /* noop */ @@ -64,7 +63,6 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { }) }, []) - const toggleBacklinks = useCallback(() => setShowBacklinks((v) => !v), []) const openSearch = useCallback((presetTag?: string | null) => { setSearchPresetTag(presetTag ?? null) setSearchNonce((n) => n + 1) @@ -81,46 +79,15 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { return () => { window.removeEventListener('refmd:open-search', handler as EventListener) } }, [openSearch]) - useShortcut( - 'view.mode.editor', - useCallback(() => { - setViewMode('editor') - }, [setViewMode]), - ) - - useShortcut( - 'view.mode.split', - useCallback(() => { - setViewMode('split') - }, [setViewMode]), - ) - - useShortcut( - 'view.mode.preview', - useCallback(() => { - setViewMode('preview') - }, [setViewMode]), - ) - - useShortcut( - 'view.backlinks.toggle', - useCallback(() => { - toggleBacklinks() - }, [toggleBacklinks]), - ) - const value = useMemo(() => ({ viewMode, viewModeHydrated, hasPersistentViewMode, setViewMode, - showBacklinks, - setShowBacklinks, - toggleBacklinks, searchPresetTag, searchNonce, openSearch, - }), [viewMode, viewModeHydrated, hasPersistentViewMode, showBacklinks, toggleBacklinks, searchPresetTag, searchNonce, openSearch, setViewMode]) + }), [viewMode, viewModeHydrated, hasPersistentViewMode, searchPresetTag, searchNonce, openSearch, setViewMode]) return {children} } diff --git a/app/src/features/edit-document/public/useViewController.ts b/app/src/features/edit-document/public/useViewController.ts index fd4e59ed..194b2e75 100644 --- a/app/src/features/edit-document/public/useViewController.ts +++ b/app/src/features/edit-document/public/useViewController.ts @@ -8,9 +8,7 @@ export function useViewController() { const ctx = useViewContext() return useMemo(() => ({ viewMode: ctx.viewMode as ViewMode, - showBacklinks: ctx.showBacklinks, setViewMode: (mode: ViewMode) => ctx.setViewMode(mode), - toggleBacklinks: () => ctx.toggleBacklinks(), openSearch: (presetTag?: string) => ctx.openSearch(presetTag), searchPresetTag: ctx.searchPresetTag, searchNonce: ctx.searchNonce, diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 1d3e5f1b..864fc976 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -1,14 +1,16 @@ import type { OnMount } from '@monaco-editor/react' -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useRouterState } from '@tanstack/react-router' import type * as monacoNs from 'monaco-editor' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import type { Awareness } from 'y-protocols/awareness' import * as Y from 'yjs' +import { useShareToken } from '@/shared/contexts/share-token-context' import { useTheme } from '@/shared/contexts/theme-context' import { useIsMobile } from '@/shared/hooks/use-mobile' import { useShortcut } from '@/shared/hooks/use-shortcut' +import { MOSAIC_SCROLL_SYNC_EVENT, type MosaicScrollSyncDetail, dispatchMosaicScrollSync } from '@/shared/lib/mosaic-events' import type { ViewMode } from '@/shared/types/view-mode' import { listDocuments } from '@/entities/document' @@ -31,6 +33,9 @@ import type { PreviewPaneProps } from './PreviewPane' import EditorToolbar from './Toolbar' const logEditorError = (scope: string, error: unknown) => { + if (error instanceof Error && /InstantiationService has been disposed/i.test(error.message)) { + return + } if (error instanceof Error) { console.error(`[editor] ${scope}:`, error) } else { @@ -51,6 +56,9 @@ export type MarkdownEditorProps = { awareness: Awareness connected: boolean initialView?: ViewMode + forcedView?: ViewMode + embedded?: boolean + scrollSyncGroupId?: string | null userName?: string userId?: string documentId: string @@ -86,7 +94,10 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const { doc, awareness, - initialView: initialViewProp = 'split', + initialView: initialViewProp = 'editor', + forcedView, + embedded = false, + scrollSyncGroupId = null, userId, userName, documentId, @@ -101,15 +112,38 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } = props const { isDarkMode } = useTheme() const isMobile = useIsMobile() - const { setEditor } = useEditorContext() + const { editor: activeEditor, setEditor, registerEditor } = useEditorContext() const { viewMode, setViewMode, viewModeHydrated, hasPersistentViewMode } = useViewContext() const navigate = useNavigate() + const shareToken = useShareToken() + const shareScope = useRouterState({ + select: (state) => { + const raw = (state.location?.search as any)?.shareScope + const scope = typeof raw === 'string' ? raw : null + return scope === 'folder' || scope === 'document' ? scope : null + }, + }) + const isShareMount = useRouterState({ + select: (state) => { + const search = (state.location?.search ?? {}) as Record + const raw = (search as any)?.shareMount ?? (search as any)?.share_mount + if (raw == null) return false + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase() + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on' + } + if (typeof raw === 'number') return raw === 1 + return Boolean(raw) + }, + }) + const isShareLink = Boolean(shareToken && !isShareMount) const brandedMonacoTheme = isDarkMode ? REFMD_DARK_THEME : REFMD_LIGHT_THEME const monacoTheme = brandedMonacoTheme - const view = viewMode + const view = forcedView ?? viewMode const [isVimMode, setIsVimMode] = useState(() => typeof window !== 'undefined' && localStorage.getItem('editorVimMode') === 'true') const [syncScroll, setSyncScroll] = useState(true) const [toolbarOpen, setToolbarOpen] = useState(false) + const [editorMountNonce, setEditorMountNonce] = useState(0) const readOnlyWarningRef = useRef(0) const emitReadOnlyWarning = useCallback(() => { if (!readOnly) return @@ -123,16 +157,38 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const vimModeRef = useRef<{ dispose: () => void } | null>(null) const vimStatusBarRef = useRef(null) const fileInputRef = useRef(null) - const viewRef = useRef(initialViewProp) + const viewRef = useRef(forcedView ?? initialViewProp) useEffect(() => { - viewRef.current = viewMode - }, [viewMode]) + viewRef.current = view as ViewMode + }, [view]) const { onMount: onMonacoMount, text: boundText, editorRef } = useMonacoBinding({ doc, awareness, language: 'markdown', onTextChange: () => {}, }) + const mosaicGroupIdRef = useRef(scrollSyncGroupId) + useEffect(() => { + mosaicGroupIdRef.current = scrollSyncGroupId + }, [scrollSyncGroupId]) + const mosaicScrollRafRef = useRef(null) + const suppressMosaicEmitRef = useRef(false) + const suppressMosaicTimeoutRef = useRef(null) + const unregisterEditorRef = useRef void)>(null) + const focusDisposableRef = useRef void }>(null) + const blurDisposableRef = useRef void }>(null) + + const isThisEditorActive = useCallback(() => { + const ed = editorRef.current + if (!ed) return false + return activeEditor === ed + }, [activeEditor, editorRef]) + + const ensureThisEditorActive = useCallback(() => { + const ed = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null + if (!ed) return + if (activeEditor !== ed) setEditor(ed as any) + }, [activeEditor, editorRef, setEditor]) const disableVimMode = useCallback(() => { if (vimModeRef.current) { safeExecute('disable vim mode', () => vimModeRef.current?.dispose()) @@ -169,11 +225,12 @@ export function MarkdownEditor(props: MarkdownEditorProps) { ;(onMonacoMount as any)._onCaretAtEnd = onCaretAtEndChange useEffect(() => { if (!viewModeHydrated) return + if (forcedView) return if (hasPersistentViewMode) return if (!initialViewProp) return if (viewMode === initialViewProp) return safeExecute('set initial view mode', () => setViewMode(initialViewProp)) - }, [hasPersistentViewMode, initialViewProp, setViewMode, viewMode, viewModeHydrated]) + }, [forcedView, hasPersistentViewMode, initialViewProp, setViewMode, viewMode, viewModeHydrated]) useAwarenessStyles(awareness, { userId, userName }) @@ -334,6 +391,34 @@ export function MarkdownEditor(props: MarkdownEditorProps) { }) ;(editor as any).__disposeScroll = () => safeExecute('dispose scroll listener', () => scrollDispose?.dispose?.()) + // Mosaic scroll sync: emit current top line to paired preview tile (by group) + try { + const mosaicScrollDispose = editor.onDidScrollChange?.(() => { + const groupId = mosaicGroupIdRef.current + if (!groupId) return + if (!syncScrollRef.current) return + if (suppressMosaicEmitRef.current) return + if (mosaicScrollRafRef.current != null) return + mosaicScrollRafRef.current = window.requestAnimationFrame(() => { + mosaicScrollRafRef.current = null + try { + if ((editor as any)?._isDisposed === true) return + const domNode = editor.getDomNode?.() + if (!domNode) return + const range = editor.getVisibleRanges?.()?.[0] + const line = range?.startLineNumber ?? editor.getPosition?.()?.lineNumber ?? 1 + if (!Number.isFinite(line) || line < 1) return + dispatchMosaicScrollSync({ groupId, source: 'editor', line }) + } catch (error) { + logEditorError('mosaic scroll sync emit', error) + } + }) + }) + ;(editor as any).__disposeMosaicScroll = () => safeExecute('dispose mosaic scroll listener', () => mosaicScrollDispose?.dispose?.()) + } catch (error) { + logEditorError('register mosaic scroll listener', error) + } + // Handle paste (Ctrl+V) with files from clipboard const dom = editor.getDomNode() as HTMLElement | null const pasteHandler = async (event: ClipboardEvent) => { @@ -397,6 +482,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const anyEditor = editorRef.current as (monacoNs.editor.IStandaloneCodeEditor & { __readOnlyOverlay?: { widget: monacoNs.editor.IOverlayWidget; domNode: HTMLElement }; __monaco?: typeof monacoNs }) | undefined safeExecute('dispose change listener', () => (anyEditor as any)?.__disposeChange?.()) safeExecute('dispose scroll listener', () => (anyEditor as any)?.__disposeScroll?.()) + safeExecute('dispose mosaic scroll listener', () => (anyEditor as any)?.__disposeMosaicScroll?.()) safeExecute('dispose paste handler', () => (anyEditor as any)?.__disposePaste?.()) safeExecute('dispose wiki handler', () => (anyEditor as any)?.__disposeWiki?.()) safeExecute('dispose cursor handler', () => (anyEditor as any)?.__disposeCursor?.()) @@ -412,10 +498,80 @@ export function MarkdownEditor(props: MarkdownEditorProps) { delete (anyEditor as any).__monaco } }) + safeExecute('dispose editor focus listener', () => focusDisposableRef.current?.dispose()) + focusDisposableRef.current = null + safeExecute('dispose editor blur listener', () => blurDisposableRef.current?.dispose()) + blurDisposableRef.current = null + safeExecute('unregister editor instance', () => unregisterEditorRef.current?.()) + unregisterEditorRef.current = null + safeExecute('cancel mosaic scroll raf', () => { + if (mosaicScrollRafRef.current != null) { + window.cancelAnimationFrame(mosaicScrollRafRef.current) + mosaicScrollRafRef.current = null + } + }) + safeExecute('cancel mosaic suppress timeout', () => { + if (suppressMosaicTimeoutRef.current != null) { + window.clearTimeout(suppressMosaicTimeoutRef.current) + suppressMosaicTimeoutRef.current = null + } + suppressMosaicEmitRef.current = false + }) disableVimMode() - setEditor(null) }, [editorRef, setEditor, disableVimMode]) + useEffect(() => { + if (typeof window === 'undefined') return + if (!scrollSyncGroupId) return + const handler = (event: Event) => { + try { + if (!syncScrollRef.current) return + const detail = (event as CustomEvent).detail + if (!detail || detail.source !== 'preview') return + if (detail.groupId !== scrollSyncGroupId) return + const line = detail.line + if (!Number.isFinite(line) || (line as number) < 1) return + + const editorInstance = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null + if (!editorInstance) return + if ((editorInstance as any)?._isDisposed === true) return + const domNode = editorInstance.getDomNode?.() + if (!domNode) return + + const model = editorInstance.getModel?.() + if (!model) return + const maxLine = model.getLineCount?.() ?? null + const clamped = maxLine + ? Math.min(maxLine, Math.max(1, Math.floor(line as number))) + : Math.max(1, Math.floor(line as number)) + + if (suppressMosaicTimeoutRef.current != null) { + window.clearTimeout(suppressMosaicTimeoutRef.current) + suppressMosaicTimeoutRef.current = null + } + suppressMosaicEmitRef.current = true + try { + ;(editorInstance as any).revealLineNearTop?.(clamped) + } catch (error) { + // Avoid noisy errors when editor is being disposed during tile close/layout changes. + if (error instanceof Error && /InstantiationService has been disposed/i.test(error.message)) return + throw error + } finally { + suppressMosaicTimeoutRef.current = window.setTimeout(() => { + suppressMosaicTimeoutRef.current = null + suppressMosaicEmitRef.current = false + }, 120) + } + } catch (error) { + logEditorError('mosaic scroll sync receive', error) + } + } + window.addEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) + return () => { + window.removeEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) + } + }, [editorRef, scrollSyncGroupId]) + const toggleVim = useCallback(async () => { const next = !isVimMode setIsVimMode(next) @@ -432,8 +588,9 @@ export function MarkdownEditor(props: MarkdownEditorProps) { emitReadOnlyWarning() return } + ensureThisEditorActive() if (fileInputRef.current) fileInputRef.current.click() - }, [emitReadOnlyWarning, readOnly]) + }, [emitReadOnlyWarning, readOnly, ensureThisEditorActive]) // uploadFiles provided by hook @@ -445,28 +602,39 @@ export function MarkdownEditor(props: MarkdownEditorProps) { viewMode={view as ViewMode} syncScroll={syncScroll} onSyncScrollToggle={() => setSyncScroll((s) => !s)} + syncScrollAvailable={Boolean(scrollSyncGroupId)} isVimMode={isVimMode} onVimModeToggle={toggleVim} onFileUpload={readOnly ? undefined : handleFileUpload} readOnly={readOnly} /> - ), [handleToolbarCommand, view, syncScroll, isVimMode, toggleVim, handleFileUpload, readOnly]) + ), [handleToolbarCommand, view, syncScroll, scrollSyncGroupId, isVimMode, toggleVim, handleFileUpload, readOnly]) const shortcutToggleSync = useCallback(() => { + if (!isThisEditorActive()) return setSyncScroll((value) => !value) - }, []) + }, [isThisEditorActive]) const shortcutToggleVim = useCallback(() => { + if (!isThisEditorActive()) return void toggleVim() - }, [toggleVim]) + }, [isThisEditorActive, toggleVim]) + const shortcutUpload = useCallback(() => { + if (!isThisEditorActive()) return + handleFileUpload() + }, [handleFileUpload, isThisEditorActive]) useShortcut('editor.sync-scroll.toggle', shortcutToggleSync) useShortcut('editor.vim.toggle', shortcutToggleVim) - useShortcut('editor.upload.trigger', handleFileUpload) + useShortcut('editor.upload.trigger', shortcutUpload) const onPreviewNavigate = useCallback(async (target: string) => { + if (isShareLink && shareScope === 'document') { + toast.info('This share link is for a single document.') + return + } const uuidRe = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ let id = target - if (!uuidRe.test(target)) { + if (!uuidRe.test(target) && !shareToken) { try { const resp = await listDocuments({ query: target }) const items = (resp.items ?? []) as unknown as Array<{ id: string; title: string }> @@ -479,71 +647,115 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } if (uuidRe.test(id)) { try { - navigate({ to: '/document/$id', params: { id } }) + navigate({ + to: '/document/$id', + params: { id }, + search: (prev: Record) => { + if (!shareToken) return prev + const next: Record = { ...(prev || {}), token: shareToken } + if (shareScope) next.shareScope = shareScope + if (isShareMount) next.shareMount = '1' + return next + }, + }) } catch (error) { logEditorError('navigate to document from preview', error) - window.location.href = `/document/${id}` + const qs = shareToken ? `?token=${encodeURIComponent(shareToken)}` : '' + window.location.href = `/document/${id}${qs}` } } - }, [navigate]) + }, [isShareLink, isShareMount, navigate, shareScope, shareToken]) // Ensure Monaco relayouts when view/layout changes or container resizes useEffect(() => { const ed = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null if (!ed) return - const relayout = () => safeExecute('editor relayout', () => ed.layout()) + const relayoutToContainer = () => { + safeExecute('editor relayout', () => { + const container = (ed as any).getContainerDomNode?.() as HTMLElement | null + const node = ed.getDomNode?.() as HTMLElement | null + const target = container || node?.parentElement || node + if (!target) { + ed.layout() + return + } + const rect = target.getBoundingClientRect() + if (!rect.width || !rect.height) { + ed.layout() + return + } + ed.layout({ width: rect.width, height: rect.height }) + }) + } // immediate relayout on view change - relayout() + relayoutToContainer() // also schedule once after transition - const t = setTimeout(relayout, 120) + const t = setTimeout(relayoutToContainer, 120) // observe parent size changes let ro: ResizeObserver | null = null try { + const container = (ed as any).getContainerDomNode?.() as HTMLElement | null const node = ed.getDomNode() as HTMLElement | null - const parent = node?.parentElement || node - if (parent && 'ResizeObserver' in window) { - ro = new ResizeObserver(() => relayout()) - ro.observe(parent) + const target = container || node?.parentElement || node + if (target && 'ResizeObserver' in window) { + ro = new ResizeObserver(() => relayoutToContainer()) + ro.observe(target) } } catch (error) { logEditorError('init resize observer', error) } // window resize - window.addEventListener('resize', relayout) + window.addEventListener('resize', relayoutToContainer) return () => { clearTimeout(t) safeExecute('disconnect resize observer', () => { if (ro) ro.disconnect() }) - window.removeEventListener('resize', relayout) + window.removeEventListener('resize', relayoutToContainer) } - }, [view, editorRef]) + }, [editorMountNonce, view, editorRef]) const handleEditorMount = useCallback( (editor: monacoNs.editor.IStandaloneCodeEditor, monaco: Parameters[1]) => { - setEditor(editor as any) + unregisterEditorRef.current?.() + unregisterEditorRef.current = registerEditor(editor as any) + safeExecute('dispose editor focus listener', () => focusDisposableRef.current?.dispose()) + safeExecute('dispose editor blur listener', () => blurDisposableRef.current?.dispose()) + focusDisposableRef.current = editor.onDidFocusEditorWidget(() => { + try { setEditor(editor as any) } catch {} + }) + blurDisposableRef.current = editor.onDidBlurEditorWidget(() => { + // Keep last active editor; do not clear on blur to avoid losing target when clicking chrome. + }) handleMount(editor, monaco) + setEditorMountNonce((n) => n + 1) }, - [handleMount, setEditor], + [handleMount, registerEditor, setEditor], ) const handleEditorDropFiles = useCallback( async (files: File[]) => { + ensureThisEditorActive() await uploadFiles(files) }, - [uploadFiles], + [ensureThisEditorActive, uploadFiles], ) return ( -
+
{ + ensureThisEditorActive() const files = Array.from(e.currentTarget.files || []) await uploadFiles(files) safeExecute('reset file input', () => { @@ -556,6 +768,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { isMobile={isMobile} view={view as ViewMode} extraRight={extraRight} + embedded={embedded} toolbar={Toolbar} toolbarOpen={toolbarOpen} onToolbarOpenChange={setToolbarOpen} @@ -592,7 +805,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } /> - +
) } diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index adf6816e..59bf0f5b 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -18,6 +18,7 @@ export type EditorLayoutProps = { isMobile: boolean view: ViewMode extraRight?: ReactNode + embedded?: boolean toolbar: ReactNode toolbarOpen: boolean onToolbarOpenChange: (open: boolean) => void @@ -70,6 +71,7 @@ export function EditorLayout({ isMobile, view, extraRight, + embedded = false, toolbar, toolbarOpen, onToolbarOpenChange, @@ -366,16 +368,16 @@ export function EditorLayout({
{layoutState.wEditor !== '0%' && (
{editorBanner ?
{editorBanner}
: null} -
+
{editorOverlay ? (
{editorOverlay} @@ -425,9 +427,9 @@ export function EditorLayout({
)}
-
+
{conflictView && conflictView.kind === 'text' ? ( -
+
{conflictControls ?
{conflictControls}
: null}
@@ -562,7 +564,7 @@ export function EditorLayout({
diff --git a/app/src/features/edit-document/ui/EditorPane.tsx b/app/src/features/edit-document/ui/EditorPane.tsx index f28a4b16..dc8e1e03 100644 --- a/app/src/features/edit-document/ui/EditorPane.tsx +++ b/app/src/features/edit-document/ui/EditorPane.tsx @@ -20,7 +20,7 @@ export default function EditorPane({ theme, onBeforeMount, readOnly, onMount, on return (
{ if (e.dataTransfer?.types?.includes('Files')) { dragCounterRef.current++; setIsDragging(true) } }} onDragLeave={() => { dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) setIsDragging(false) }} onDragOver={(e) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); setIsDragging(true) } }} diff --git a/app/src/features/edit-document/ui/PreviewPane.tsx b/app/src/features/edit-document/ui/PreviewPane.tsx index c2c2b509..c7cffa56 100644 --- a/app/src/features/edit-document/ui/PreviewPane.tsx +++ b/app/src/features/edit-document/ui/PreviewPane.tsx @@ -17,7 +17,6 @@ import { useViewController } from '../public/useViewController' export type PreviewPaneProps = { content: string viewMode?: ViewMode - isSecondaryViewer?: boolean onScroll?: (scrollTop: number, scrollPercentage: number) => void onScrollAnchorLine?: (line: number) => void scrollPercentage?: number @@ -31,12 +30,12 @@ export type PreviewPaneProps = { taskToggleDisabled?: boolean } -function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer = false, onScroll, onScrollAnchorLine, scrollPercentage, documentIdOverride, onNavigate, forceFloatingToc = false, stickToBottom = false, scrollToLine, onToggleTask, taskToggleDisabled }: PreviewPaneProps) { +function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrollAnchorLine, scrollPercentage, documentIdOverride, onNavigate, forceFloatingToc = false, stickToBottom = false, scrollToLine, onToggleTask, taskToggleDisabled }: PreviewPaneProps) { const vc = useViewController() const onTagClickStable = React.useCallback((tag: string) => { vc.openSearch(tag) }, [vc]) - // Track when user is actively interacting with preview to enable preview->editor sync + // Track user interaction to avoid overriding scroll position during active scrolling. useEffect(() => { const el = previewRef.current if (!el) return @@ -67,6 +66,20 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer const previewRef = useRef(null) const scrollRafId = useRef(null) const anchorsRef = useRef>([]) + const suppressSyncEmitRef = useRef(false) + const suppressSyncEmitTimerRef = useRef(null) + + const suppressSyncEmit = React.useCallback((ms = 140) => { + if (typeof window === 'undefined') return + suppressSyncEmitRef.current = true + if (suppressSyncEmitTimerRef.current != null) { + window.clearTimeout(suppressSyncEmitTimerRef.current) + } + suppressSyncEmitTimerRef.current = window.setTimeout(() => { + suppressSyncEmitTimerRef.current = null + suppressSyncEmitRef.current = false + }, ms) + }, []) // Build anchors from data-sourcepos (requires ReactMarkdown sourcePos) const rebuildAnchors = React.useCallback(() => { @@ -99,11 +112,10 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer cn( 'prose prose-neutral dark:prose-invert break-words overflow-wrap-anywhere', viewMode === 'preview' ? 'max-w-6xl mx-auto' : 'max-w-none', - isSecondaryViewer && 'markdown-preview-secondary' - ), [viewMode, isSecondaryViewer]) + ), [viewMode]) - const showAsideToc = viewMode === 'preview' && !isMobile && !isSecondaryViewer && !forceFloatingToc - const showFloatingTrigger = viewMode === 'split' || (viewMode === 'preview' && isMobile) || isSecondaryViewer || forceFloatingToc + const showAsideToc = viewMode === 'preview' && !isMobile && !forceFloatingToc + const showFloatingTrigger = viewMode === 'split' || (viewMode === 'preview' && isMobile) || forceFloatingToc // Apply external scroll percentage to container (fallback when no anchor line) useEffect(() => { @@ -115,8 +127,9 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer if ((el as any).__userInteracting === true) return const { scrollHeight, clientHeight } = el const denom = Math.max(1, scrollHeight - clientHeight) + suppressSyncEmit() el.scrollTop = Math.round(denom * Math.min(1, Math.max(0, scrollPercentage))) - }, [scrollPercentage, scrollToLine]) + }, [scrollPercentage, scrollToLine, suppressSyncEmit]) // If editor is at bottom (pct≈1) and content grows, keep preview pinned to bottom useEffect(() => { @@ -128,11 +141,12 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer const pin = () => { const { scrollHeight, clientHeight } = el const denom = Math.max(0, scrollHeight - clientHeight) + suppressSyncEmit() el.scrollTop = denom } // Wait for layout after content change requestAnimationFrame(() => { requestAnimationFrame(pin) }) - }, [content, scrollPercentage, stickToBottom, scrollToLine]) + }, [content, scrollPercentage, stickToBottom, scrollToLine, suppressSyncEmit]) // Rebuild anchors after content or container size changes useEffect(() => { @@ -168,17 +182,24 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer const maxTop = Math.max(0, container.scrollHeight - container.clientHeight) const nextTop = Math.max(0, Math.min(maxTop, targetTop - margin)) requestAnimationFrame(() => { + suppressSyncEmit() container.scrollTop = nextTop }) - }, [scrollToLine]) + }, [scrollToLine, suppressSyncEmit]) // Cleanup rAF - useEffect(() => () => { if (scrollRafId.current != null) cancelAnimationFrame(scrollRafId.current) }, []) + useEffect(() => () => { + if (scrollRafId.current != null) cancelAnimationFrame(scrollRafId.current) + if (suppressSyncEmitTimerRef.current != null) { + window.clearTimeout(suppressSyncEmitTimerRef.current) + suppressSyncEmitTimerRef.current = null + } + }, []) const handleFloatingItemClick = React.useCallback(() => setShowFloatingToc(false), []) return ( -
+
@@ -250,7 +268,7 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer onClick={() => setShowFloatingToc((s) => !s)} className={cn( 'p-3 rounded-full border border-primary/60 bg-primary text-primary-foreground shadow-lg transition-all hover:bg-primary/90 hover:shadow-xl z-40', - (isMobile || forceFloatingToc) ? 'fixed bottom-6 right-6' : 'absolute bottom-6 right-6' + isMobile ? 'fixed bottom-6 right-6' : 'absolute bottom-6 right-6' )} title="Table of Contents" size="icon" @@ -264,7 +282,7 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer ref={floatingTocRef} className={cn( overlayPanelClass, - (isMobile || forceFloatingToc) + isMobile ? 'fixed bottom-24 right-6 w-[min(320px,calc(100%-2.5rem))] z-40' : 'absolute bottom-20 right-6 w-[300px] max-w-[calc(100%-3rem)] z-40', )} @@ -282,7 +300,6 @@ function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer
) : undefined} onItemClick={handleFloatingItemClick} floating diff --git a/app/src/features/edit-document/ui/Toolbar.tsx b/app/src/features/edit-document/ui/Toolbar.tsx index adf484f6..c7cca18f 100644 --- a/app/src/features/edit-document/ui/Toolbar.tsx +++ b/app/src/features/edit-document/ui/Toolbar.tsx @@ -29,6 +29,7 @@ export interface EditorToolbarProps { className?: string syncScroll?: boolean onSyncScrollToggle?: () => void + syncScrollAvailable?: boolean onFileUpload?: () => void viewMode?: ViewMode isVimMode?: boolean @@ -48,6 +49,7 @@ function EditorToolbarComponent({ className, syncScroll, onSyncScrollToggle, + syncScrollAvailable = false, onFileUpload, viewMode, isVimMode, @@ -111,8 +113,9 @@ function EditorToolbarComponent({ ) }, [onCommand, readOnly]) + const showSyncScrollToggle = Boolean(onSyncScrollToggle) && (viewMode === 'split' || syncScrollAvailable) const hasUtilityControls = Boolean( - (onFileUpload && !readOnly) || (viewMode === 'split' && onSyncScrollToggle) || onVimModeToggle, + (onFileUpload && !readOnly) || showSyncScrollToggle || onVimModeToggle, ) return ( @@ -156,7 +159,7 @@ function EditorToolbarComponent({ )} - {viewMode === 'split' && onSyncScrollToggle && ( + {showSyncScrollToggle && ( diff --git a/app/src/features/file-tree/ui/FileNode.tsx b/app/src/features/file-tree/ui/FileNode.tsx index 584fd060..1d6679dd 100644 --- a/app/src/features/file-tree/ui/FileNode.tsx +++ b/app/src/features/file-tree/ui/FileNode.tsx @@ -31,6 +31,7 @@ import { toast } from 'sonner' import type { GitPullConflictItem } from '@/shared/api' import useInView from '@/shared/hooks/use-in-view' +import { dispatchOpenPreviewTile } from '@/shared/lib/mosaic-events' import { overlayMenuClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' @@ -68,7 +69,6 @@ type FileNodeProps = { onDrop: (e: React.DragEvent, id: string, type: 'file' | 'folder', parentId?: string) => void onDragOver: (e: React.DragEvent, nodeId?: string, nodeType?: 'file' | 'folder') => void pluginRules?: FileTreeRule[] - onOpenSecondaryViewer?: (id: string, type?: 'document' | 'scrap') => void gitEnabled?: boolean conflict?: GitPullConflictItem | null } @@ -92,7 +92,6 @@ export const FileNode = memo(function FileNode({ onDrop, onDragOver, pluginRules, - onOpenSecondaryViewer, gitEnabled = false, conflict = null, }: FileNodeProps) { @@ -183,7 +182,15 @@ export const FileNode = memo(function FileNode({ setDuplicatePending(false) } }, [isShareMount, node, onDuplicate]) - const handleSelect = useCallback(() => { onSelect(node) }, [node, onSelect]) + const handleSelect = useCallback((event?: React.MouseEvent) => { + if (event && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + event.stopPropagation() + dispatchOpenPreviewTile(node.id) + return + } + onSelect(node) + }, [node, onSelect]) const handleOpenConflictResolver = useCallback(() => { router.navigate({ to: '/document/$id', @@ -508,9 +515,9 @@ export const FileNode = memo(function FileNode({ )} guardMenuAction(event, () => onOpenSecondaryViewer?.(node.id, 'document'))} + onSelect={(event) => guardMenuAction(event, () => dispatchOpenPreviewTile(node.id))} > - Open in Secondary Viewer + Open in Tile {hasConflict && !isShareMount && ( void - showEditorFeatures: boolean - headerViewMode: 'editor' | 'split' | 'preview' - changeView: (mode: 'editor' | 'split' | 'preview') => void - isCompact: boolean canShare: boolean onShare: () => void onToggleTheme: () => void onSignOut: () => void documentActions?: DocumentHeaderAction[] + viewMode?: MobileViewMode + onChangeViewMode?: (mode: MobileViewMode) => void } export function MobileHeaderMenu({ open, onClose, - showEditorFeatures, - headerViewMode, - changeView, - isCompact, canShare, onShare, onToggleTheme, onSignOut, documentActions = [], + viewMode, + onChangeViewMode, }: MobileHeaderMenuProps) { if (!open) return null - const handleSelect = (mode: 'editor' | 'split' | 'preview') => { - changeView(mode) - onClose() - } + const showViewModeToggle = Boolean(viewMode && onChangeViewMode) return ( <> @@ -50,37 +45,27 @@ export function MobileHeaderMenu({
- {showEditorFeatures && ( -
-

View Mode

-
+ {showViewModeToggle ? ( +
+

View

+
- {!isCompact && ( - - )}
- )} - + ) : null}
+ ) +} + +function makeTileKey(): TileKey { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `tile:${crypto.randomUUID()}` as TileKey + } + return `tile:${Math.random().toString(36).slice(2)}` as TileKey +} + +function makeSyncGroupId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `split:${crypto.randomUUID()}` + } + return `split:${Math.random().toString(36).slice(2)}` +} + +function replaceNodeAtPath( + layout: MosaicNode | null, + path: MosaicPath, + replacement: MosaicNode, +): MosaicNode { + if (!layout) return replacement + if (path.length === 0) return replacement + if (!isParent(layout)) return layout + const parent = layout as MosaicParent + const [head, ...rest] = path + if (head === 'first') return { ...parent, first: replaceNodeAtPath(parent.first, rest as MosaicPath, replacement) } + return { ...parent, second: replaceNodeAtPath(parent.second, rest as MosaicPath, replacement) } +} + +function findPathToTile(layout: MosaicNode | null, target: TileKey): MosaicPath | null { + if (!layout) return null + if (!isParent(layout)) return (layout as TileKey) === target ? ([] as MosaicPath) : null + const parent = layout as MosaicParent + const inFirst = findPathToTile(parent.first, target) + if (inFirst) return ['first', ...inFirst] + const inSecond = findPathToTile(parent.second, target) + if (inSecond) return ['second', ...inSecond] + return null +} + +function removeLeaf(layout: MosaicNode | null, target: TileKey): MosaicNode | null { + if (!layout) return null + if (!isParent(layout)) return (layout as TileKey) === target ? null : layout + const parent = layout as MosaicParent + const first = removeLeaf(parent.first, target) + const second = removeLeaf(parent.second, target) + if (!first && !second) return null + if (!first) return second + if (!second) return first + if (first === parent.first && second === parent.second) return parent + return { ...parent, first, second } +} + +function getLeavesSafe(layout: MosaicNode | null): TileKey[] { + if (!layout) return [] + try { + return getLeaves(layout) + } catch { + return [] + } +} + +function normalizeSplitPercentage(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 50 + return Math.min(100, Math.max(0, value)) +} + +function insertLeafAtRight(layout: MosaicNode | null, leaf: TileKey): MosaicNode { + if (!layout) return leaf + return { direction: 'row', first: layout, second: leaf, splitPercentage: 50 } +} + +type InsertSplitMode = 'auto' | 'row' | 'column' + +function insertLeafWithMode( + layout: MosaicNode | null, + leaf: TileKey, + preferredLeaf: TileKey | undefined, + mode: InsertSplitMode, +): MosaicNode { + if (mode === 'row') return insertLeafAtRight(layout, leaf) + return insertLeafBsp(layout, leaf, preferredLeaf, mode) +} + +type BspRect = { x: number; y: number; w: number; h: number } +type BspLeafPane = { leaf: TileKey; path: MosaicPath; rect: BspRect } + +function getRootRect(): BspRect { + if (typeof window === 'undefined') return { x: 0, y: 0, w: 1, h: 1 } + const w = window.innerWidth + const h = window.innerHeight + if (!w || !h) return { x: 0, y: 0, w: 1, h: 1 } + const aspect = w / h + if (aspect >= 1) return { x: 0, y: 0, w: aspect, h: 1 } + return { x: 0, y: 0, w: 1, h: 1 / aspect } +} + +function collectLeafPanes( + node: MosaicNode, + rect: BspRect, + path: MosaicPath, + out: BspLeafPane[], +) { + if (!isParent(node)) { + out.push({ leaf: node as TileKey, path, rect }) + return + } + + const parent = node as MosaicParent + const split = normalizeSplitPercentage((parent as any).splitPercentage) / 100 + if (parent.direction === 'row') { + const firstRect: BspRect = { x: rect.x, y: rect.y, w: rect.w * split, h: rect.h } + const secondRect: BspRect = { x: rect.x + firstRect.w, y: rect.y, w: rect.w * (1 - split), h: rect.h } + collectLeafPanes(parent.first, firstRect, [...path, 'first'], out) + collectLeafPanes(parent.second, secondRect, [...path, 'second'], out) + return + } + + const firstRect: BspRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h * split } + const secondRect: BspRect = { x: rect.x, y: rect.y + firstRect.h, w: rect.w, h: rect.h * (1 - split) } + collectLeafPanes(parent.first, firstRect, [...path, 'first'], out) + collectLeafPanes(parent.second, secondRect, [...path, 'second'], out) +} + +function pickBspTarget(layout: MosaicNode, preferredLeaf?: TileKey): BspLeafPane { + const panes: BspLeafPane[] = [] + collectLeafPanes(layout, getRootRect(), [] as MosaicPath, panes) + + if (preferredLeaf) { + const preferred = panes.find((pane) => pane.leaf === preferredLeaf) + if (preferred) { + const preferredArea = preferred.rect.w * preferred.rect.h + let maxArea = preferredArea + for (const pane of panes) { + const area = pane.rect.w * pane.rect.h + if (area > maxArea) maxArea = area + } + // If preferred is tied for "largest" (within epsilon), split it for more intuitive placement. + const epsilon = 1e-6 + if (preferredArea + epsilon >= maxArea) return preferred + } + } + + // BSPwm-style: split the largest visible pane by area. + let best = panes[0] + let bestArea = (best?.rect.w ?? 0) * (best?.rect.h ?? 0) + for (const pane of panes) { + const area = pane.rect.w * pane.rect.h + if (area > bestArea) { + best = pane + bestArea = area + } + } + return best +} + +function computeBspSplitDirection(rect: BspRect): 'row' | 'column' { + // BSPwm-style: split along the longer axis (vertical split when pane is wider). + return rect.w >= rect.h ? 'row' : 'column' +} + +function resolveInsertSplitDirection(mode: InsertSplitMode, rect: BspRect): 'row' | 'column' { + if (mode === 'row') return 'row' + if (mode === 'column') return 'column' + return computeBspSplitDirection(rect) +} + +function insertLeafBsp( + layout: MosaicNode | null, + leaf: TileKey, + preferredLeaf?: TileKey, + mode: InsertSplitMode = 'auto', +): MosaicNode { + if (!layout) return leaf + if (!isParent(layout)) { + const rootRect = getRootRect() + return { + direction: resolveInsertSplitDirection(mode, rootRect), + first: layout, + second: leaf, + splitPercentage: 50, + } + } + const target = pickBspTarget(layout, preferredLeaf) + const direction = resolveInsertSplitDirection(mode, target.rect) + const replacement: MosaicNode = { + direction, + first: target.leaf, + second: leaf, + splitPercentage: 50, + } + return replaceNodeAtPath(layout, target.path, replacement) +} + +type SwapDirection = 'left' | 'right' | 'up' | 'down' + +function overlaps(a0: number, a1: number, b0: number, b1: number) { + return Math.min(a1, b1) - Math.max(a0, b0) > 0 +} + +function pickNeighborLeaf(panes: BspLeafPane[], fromLeaf: TileKey, direction: SwapDirection): TileKey | null { + const from = panes.find((pane) => pane.leaf === fromLeaf) + if (!from) return null + + const fromCx = from.rect.x + from.rect.w / 2 + const fromCy = from.rect.y + from.rect.h / 2 + + const isOverlapMatch = (pane: BspLeafPane) => { + if (direction === 'left' || direction === 'right') { + return overlaps(from.rect.y, from.rect.y + from.rect.h, pane.rect.y, pane.rect.y + pane.rect.h) + } + return overlaps(from.rect.x, from.rect.x + from.rect.w, pane.rect.x, pane.rect.x + pane.rect.w) + } + + const candidates = panes.filter((pane) => pane.leaf !== fromLeaf) + + const score = (pane: BspLeafPane) => { + const cx = pane.rect.x + pane.rect.w / 2 + const cy = pane.rect.y + pane.rect.h / 2 + if (direction === 'left') return cx < fromCx ? cx : -Infinity + if (direction === 'right') return cx > fromCx ? -cx : -Infinity + if (direction === 'up') return cy < fromCy ? cy : -Infinity + return cy > fromCy ? -cy : -Infinity + } + + const pick = (list: BspLeafPane[]) => { + let best: BspLeafPane | null = null + let bestScore = -Infinity + for (const pane of list) { + const s = score(pane) + if (s > bestScore) { + best = pane + bestScore = s + } + } + return best?.leaf ?? null + } + + const overlapCandidates = candidates.filter((pane) => isOverlapMatch(pane)) + return pick(overlapCandidates) ?? pick(candidates) +} + +function ensureLeafInLayout( + layout: MosaicNode | null, + leaf: TileKey, + preferredLeaf?: TileKey, + mode: InsertSplitMode = 'auto', +): MosaicNode { + const leaves = getLeavesSafe(layout) + if (leaves.includes(leaf)) return layout ?? leaf + return insertLeafWithMode(layout, leaf, preferredLeaf, mode) +} + +function maybeBuildTwoDocSplitGrid( + layout: MosaicNode | null, + tiles: Record, +): MosaicNode | null { + if (!layout) return null + const leaves = getLeavesSafe(layout) + if (leaves.length !== 4) return null + + const docOrder: string[] = [] + const groups = new Map() + for (const leaf of leaves) { + const spec = tiles[leaf] + if (!spec) return null + if (spec.mode !== 'editor' && spec.mode !== 'preview') return null + const docId = spec.documentId + if (!groups.has(docId)) { + groups.set(docId, {}) + docOrder.push(docId) + } + const group = groups.get(docId)! + if (spec.mode === 'editor') group.editor = leaf + else group.preview = leaf + } + + if (groups.size !== 2) return null + for (const group of groups.values()) { + if (!group.editor || !group.preview) return null + } + + const makeRow = (docId: string): MosaicNode => { + const group = groups.get(docId)! + return { + direction: 'row', + first: group.editor!, + second: group.preview!, + splitPercentage: 50, + } + } + + const [firstDoc, secondDoc] = docOrder + if (!firstDoc || !secondDoc) return null + return { + direction: 'column', + first: makeRow(firstDoc), + second: makeRow(secondDoc), + splitPercentage: 50, + } +} + +function pruneLayout( + layout: MosaicNode | null, + isValidLeaf: (leaf: TileKey) => boolean, +): MosaicNode | null { + if (!layout) return null + if (!isParent(layout)) { + const leaf = layout as TileKey + return isValidLeaf(leaf) ? leaf : null + } + const parent = layout as MosaicParent + const first = pruneLayout(parent.first, isValidLeaf) + const second = pruneLayout(parent.second, isValidLeaf) + if (!first && !second) return null + if (!first) return second + if (!second) return first + if (first === parent.first && second === parent.second) return parent + return { ...parent, first, second } +} + +function balanceLayoutSplits(layout: MosaicNode): MosaicNode { + const equalize = (node: MosaicNode): { node: MosaicNode; leafCount: number } => { + if (!isParent(node)) return { node, leafCount: 1 } + const parent = node as MosaicParent + const first = equalize(parent.first) + const second = equalize(parent.second) + const total = first.leafCount + second.leafCount + const nextSplit = total > 0 ? (first.leafCount / total) * 100 : 50 + const currentSplit = normalizeSplitPercentage((parent as any).splitPercentage) + const normalizedNext = normalizeSplitPercentage(nextSplit) + const epsilon = 1e-6 + const sameSplit = Math.abs(currentSplit - normalizedNext) < epsilon + const nextNode = + first.node === parent.first && second.node === parent.second && sameSplit + ? node + : { ...parent, first: first.node, second: second.node, splitPercentage: normalizedNext } + return { node: nextNode, leafCount: total } + } + + return equalize(layout).node +} + +function defaultState(activeDocumentId: string): MosaicState { + const editorKey = makeTileKey() + const previewKey = makeTileKey() + const groupId = makeSyncGroupId() + return { + layout: { direction: 'row', first: editorKey, second: previewKey, splitPercentage: 50 }, + tiles: { + [editorKey]: { mode: 'editor', documentId: activeDocumentId, syncGroupId: groupId }, + [previewKey]: { mode: 'preview', documentId: activeDocumentId, syncGroupId: groupId }, + }, + } +} + +function deriveDocumentViewMode(documentId: string, tiles: Record): 'editor' | 'split' | 'preview' { + const id = documentId.trim() + if (!id) return 'editor' + let hasEditor = false + let hasPreview = false + for (const spec of Object.values(tiles)) { + if (spec.documentId !== id) continue + if (spec.mode === 'editor') hasEditor = true + else if (spec.mode === 'preview') hasPreview = true + if (hasEditor && hasPreview) return 'split' + } + if (hasEditor) return 'editor' + if (hasPreview) return 'preview' + return 'editor' +} + +function sanitizeState(state: MosaicState, activeDocumentId: string): MosaicState { + const prunedLayout = pruneLayout(state.layout, (leaf) => Boolean(state.tiles[leaf])) + const leaves = getLeavesSafe(prunedLayout) + const nextTiles: Record = {} + for (const leaf of leaves) { + const spec = state.tiles[leaf] + if (spec && typeof spec.documentId === 'string' && spec.documentId.trim()) { + const trimmedId = spec.documentId.trim() + const rawGroupId = (spec as any).syncGroupId + const trimmedGroupId = typeof rawGroupId === 'string' ? rawGroupId.trim() : '' + + const needsDocUpdate = trimmedId !== spec.documentId + const needsGroupUpdate = + typeof rawGroupId === 'string' + ? (trimmedGroupId ? trimmedGroupId !== rawGroupId : rawGroupId.length > 0) + : false + + if (!needsDocUpdate && !needsGroupUpdate) { + nextTiles[leaf] = spec + continue + } + + const nextSpec: TileSpec = { ...spec, documentId: trimmedId } + if (trimmedGroupId) nextSpec.syncGroupId = trimmedGroupId + else delete (nextSpec as any).syncGroupId + nextTiles[leaf] = nextSpec + } + } + + // Ensure editor/preview tiles for the same document share a sync group. + let needsSyncUpdate = false + const byDoc = new Map() + for (const leaf of leaves) { + const spec = nextTiles[leaf] + if (!spec) continue + if (spec.mode !== 'editor' && spec.mode !== 'preview') continue + const docId = spec.documentId + let bucket = byDoc.get(docId) + if (!bucket) { + bucket = { editors: [], previews: [] } + byDoc.set(docId, bucket) + } + if (spec.mode === 'editor') bucket.editors.push(leaf) + else bucket.previews.push(leaf) + } + + for (const [, bucket] of byDoc) { + if (bucket.editors.length === 0 || bucket.previews.length === 0) continue + + const editorGroups = new Set(bucket.editors.map((key) => nextTiles[key]?.syncGroupId).filter(Boolean) as string[]) + const previewGroups = new Set(bucket.previews.map((key) => nextTiles[key]?.syncGroupId).filter(Boolean) as string[]) + + let groupId: string | null = null + for (const candidate of editorGroups) { + if (previewGroups.has(candidate)) { + groupId = candidate + break + } + } + if (!groupId) { + groupId = editorGroups.values().next().value ?? previewGroups.values().next().value ?? null + } + if (!groupId) groupId = makeSyncGroupId() + + for (const key of [...bucket.editors, ...bucket.previews]) { + const spec = nextTiles[key] + if (!spec) continue + if (spec.syncGroupId !== groupId) { + nextTiles[key] = { ...spec, syncGroupId: groupId } + needsSyncUpdate = true + } + } + } + if (leaves.length === 0) { + return defaultState(activeDocumentId) + } + if (state.layout === prunedLayout) { + const stateKeys = Object.keys(state.tiles) as TileKey[] + if (stateKeys.length === leaves.length) { + let same = true + for (const key of stateKeys) { + if (!nextTiles[key] || state.tiles[key] !== nextTiles[key]) { + same = false + break + } + } + if (same && !needsSyncUpdate) return state + } + } + return { layout: prunedLayout, tiles: nextTiles } +} + +function loadState(activeDocumentId: string, storageKey: string): MosaicState { + if (typeof window === 'undefined') return defaultState(activeDocumentId) + try { + const raw = localStorage.getItem(storageKey) + if (!raw) return defaultState(activeDocumentId) + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') return defaultState(activeDocumentId) + const candidate = parsed as Partial + if (!candidate.tiles || typeof candidate.tiles !== 'object') return defaultState(activeDocumentId) + const layout = (candidate.layout ?? null) as MosaicNode | null + const tiles = candidate.tiles as Record + return sanitizeState({ layout, tiles }, activeDocumentId) + } catch { + return defaultState(activeDocumentId) + } +} + +function saveState(state: MosaicState, storageKey: string) { + try { + localStorage.setItem(storageKey, JSON.stringify(state)) + } catch { + /* noop */ + } +} + +type Props = Pick & { + shareScope?: ShareScope + isShareMount?: boolean +} + +export default function DocumentMosaicWorkspace(props: Props) { + const { id, loaderData, shareToken, shareScope: shareScopeProp, isShareMount = false, conflictMode } = props + const navigate = useNavigate() + const { user, activeWorkspaceId } = useAuthContext() + const shareLinkToken = shareToken && !isShareMount ? shareToken : undefined + const mosaicStorageKey = useMemo(() => { + if (shareLinkToken) return null + return buildMosaicStorageKey({ userId: user?.id ?? null, workspaceId: activeWorkspaceId }) + }, [activeWorkspaceId, shareLinkToken, user?.id]) + const mosaicStorageKeyRef = useRef(mosaicStorageKey) + const [mosaicState, setMosaicState] = useState(() => { + return mosaicStorageKey ? loadState(id, mosaicStorageKey) : defaultState(id) + }) + const [activeDocumentId, setActiveDocumentId] = useState(id) + const activeDocumentIdRef = useRef(activeDocumentId) + const activeTileRef = useRef<{ tileKey: TileKey; documentId: string; mode: TileMode } | null>(null) + const previousTileKeyRef = useRef(null) + const expandedTileKeyRef = useRef(null) + const [insertSplitMode, setInsertSplitMode] = useState(() => { + if (typeof window === 'undefined') return 'row' + try { + const raw = localStorage.getItem('refmd:mosaic:insert-split-mode') + if (raw === 'row' || raw === 'column' || raw === 'auto') return raw + return 'row' + } catch { + return 'row' + } + }) + const insertSplitModeRef = useRef(insertSplitMode) + const focusRequestIdRef = useRef(0) + const saveTimerRef = useRef(null) + const latestStateRef = useRef(mosaicState) + const shareLinkTokenRef = useRef(shareLinkToken) + const clearSavedLayoutRef = useRef(false) + const lastReportedViewModeRef = useRef<{ docId: string; mode: 'editor' | 'split' | 'preview' } | null>(null) + const lastRouteDocIdRef = useRef(id) + const lastSeenRouteDocIdRef = useRef(id) + + useEffect(() => { + insertSplitModeRef.current = insertSplitMode + }, [insertSplitMode]) + + useEffect(() => { + if (typeof window === 'undefined') return + try { + localStorage.setItem('refmd:mosaic:insert-split-mode', insertSplitMode) + } catch { + // ignore + } + }, [insertSplitMode]) + + useEffect(() => { + latestStateRef.current = mosaicState + }, [mosaicState]) + + useEffect(() => { + if (!mosaicStorageKey) return + if (mosaicStorageKeyRef.current === mosaicStorageKey) return + mosaicStorageKeyRef.current = mosaicStorageKey + activeTileRef.current = null + previousTileKeyRef.current = null + expandedTileKeyRef.current = null + setMosaicState(loadState(id, mosaicStorageKey)) + }, [id, mosaicStorageKey, setMosaicState]) + + useEffect(() => { + const previous = lastRouteDocIdRef.current + lastRouteDocIdRef.current = id + if (!previous || previous === id) return + + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const alreadyOpen = Object.values(safe.tiles).some((spec) => spec.documentId === id) + if (alreadyOpen) return safe + + let changed = false + const nextTiles: Record = { ...safe.tiles } + for (const [tileKey, spec] of Object.entries(safe.tiles) as Array<[TileKey, TileSpec]>) { + if (spec.documentId !== previous) continue + nextTiles[tileKey] = { ...spec, documentId: id } + changed = true + } + if (!changed) return safe + + // If the previous focused document was in split view, reset that pair's divider to 50/50 + // so the newly opened document starts balanced. + let nextLayout = safe.layout + const entries = Object.entries(nextTiles) as Array<[TileKey, TileSpec]> + const editorKey = entries.find(([, spec]) => spec.documentId === id && spec.mode === 'editor')?.[0] ?? null + const previewKey = entries.find(([, spec]) => spec.documentId === id && spec.mode === 'preview')?.[0] ?? null + if (editorKey && previewKey && nextLayout) { + const editorPath = findPathToTile(nextLayout, editorKey) + const previewPath = findPathToTile(nextLayout, previewKey) + if (editorPath && previewPath) { + const minLength = Math.min(editorPath.length, previewPath.length) + let idx = 0 + while (idx < minLength && editorPath[idx] === previewPath[idx]) idx += 1 + const lcaPath = editorPath.slice(0, idx) as MosaicPath + const lcaNode = ((): MosaicNode | null => { + let node: MosaicNode | null = nextLayout + for (const step of lcaPath) { + if (!node || !isParent(node)) return null + node = step === 'first' ? (node as MosaicParent).first : (node as MosaicParent).second + } + return node + })() + if ( + lcaNode && + isParent(lcaNode) && + ((lcaNode as MosaicParent).first === editorKey || (lcaNode as MosaicParent).second === editorKey) && + ((lcaNode as MosaicParent).first === previewKey || (lcaNode as MosaicParent).second === previewKey) + ) { + nextLayout = updateParentSplitPercentage(nextLayout, lcaPath, 50) + } + } + } + + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, [id, setMosaicState]) + + const focusTileElement = useCallback((tileKey: TileKey) => { + if (typeof document === 'undefined') return + if (typeof window === 'undefined') return + const requestId = ++focusRequestIdRef.current + const selectorKey = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(tileKey) : tileKey + const el = document.querySelector(`[data-refmd-tile-key="${selectorKey}"]`) + if (!el) return + try { + el.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } catch {} + + const focusInside = () => { + if (focusRequestIdRef.current !== requestId) return true + if (!el.isConnected) return false + const safe = sanitizeState(latestStateRef.current, id) + const leaves = getLeavesSafe(safe.layout) + if (!leaves.includes(tileKey)) return false + + const monacoInput = + el.querySelector('.monaco-editor textarea.inputarea') ?? + el.querySelector('.monaco-editor textarea') ?? + el.querySelector('textarea.inputarea') + if (monacoInput) { + try { + monacoInput.focus({ preventScroll: true } as any) + } catch { + try { + monacoInput.focus() + } catch {} + } + return true + } + + const firstInput = + el.querySelector('textarea, input, [contenteditable="true"], [tabindex="0"]') ?? null + if (firstInput) { + try { + firstInput.focus({ preventScroll: true } as any) + } catch { + try { + firstInput.focus() + } catch {} + } + return true + } + return false + } + + if (focusInside()) return + // Monaco might mount a tick later; try a few times. + let tries = 0 + const retry = () => { + if (focusRequestIdRef.current !== requestId) return + tries += 1 + if (focusInside()) return + if (tries >= 8) { + try { + el.focus({ preventScroll: true } as any) + } catch { + try { + el.focus() + } catch {} + } + return + } + window.requestAnimationFrame(retry) + } + window.requestAnimationFrame(retry) + }, [id]) + + useEffect(() => { + activeDocumentIdRef.current = activeDocumentId + }, [activeDocumentId]) + + useEffect(() => { + setActiveDocumentId(id) + activeDocumentIdRef.current = id + + const safe = sanitizeState(latestStateRef.current, id) + const existingTileKey = activeTileRef.current?.tileKey ?? null + const existingSpec = existingTileKey ? safe.tiles[existingTileKey] : undefined + if (existingTileKey && existingSpec?.documentId === id) { + activeTileRef.current = { tileKey: existingTileKey, documentId: id, mode: existingSpec.mode } + return + } + + const leaves = getLeavesSafe(safe.layout) + const candidates: Array<{ key: TileKey; spec: TileSpec }> = [] + for (const key of leaves) { + const spec = safe.tiles[key] + if (!spec) continue + if (spec.documentId !== id) continue + candidates.push({ key, spec }) + } + if (candidates.length === 0) return + + const modeRank: Record = { editor: 0, preview: 1, backlinks: 2 } + candidates.sort((a, b) => (modeRank[a.spec.mode] ?? 9) - (modeRank[b.spec.mode] ?? 9)) + const picked = candidates[0] + if (!picked) return + activeTileRef.current = { tileKey: picked.key, documentId: id, mode: picked.spec.mode } + }, [id]) + + useEffect(() => { + shareLinkTokenRef.current = shareLinkToken + }, [shareLinkToken]) + + const shareBrowseQuery = useQuery({ + queryKey: ['share-browse', shareToken], + queryFn: async () => browseShare(shareToken!), + staleTime: 5 * 60 * 1000, + enabled: Boolean(shareToken && (isShareMount || shareScopeProp === 'folder' || shareScopeProp == null)), + }) + + const inferredShareScope = useMemo(() => { + const tree = (shareBrowseQuery.data as any)?.tree + if (!Array.isArray(tree) || tree.length === 0) return undefined + const root = tree.find((n: any) => !n.parent_id) ?? tree[0] + return root?.type === 'folder' ? 'folder' : 'document' + }, [shareBrowseQuery.data]) + + const effectiveShareScope = shareScopeProp ?? inferredShareScope + const isSingleDocShare = Boolean(shareLinkToken && effectiveShareScope === 'document') + const focusedPluginLookup = useCreatedByPluginId(id, shareToken ?? null) + const splitCapablePluginDocs = useSplitCapablePluginDocs() + const focusedIsNonSplitPluginDoc = useMemo(() => { + const pluginId = focusedPluginLookup.pluginId + if (!pluginId) return false + return !splitCapablePluginDocs.has(id) + }, [focusedPluginLookup.pluginId, id, splitCapablePluginDocs]) + + const allowedSharedDocIds = useMemo | null>(() => { + if (!shareToken) return null + if (effectiveShareScope === 'document') return new Set([id]) + if (effectiveShareScope !== 'folder') return null + const tree = (shareBrowseQuery.data as any)?.tree + if (!Array.isArray(tree) || tree.length === 0) return null + return new Set(tree.filter((n: any) => n?.type === 'document').map((n: any) => String(n.id))) + }, [effectiveShareScope, id, shareBrowseQuery.data, shareToken]) + + const canAccessSharedDocument = useCallback( + (documentId: string) => { + const target = documentId.trim() + if (!target) return false + if (!shareToken) return true + if (effectiveShareScope === 'document') return target === id + if (effectiveShareScope === 'folder') { + if (!allowedSharedDocIds) return target === id + return allowedSharedDocIds.has(target) + } + // Unknown share scope: default to least privilege until scope is resolved. + return target === id + }, + [allowedSharedDocIds, effectiveShareScope, id, shareToken], + ) + + const markActiveDocument = useCallback( + (documentId: string, tileKey?: TileKey, mode?: TileMode) => { + const trimmed = documentId.trim() + if (!trimmed) return + setActiveDocumentId(trimmed) + activeDocumentIdRef.current = trimmed + if (tileKey && mode) { + const prev = activeTileRef.current?.tileKey ?? null + if (prev && prev !== tileKey) { + previousTileKeyRef.current = prev + } + activeTileRef.current = { tileKey, documentId: trimmed, mode } + } + // Keep the URL in sync with the currently focused document so share/copy works and + // "focused document" logic (ctx.id) updates across tiles. + if (trimmed === id) return + if (isSingleDocShare) return + try { + navigate({ + to: '/document/$id', + params: { id: trimmed }, + replace: true, + search: (prev: Record) => { + const next: Record = { ...(prev || {}) } + if (shareToken) next.token = shareToken + if (shareScopeProp) next.shareScope = shareScopeProp + if (isShareMount) next.shareMount = '1' + return next + }, + }) + } catch { + // ignore + } + }, + [id, isShareMount, isSingleDocShare, navigate, setActiveDocumentId, shareScopeProp, shareToken], + ) + + const swapTiles = useCallback( + (firstKey: TileKey, secondKey: TileKey) => { + if (firstKey === secondKey) return + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const firstSpec = safe.tiles[firstKey] + const secondSpec = safe.tiles[secondKey] + if (!firstSpec || !secondSpec) return safe + const nextTiles: Record = { ...safe.tiles } + nextTiles[firstKey] = secondSpec + nextTiles[secondKey] = firstSpec + return sanitizeState({ ...safe, tiles: nextTiles }, id) + }) + }, + [id], + ) + + const swapActiveTileWithLast = useCallback(() => { + const active = activeTileRef.current?.tileKey ?? null + const previous = previousTileKeyRef.current + if (!active || !previous) return + swapTiles(active, previous) + }, [swapTiles]) + + const swapActiveTileByDirection = useCallback( + (direction: SwapDirection) => { + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const active = activeTileRef.current?.tileKey ?? null + if (!active) return safe + if (!safe.layout) return safe + const panes: BspLeafPane[] = [] + collectLeafPanes(safe.layout, getRootRect(), [] as MosaicPath, panes) + const neighbor = pickNeighborLeaf(panes, active, direction) + if (!neighbor) return safe + const a = safe.tiles[active] + const b = safe.tiles[neighbor] + if (!a || !b) return safe + const nextTiles: Record = { ...safe.tiles, [active]: b, [neighbor]: a } + return sanitizeState({ ...safe, tiles: nextTiles }, id) + }) + }, + [id], + ) + + useEffect(() => { + const currentActive = activeDocumentIdRef.current + if (currentActive === id) return + const stillExists = Object.values(mosaicState.tiles).some((tile) => tile.documentId === currentActive) + if (!stillExists) { + setActiveDocumentId(id) + activeDocumentIdRef.current = id + activeTileRef.current = null + } + }, [id, mosaicState.tiles]) + + const applyViewModeForDocument = useCallback( + (documentId: string, mode: 'editor' | 'split' | 'preview') => { + const target = documentId.trim() + if (!target) return + if (isSingleDocShare && target !== id) return + + if (!canAccessSharedDocument(target)) { + toast.info('This document is not included in the shared scope.') + return + } + + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + let nextLayout = safe.layout + let nextTiles: Record = { ...safe.tiles } + let didMutateLayout = false + + const entries = Object.entries(safe.tiles) as Array<[TileKey, TileSpec]> + const editorKeys = entries + .filter(([, spec]) => spec.documentId === target && spec.mode === 'editor') + .map(([k]) => k) + const previewKeys = entries + .filter(([, spec]) => spec.documentId === target && spec.mode === 'preview') + .map(([k]) => k) + + const removeTile = (key: TileKey) => { + delete nextTiles[key] + } + + const clearSync = (key: TileKey) => { + const spec = nextTiles[key] + if (!spec) return + if (!spec.syncGroupId) return + nextTiles[key] = { ...spec, syncGroupId: undefined } + } + + const setSpec = (key: TileKey, spec: TileSpec) => { + nextTiles[key] = spec + } + + const addLeaf = (key: TileKey) => { + nextLayout = insertLeafWithMode(nextLayout, key, activeTileRef.current?.tileKey, insertSplitMode) + didMutateLayout = true + } + + if (mode === 'editor') { + const existingEditorKey = editorKeys[0] + const reusedFromPreviewKey = !existingEditorKey ? previewKeys[0] : undefined + const editorKey = existingEditorKey ?? reusedFromPreviewKey ?? makeTileKey() + + if (!existingEditorKey && !reusedFromPreviewKey) { + setSpec(editorKey, { mode: 'editor', documentId: target }) + addLeaf(editorKey) + } else { + setSpec(editorKey, { ...nextTiles[editorKey], mode: 'editor', documentId: target, syncGroupId: undefined }) + } + + for (const key of editorKeys) { + if (key !== editorKey) removeTile(key) + } + for (const key of previewKeys) { + if (key !== editorKey) removeTile(key) + } + clearSync(editorKey) + } else if (mode === 'preview') { + const existingPreviewKey = previewKeys[0] + const reusedFromEditorKey = !existingPreviewKey ? editorKeys[0] : undefined + const previewKey = existingPreviewKey ?? reusedFromEditorKey ?? makeTileKey() + + if (!existingPreviewKey && !reusedFromEditorKey) { + setSpec(previewKey, { mode: 'preview', documentId: target }) + addLeaf(previewKey) + } else { + setSpec(previewKey, { ...nextTiles[previewKey], mode: 'preview', documentId: target, syncGroupId: undefined }) + } + + for (const key of previewKeys) { + if (key !== previewKey) removeTile(key) + } + for (const key of editorKeys) { + if (key !== previewKey) removeTile(key) + } + clearSync(previewKey) + } else { + const groupId = makeSyncGroupId() + const active = activeTileRef.current + + const pickBaseKey = () => { + if (active && active.documentId === target) { + const spec = safe.tiles[active.tileKey] + if (spec && spec.documentId === target && (spec.mode === 'editor' || spec.mode === 'preview')) { + return active.tileKey + } + } + const first = entries.find(([, spec]) => spec.documentId === target && (spec.mode === 'editor' || spec.mode === 'preview')) + return first?.[0] + } + + const baseKey = pickBaseKey() + if (!baseKey) { + const editorKey = makeTileKey() + const previewKey = makeTileKey() + setSpec(editorKey, { mode: 'editor', documentId: target, syncGroupId: groupId }) + setSpec(previewKey, { mode: 'preview', documentId: target, syncGroupId: groupId }) + nextLayout = insertLeafWithMode(nextLayout, editorKey, activeTileRef.current?.tileKey, insertSplitMode) + nextLayout = insertLeafWithMode(nextLayout, previewKey, editorKey, insertSplitMode) + didMutateLayout = true + } else { + const baseSpec = safe.tiles[baseKey] + const baseMode: TileMode = baseSpec?.mode === 'preview' ? 'preview' : 'editor' + const oppositeMode: TileMode = baseMode === 'editor' ? 'preview' : 'editor' + const existingOppositeKey = entries.find( + ([key, spec]) => key !== baseKey && spec.documentId === target && spec.mode === oppositeMode, + )?.[0] + const oppositeKey = existingOppositeKey ?? makeTileKey() + + setSpec(baseKey, { ...nextTiles[baseKey], mode: baseMode, documentId: target, syncGroupId: groupId }) + setSpec(oppositeKey, { ...nextTiles[oppositeKey], mode: oppositeMode, documentId: target, syncGroupId: groupId }) + + if (existingOppositeKey) { + const oppositePath = findPathToTile(nextLayout, existingOppositeKey) + if (oppositePath) { + nextLayout = removeLeaf(nextLayout, existingOppositeKey) + } + } + + const basePath = findPathToTile(nextLayout, baseKey) + if (!basePath) { + nextLayout = ensureLeafInLayout(nextLayout, baseKey, undefined, insertSplitMode) + } + + const finalBasePath = findPathToTile(nextLayout, baseKey) + if (!finalBasePath) { + // Shouldn't happen, but keep existing layout and append instead of replacing the whole tree. + nextLayout = ensureLeafInLayout(nextLayout, baseKey, undefined, insertSplitMode) + nextLayout = ensureLeafInLayout(nextLayout, oppositeKey, baseKey, insertSplitMode) + } + const finalPath = findPathToTile(nextLayout, baseKey) ?? ([] as MosaicPath) + const editorKey = baseMode === 'editor' ? baseKey : oppositeKey + const previewKey = baseMode === 'preview' ? baseKey : oppositeKey + const replacement: MosaicNode = { + direction: 'row', + first: editorKey, + second: previewKey, + splitPercentage: 50, + } + nextLayout = replaceNodeAtPath(nextLayout, finalPath, replacement) + didMutateLayout = true + } + + if (insertSplitMode === 'auto') { + const grid = maybeBuildTwoDocSplitGrid(nextLayout, nextTiles) + if (grid) nextLayout = grid + } + } + + if (insertSplitMode === 'row' && didMutateLayout && nextLayout) { + nextLayout = balanceLayoutSplits(nextLayout) + expandedTileKeyRef.current = null + } + + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, + [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], + ) + + useEffect(() => { + if (isSingleDocShare) return + if (!focusedIsNonSplitPluginDoc) return + const current = deriveDocumentViewMode(id, mosaicState.tiles) + if (current === 'preview') return + applyViewModeForDocument(id, 'preview') + }, [applyViewModeForDocument, focusedIsNonSplitPluginDoc, id, isSingleDocShare, mosaicState.tiles]) + + useEffect(() => { + if (!mosaicStorageKeyRef.current) return + if (typeof window === 'undefined') return + if (saveTimerRef.current != null) { + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = null + } + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null + const key = mosaicStorageKeyRef.current + if (!key) return + saveState(mosaicState, key) + }, 250) + return () => { + if (saveTimerRef.current != null) { + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = null + } + } + }, [mosaicState, shareLinkToken]) + + useEffect(() => { + return () => { + if (shareLinkTokenRef.current) return + const key = mosaicStorageKeyRef.current + if (clearSavedLayoutRef.current) { + if (key) { + try { + localStorage.removeItem(key) + } catch {} + } + return + } + if (saveTimerRef.current != null) { + try { + window.clearTimeout(saveTimerRef.current) + } catch {} + saveTimerRef.current = null + } + if (key) saveState(latestStateRef.current, key) + } + }, []) + + const closeAllTilesToDashboard = useCallback(() => { + if (typeof window === 'undefined') return + clearSavedLayoutRef.current = true + if (saveTimerRef.current != null) { + try { + window.clearTimeout(saveTimerRef.current) + } catch {} + saveTimerRef.current = null + } + const key = mosaicStorageKeyRef.current + if (key) { + try { + localStorage.removeItem(key) + } catch {} + } + navigate({ to: '/dashboard', replace: true }) + }, [navigate]) + + const toggleExpandTile = useCallback( + (tileKey: TileKey) => { + if (typeof window === 'undefined') return + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const layout = safe.layout + if (!layout) return safe + const path = findPathToTile(layout, tileKey) + if (!path) return safe + + const isExpanded = expandedTileKeyRef.current === tileKey + const percentage = isExpanded ? UNEXPAND_PERCENTAGE : EXPAND_PERCENTAGE + expandedTileKeyRef.current = isExpanded ? null : tileKey + + const nextLayout = updateTree(layout, [createExpandUpdate(path, percentage)]) + return sanitizeState({ ...safe, layout: nextLayout }, id) + }) + }, + [id], + ) + + const focusActiveTileByDirection = useCallback( + (direction: SwapDirection) => { + const safe = sanitizeState(latestStateRef.current, id) + const active = activeTileRef.current?.tileKey ?? null + const layout = safe.layout + if (!active || !layout) return + const panes: BspLeafPane[] = [] + collectLeafPanes(layout, getRootRect(), [] as MosaicPath, panes) + const neighbor = pickNeighborLeaf(panes, active, direction) + if (!neighbor) return + const spec = safe.tiles[neighbor] + if (!spec) return + markActiveDocument(spec.documentId, neighbor, spec.mode) + focusTileElement(neighbor) + }, + [focusTileElement, id, markActiveDocument], + ) + + const balanceTileSizes = useCallback(() => { + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + if (!safe.layout) return safe + const nextLayout = balanceLayoutSplits(safe.layout) + if (nextLayout === safe.layout) return safe + expandedTileKeyRef.current = null + return sanitizeState({ ...safe, layout: nextLayout }, id) + }) + }, [id]) + + const closeActiveTile = useCallback(() => { + const active = activeTileRef.current?.tileKey ?? null + if (!active) return + if (isSingleDocShare) return + + const safeNow = sanitizeState(latestStateRef.current, id) + const leaves = getLeavesSafe(safeNow.layout) + if (leaves.length <= 1) { + closeAllTilesToDashboard() + return + } + + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + if (!safe.layout) return safe + if (!safe.tiles[active]) return safe + const nextLayout = removeLeaf(safe.layout, active) + const nextTiles: Record = { ...safe.tiles } + delete nextTiles[active] + expandedTileKeyRef.current = null + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, [closeAllTilesToDashboard, id, isSingleDocShare]) + + const closeOtherTiles = useCallback(() => { + const active = activeTileRef.current?.tileKey ?? null + if (!active) return + if (isSingleDocShare) return + + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const spec = safe.tiles[active] + if (!spec) return safe + expandedTileKeyRef.current = null + return sanitizeState({ layout: active, tiles: { [active]: spec } as Record }, id) + }) + }, [id, isSingleDocShare]) + + useEffect(() => { + if (!isSingleDocShare) return + setMosaicState(defaultState(id)) + }, [id, isSingleDocShare]) + + useEffect(() => { + if (typeof window === 'undefined') return + const mode = deriveDocumentViewMode(id, mosaicState.tiles) + const prev = lastReportedViewModeRef.current + if (prev && prev.docId === id && prev.mode === mode) return + lastReportedViewModeRef.current = { docId: id, mode } + dispatchMosaicCurrentViewMode(id, mode) + }, [id, mosaicState.tiles]) + + useShortcut('tiles.split.direction.auto', () => setInsertSplitMode('auto')) + useShortcut('tiles.split.direction.row', () => setInsertSplitMode('row')) + useShortcut('tiles.split.direction.column', () => setInsertSplitMode('column')) + useShortcut('tiles.swap.left', () => swapActiveTileByDirection('left'), { preventDefault: true }) + useShortcut('tiles.swap.right', () => swapActiveTileByDirection('right'), { preventDefault: true }) + useShortcut('tiles.swap.up', () => swapActiveTileByDirection('up'), { preventDefault: true }) + useShortcut('tiles.swap.down', () => swapActiveTileByDirection('down'), { preventDefault: true }) + useShortcut('tiles.swap.last', () => swapActiveTileWithLast(), { preventDefault: true }) + useShortcut('tiles.focus.left', () => focusActiveTileByDirection('left'), { preventDefault: true }) + useShortcut('tiles.focus.right', () => focusActiveTileByDirection('right'), { preventDefault: true }) + useShortcut('tiles.focus.up', () => focusActiveTileByDirection('up'), { preventDefault: true }) + useShortcut('tiles.focus.down', () => focusActiveTileByDirection('down'), { preventDefault: true }) + useShortcut( + 'tiles.toggle.expand', + () => { + const active = activeTileRef.current?.tileKey ?? null + if (!active) return + toggleExpandTile(active) + }, + { preventDefault: true }, + ) + useShortcut('tiles.balance', () => balanceTileSizes(), { preventDefault: true }) + useShortcut('tiles.close.active', () => closeActiveTile(), { preventDefault: true }) + useShortcut('tiles.close.others', () => closeOtherTiles(), { preventDefault: true }) + useShortcut( + 'tiles.open.editor', + () => { + const target = activeDocumentIdRef.current || id + addEditorTile(target) + }, + { preventDefault: true }, + ) + useShortcut( + 'tiles.open.preview', + () => { + const target = activeDocumentIdRef.current || id + addPreviewTile(target) + }, + { preventDefault: true }, + ) + + useEffect(() => { + if (isSingleDocShare) return + // If the currently focused document (URL) is no longer present in any tile (e.g. the user closed that tile), + // do not rewrite existing tiles to show it. Instead, move focus/URL to a remaining document. + const tilesNow = Object.values(mosaicState.tiles) + const hasFocused = tilesNow.some((t) => t.documentId === id) + if (!hasFocused && tilesNow.length > 0 && lastSeenRouteDocIdRef.current === id) { + const fallback = tilesNow[0]?.documentId?.trim() + if (fallback && fallback !== id) { + markActiveDocument(fallback) + return + } + } + lastSeenRouteDocIdRef.current = id + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const tiles = Object.entries(safe.tiles) as Array<[TileKey, TileSpec]> + const hasAny = tiles.some(([, t]) => t.documentId === id) + if (hasAny) return safe + + const editorCandidate = tiles.find(([, t]) => t.mode === 'editor') + if (editorCandidate) { + const [editorKey, editorSpec] = editorCandidate + const prevDocId = editorSpec.documentId + const previewCandidate = tiles.find(([, t]) => t.mode === 'preview' && t.documentId === prevDocId) + const nextTiles: Record = {} + for (const [key, spec] of tiles) { + if (key === editorKey) nextTiles[key] = { ...spec, documentId: id } + else if (previewCandidate && key === previewCandidate[0]) nextTiles[key] = { ...spec, documentId: id } + else nextTiles[key] = spec + } + return sanitizeState({ ...safe, tiles: nextTiles }, id) + } + + const editorKey = makeTileKey() + const nextTiles: Record = { + ...safe.tiles, + [editorKey]: { mode: 'editor', documentId: id }, + } + const nextLayoutBase: MosaicNode = insertLeafWithMode( + safe.layout, + editorKey, + activeTileRef.current?.tileKey, + insertSplitMode, + ) + const nextLayout = + insertSplitMode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, [id, insertSplitMode, isSingleDocShare, markActiveDocument, mosaicState.tiles]) + + const addEditorTile = useCallback( + (docId: string) => { + const target = docId.trim() + if (!target) return + if (isSingleDocShare) return + if (!canAccessSharedDocument(target)) { + toast.info('This document is not included in the shared scope.') + return + } + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const exists = Object.values(safe.tiles).some((t) => t.documentId === target && t.mode === 'editor') + if (exists) return safe + const editorKey = makeTileKey() + const nextLayoutBase = insertLeafWithMode(safe.layout, editorKey, activeTileRef.current?.tileKey, insertSplitMode) + const nextLayout = + insertSplitMode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase + if (insertSplitMode === 'row') expandedTileKeyRef.current = null + const nextTiles: Record = { + ...safe.tiles, + [editorKey]: { mode: 'editor', documentId: target }, + } + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, + [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], + ) + + const addPreviewTile = useCallback( + (docId: string, splitMode?: InsertSplitMode) => { + const target = docId.trim() + if (!target) return + if (isSingleDocShare) return + if (!canAccessSharedDocument(target)) { + toast.info('This document is not included in the shared scope.') + return + } + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const exists = Object.values(safe.tiles).some((t) => t.documentId === target && t.mode === 'preview') + if (exists) return safe + const previewKey = makeTileKey() + const mode = splitMode ?? insertSplitMode + const nextLayoutBase = insertLeafWithMode(safe.layout, previewKey, activeTileRef.current?.tileKey, mode) + const nextLayout = mode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase + if (mode === 'row') expandedTileKeyRef.current = null + const nextTiles: Record = { + ...safe.tiles, + [previewKey]: { mode: 'preview', documentId: target }, + } + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, + [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], + ) + + const addBacklinksTile = useCallback( + (docId: string) => { + const target = docId.trim() + if (!target) return + if (isSingleDocShare) return + if (!canAccessSharedDocument(target)) { + toast.info('This document is not included in the shared scope.') + return + } + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const exists = Object.values(safe.tiles).some((t) => t.documentId === target && t.mode === 'backlinks') + if (exists) return safe + const tileKey = makeTileKey() + const nextLayoutBase = insertLeafWithMode(safe.layout, tileKey, activeTileRef.current?.tileKey, insertSplitMode) + const nextLayout = + insertSplitMode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase + if (insertSplitMode === 'row') expandedTileKeyRef.current = null + const nextTiles: Record = { + ...safe.tiles, + [tileKey]: { mode: 'backlinks', documentId: target }, + } + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, + [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], + ) + + useEffect(() => { + if (isSingleDocShare) return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string; splitMode?: InsertSplitMode }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + if (!documentId) return + addPreviewTile(documentId, detail?.splitMode) + } + window.addEventListener(OPEN_PREVIEW_TILE_EVENT, handler as EventListener) + return () => window.removeEventListener(OPEN_PREVIEW_TILE_EVENT, handler as EventListener) + }, [addPreviewTile, isSingleDocShare]) + + useEffect(() => { + if (isSingleDocShare) return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + if (!documentId) return + addEditorTile(documentId) + } + window.addEventListener(OPEN_EDITOR_TILE_EVENT, handler as EventListener) + return () => window.removeEventListener(OPEN_EDITOR_TILE_EVENT, handler as EventListener) + }, [addEditorTile, isSingleDocShare]) + + useEffect(() => { + if (isSingleDocShare) return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + if (!documentId) return + addBacklinksTile(documentId) + } + window.addEventListener(OPEN_BACKLINKS_TILE_EVENT, handler as EventListener) + return () => window.removeEventListener(OPEN_BACKLINKS_TILE_EVENT, handler as EventListener) + }, [addBacklinksTile, isSingleDocShare]) + + useEffect(() => { + if (typeof window === 'undefined') return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string; mode?: string }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + const mode = detail?.mode + if (!documentId) return + if (mode !== 'editor' && mode !== 'split' && mode !== 'preview') return + applyViewModeForDocument(documentId, mode) + } + window.addEventListener(MOSAIC_SET_VIEW_MODE_EVENT, handler as EventListener) + return () => window.removeEventListener(MOSAIC_SET_VIEW_MODE_EVENT, handler as EventListener) + }, [applyViewModeForDocument]) + + return ( + ( + + )} + /> + ) +} + +function getDocText(doc: NonNullable) { + try { + return doc.getText('content').toString() + } catch { + return '' + } +} + +function toggleTaskInDoc(doc: NonNullable, lineNumber: number, checked: boolean) { + if (!Number.isInteger(lineNumber) || lineNumber < 1) return + const ytext = doc.getText('content') + const text = ytext.toString() + let offset = 0 + let currentLine = 1 + while (currentLine < lineNumber) { + const nextNewline = text.indexOf('\n', offset) + if (nextNewline === -1) return + offset = nextNewline + 1 + currentLine += 1 + } + const nextNewline = text.indexOf('\n', offset) + const lineEnd = nextNewline === -1 ? text.length : nextNewline + const lineText = text.slice(offset, lineEnd) + const taskMatch = lineText.match(/^(\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s*\[)([ xX])(\]\s*)(.*)$/) + if (!taskMatch) return + const [, prefix, currentChar, closing, rest] = taskMatch + const nextChar = checked ? 'x' : ' ' + if (currentChar === nextChar) return + const newLine = `${prefix}${nextChar}${closing}${rest}` + doc.transact(() => { + const y = doc.getText('content') + y.delete(offset, lineText.length) + y.insert(offset, newLine) + }) +} + +function useDocText(doc: DocumentPageRenderContext['doc'], override?: string) { + const [text, setText] = useState(() => (override != null ? override : doc ? getDocText(doc) : '')) + const rafRef = useRef(null) + + useEffect(() => { + if (override != null) { + setText(override) + return + } + if (!doc) { + setText('') + return + } + setText(getDocText(doc)) + const ytext = doc.getText('content') + const onUpdate = () => { + if (rafRef.current != null) return + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null + setText(ytext.toString()) + }) + } + ytext.observe(onUpdate) + return () => { + try { + ytext.unobserve(onUpdate) + } catch {} + if (rafRef.current != null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } + }, [doc, override]) + + return text +} + +function useElementWidth() { + const ref = useRef(null) + const [width, setWidth] = useState(0) + + useEffect(() => { + const el = ref.current + if (!el) return + + const measure = () => { + try { + setWidth(el.getBoundingClientRect().width) + } catch {} + } + + measure() + + if (typeof window === 'undefined') return + + let ro: ResizeObserver | null = null + if ('ResizeObserver' in window) { + ro = new ResizeObserver(() => measure()) + try { + ro.observe(el) + } catch {} + } + + window.addEventListener('resize', measure) + return () => { + try { + ro?.disconnect() + } catch {} + window.removeEventListener('resize', measure) + } + }, []) + + return [ref, width] as const +} + +function useCreatedByPluginId(documentId: string, token?: string | null) { + const docId = documentId.trim() + const query = useQuery({ + queryKey: ['document-meta', docId, token ?? null], + queryFn: async () => fetchDocumentMeta(docId, token ?? undefined), + staleTime: 60_000, + enabled: Boolean(docId), + }) + + const pluginId = useMemo(() => { + const raw = (query.data as any)?.created_by_plugin + return typeof raw === 'string' && raw.trim() ? raw.trim() : '' + }, [query.data]) + + const docType = useMemo(() => { + const raw = (query.data as any)?.type + return typeof raw === 'string' && raw.trim() ? raw.trim() : '' + }, [query.data]) + + return { pluginId, docType, loading: query.isPending, error: query.isError } +} + +function useSplitCapablePluginDocs() { + const [, forceUpdate] = useState(0) + useEffect(() => { + ensureSplitCapablePluginDocListener() + const listener = () => forceUpdate((n) => n + 1) + splitCapablePluginDocSubscribers.add(listener) + return () => { + splitCapablePluginDocSubscribers.delete(listener) + } + }, []) + return splitCapablePluginDocIds +} + +function PluginDocumentTileMount({ + match, + mode, + variant = 'full', + className, +}: { + match: DocumentPluginMatch + mode: 'primary' | 'secondary' + variant?: 'full' | 'preview' + className?: string +}) { + const containerRef = useRef(null) + const disposeRef = useRef<(() => void) | null>(null) + const mountNodeKey = useMemo(() => { + const pluginId = match?.manifest?.id ? String(match.manifest.id) : 'none' + return `${pluginId}:${match.docId}:${match.route}:${match.token ?? ''}:${mode}:${variant}` + }, [match, mode, variant]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + let cancelled = false + + if (disposeRef.current) { + try { + disposeRef.current() + } catch {} + disposeRef.current = null + } + + ;(async () => { + try { + const dispose = (await mountResolvedPlugin( + match, + container, + mode, + variant === 'preview' + ? { + tweakHost: (host) => { + if (!host || typeof host !== 'object') return + if (!host.ui || typeof host.ui !== 'object') host.ui = {} + ;(host.ui as any).mountSplitEditor = (target: Element, options?: any) => { + if (typeof window === 'undefined') return undefined + if (!target) return undefined + const el = target as HTMLElement + const previewDelegate = options?.preview?.delegate + const onDocumentReady = options?.document?.onReady + const nextDocId = options?.docId ?? host?.context?.docId ?? null + const nextToken = options?.token ?? host?.context?.token ?? null + if (typeof nextDocId === 'string' && nextDocId.trim()) { + try { + window.dispatchEvent( + new CustomEvent<{ docId: string }>(PLUGIN_USES_SPLIT_EDITOR_EVENT, { + detail: { docId: nextDocId.trim() }, + }), + ) + } catch { + /* noop */ + } + } + return mountSplitEditorPreviewStage(el, { + docId: nextDocId, + token: nextToken, + host, + previewDelegate, + onDocumentReady, + }) + } + }, + } + : {}, + )) as any + + if (cancelled) { + if (typeof dispose === 'function') { + try { + dispose() + } catch {} + } + return + } + disposeRef.current = typeof dispose === 'function' ? dispose : null + } catch (err) { + console.error('[plugins] failed to mount plugin in tile', err) + } + })() + return () => { + cancelled = true + try { + disposeRef.current?.() + } catch {} + disposeRef.current = null + } + }, [match, mode, mountNodeKey]) + + return ( +
+
+
+ ) +} + +function DocumentMosaicBody({ + ctx, + mosaicState, + setMosaicState, + addPreviewTile, + insertSplitMode, + isSingleDocShare, + onCloseAllTiles, + onActivateDocument, + onToggleExpandTile, + expandedTileKeyRef, +}: { + ctx: DocumentPageRenderContext + mosaicState: MosaicState + setMosaicState: Dispatch> + addPreviewTile: (documentId: string) => void + insertSplitMode: InsertSplitMode + isSingleDocShare: boolean + onCloseAllTiles: () => void + onActivateDocument: (documentId: string, tileKey?: TileKey, mode?: TileMode) => void + onToggleExpandTile: (tileKey: TileKey) => void + expandedTileKeyRef: { current: TileKey | null } +}) { + const setTileMode = useCallback( + (tileKey: TileKey, mode: TileMode) => { + setMosaicState((prev) => { + const safe = sanitizeState(prev, ctx.id) + const spec = safe.tiles[tileKey] + if (!spec) return safe + const nextTiles: Record = {} + for (const [key, value] of Object.entries(safe.tiles) as Array<[TileKey, TileSpec]>) { + if (key === tileKey) nextTiles[key] = { ...value, mode, syncGroupId: undefined } + else nextTiles[key] = value + } + return { ...safe, tiles: nextTiles } + }) + }, + [ctx.id, setMosaicState], + ) + + const splitFromTile = useCallback( + (tileKey: TileKey, path: MosaicPath) => { + if (isSingleDocShare) return + const splitPath = [...path] as MosaicPath + setMosaicState((prev) => { + const safe = sanitizeState(prev, ctx.id) + const spec = safe.tiles[tileKey] + if (!spec) return safe + if (spec.mode !== 'editor' && spec.mode !== 'preview') return safe + + const opposite: TileMode = spec.mode === 'editor' ? 'preview' : 'editor' + const groupId = makeSyncGroupId() + const newKey = makeTileKey() + + const nextTiles: Record = { + ...safe.tiles, + [tileKey]: { ...spec, syncGroupId: groupId }, + [newKey]: { mode: opposite, documentId: spec.documentId, syncGroupId: groupId }, + } + + const wantsEditorLeft = spec.mode === 'preview' + const replacement: MosaicNode = { + direction: 'row', + first: wantsEditorLeft ? newKey : tileKey, + second: wantsEditorLeft ? tileKey : newKey, + splitPercentage: 50, + } + let nextLayout = replaceNodeAtPath(safe.layout, splitPath, replacement) + const nextState = sanitizeState({ layout: nextLayout, tiles: nextTiles }, ctx.id) + if (insertSplitMode === 'auto') { + const grid = maybeBuildTwoDocSplitGrid(nextState.layout, nextState.tiles) + if (!grid) return nextState + return sanitizeState({ ...nextState, layout: grid }, ctx.id) + } + if (insertSplitMode === 'row' && nextState.layout) { + expandedTileKeyRef.current = null + return sanitizeState({ ...nextState, layout: balanceLayoutSplits(nextState.layout) }, ctx.id) + } + return nextState + }) + }, + [ctx.id, expandedTileKeyRef, insertSplitMode, isSingleDocShare, setMosaicState], + ) + + return ( +
+ + className="refmd-mosaic-theme" + value={mosaicState.layout} + onChange={(next) => { + expandedTileKeyRef.current = null + if (!isSingleDocShare && getLeavesSafe(next).length === 0) { + onCloseAllTiles() + return + } + setMosaicState((prev) => { + const prevSafe = sanitizeState(prev, ctx.id) + return sanitizeState({ ...prevSafe, layout: next }, ctx.id) + }) + }} + renderTile={(tileId, path) => { + const spec = mosaicState.tiles[tileId] + if (!spec) { + return ( + path={path} title="" toolbarControls={[]}> +
Missing tile state.
+ + ) + } + + const docId = spec.documentId + const isFocusedDoc = docId === ctx.id + const hasPreviewTileForDoc = Object.entries(mosaicState.tiles).some( + ([key, tile]) => key !== tileId && tile.documentId === docId && tile.mode === 'preview', + ) + const hasEditorTileForDoc = Object.entries(mosaicState.tiles).some( + ([key, tile]) => key !== tileId && tile.documentId === docId && tile.mode === 'editor', + ) + + if (spec.mode === 'editor') { + return ( + splitFromTile(tileId, path)} + onToggleExpand={() => onToggleExpandTile(tileId)} + onSwitchToPreview={() => setTileMode(tileId, 'preview')} + isSingleDocShare={isSingleDocShare} + onActivate={() => onActivateDocument(docId, tileId, 'editor')} + /> + ) + } + + if (spec.mode === 'backlinks') { + return ( + onToggleExpandTile(tileId)} + onActivate={() => onActivateDocument(docId, tileId, 'backlinks')} + /> + ) + } + + return ( + splitFromTile(tileId, path)} + onToggleExpand={() => onToggleExpandTile(tileId)} + onSwitchToEditor={() => setTileMode(tileId, 'editor')} + isSingleDocShare={isSingleDocShare} + onActivate={() => onActivateDocument(docId, tileId, 'preview')} + /> + ) + }} + /> +
+ ) +} + +function MosaicPreviewTile({ + tileKey, + path, + documentId, + syncGroupId, + isFocusedDocument, + activeCtx, + addPreviewTile, + onSplit, + onToggleExpand, + onSwitchToEditor, + isSingleDocShare, + onActivate, +}: { + tileKey: TileKey + path: MosaicPath + documentId: string + syncGroupId?: string | null + isFocusedDocument: boolean + activeCtx: DocumentPageRenderContext + addPreviewTile: (documentId: string) => void + onSplit: () => void + onToggleExpand: () => void + onSwitchToEditor: () => void + isSingleDocShare: boolean + onActivate?: () => void +}) { + const { activeWorkspaceId } = useAuthContext() + const splitCapablePluginDocs = useSplitCapablePluginDocs() + const [containerRef, containerWidth] = useElementWidth() + const forceFloatingToc = containerWidth > 0 && containerWidth < FORCE_FLOATING_TOC_MAX_WIDTH_PX + const [externalScrollToLine, setExternalScrollToLine] = useState(undefined) + + useEffect(() => { + if (!syncGroupId) { + setExternalScrollToLine(undefined) + return + } + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail || detail.source !== 'editor') return + if (detail.groupId !== syncGroupId) return + const line = detail.line + if (!Number.isFinite(line) || (line as number) < 1) return + setExternalScrollToLine(line) + } + window.addEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) + return () => window.removeEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) + }, [syncGroupId]) + + const pluginHint = isFocusedDocument ? activeCtx.loaderData?.createdByPlugin ?? null : null + const pluginLookup = useCreatedByPluginId(documentId, activeCtx.shareToken ?? null) + const pluginId = + (typeof pluginHint === 'string' && pluginHint.trim() ? pluginHint.trim() : pluginLookup.pluginId) || '' + const docType = pluginLookup.docType || '' + const pluginTileMode = isFocusedDocument ? ('primary' as const) : ('secondary' as const) + + const pluginQuery = useQuery({ + queryKey: [ + 'plugin-doc-match', + documentId, + activeCtx.shareToken ?? null, + pluginId || null, + docType || null, + pluginTileMode, + activeWorkspaceId ?? null, + ], + queryFn: async () => { + const token = activeCtx.shareToken ?? null + const document = docType ? { type: docType } : undefined + if (pluginId) { + return resolvePluginForDocumentById(documentId, pluginId, token, { source: pluginTileMode, document, workspaceId: activeWorkspaceId ?? null }) + } + return resolvePluginForDocument(documentId, token, { source: pluginTileMode, document, workspaceId: activeWorkspaceId ?? null }) + }, + staleTime: 60_000, + enabled: Boolean(documentId), + }) + + const pluginMatch = (pluginQuery.data ?? null) as DocumentPluginMatch | null + const shouldMountPlugin = Boolean(pluginMatch) + const isPluginDocument = Boolean(pluginId || pluginMatch) + const pluginSupportsSplit = Boolean(isPluginDocument && splitCapablePluginDocs.has(documentId)) + + const allowSplitControls = !isSingleDocShare && (!isPluginDocument || pluginSupportsSplit) + const isMarkdownPreview = !shouldMountPlugin && !isPluginDocument + + const useLiveContent = Boolean( + isMarkdownPreview && + isFocusedDocument && + !activeCtx.showOverlay && + activeCtx.doc && + activeCtx.awareness && + !activeCtx.realtimeError, + ) + const liveContent = useDocText(useLiveContent ? activeCtx.doc : null, useLiveContent ? activeCtx.previewOverride : undefined) + const canToggleTasks = Boolean(useLiveContent && activeCtx.doc && !activeCtx.isReadOnly && !activeCtx.previewOverride) + const shareToken = activeCtx.shareToken + + const previewSession = useCollaborativeDocument(documentId, shareToken, { + enabled: isMarkdownPreview && !useLiveContent, + contributeToRealtimeContext: false, + useUrlShareTokenFallback: false, + validateShareToken: false, + loadMeta: false, + trackAwareness: false, + disablePersistence: true, + }) + const realtimeContent = useDocText(!useLiveContent ? previewSession.doc : null, undefined) + + const contentQuery = useQuery({ + queryKey: ['document-content', documentId], + queryFn: async () => fetchDocumentContent(documentId), + staleTime: 30 * 1000, + enabled: isMarkdownPreview && !useLiveContent && !shareToken, + }) + + const fetchedContent = useMemo(() => { + const data = contentQuery.data as any + if (data && typeof data === 'object' && 'content' in data) { + const raw = (data as any).content + return typeof raw === 'string' ? raw : '' + } + return '' + }, [contentQuery.data]) + + const resolvedContent = useMemo(() => { + if (useLiveContent) return liveContent + if (realtimeContent.length > 0) return realtimeContent + if (fetchedContent.length > 0) return fetchedContent + // Both sources are currently empty. Prefer REST for non-share (it represents persisted content), + // otherwise fall back to realtime content. + return shareToken ? realtimeContent : fetchedContent + }, [ + fetchedContent, + liveContent, + realtimeContent, + shareToken, + useLiveContent, + ]) + + const showError = useMemo(() => { + if (useLiveContent) return false + if (previewSession.error && !fetchedContent) return true + if (!shareToken && contentQuery.isError && !previewSession.doc) return true + return false + }, [contentQuery.isError, fetchedContent, previewSession.doc, previewSession.error, shareToken, useLiveContent]) + + const showLoading = useMemo(() => { + if (useLiveContent) return false + if (showError) return false + if (shareToken) return previewSession.status === 'connecting' && !previewSession.error + return contentQuery.isLoading && !previewSession.error + }, [ + contentQuery.isLoading, + contentQuery.isError, + previewSession.status, + previewSession.error, + shareToken, + showError, + useLiveContent, + ]) + + const toolbarControls = useMemo(() => { + if (isSingleDocShare) return [] + return [ + ...(allowSplitControls + ? [ + , + ] + : []), + ...(allowSplitControls + ? [ + , + ] + : []), + , + , + , + , +
+ ) } diff --git a/app/src/widgets/header/Header.tsx b/app/src/widgets/header/Header.tsx index c12930a6..2981e86b 100644 --- a/app/src/widgets/header/Header.tsx +++ b/app/src/widgets/header/Header.tsx @@ -1,11 +1,13 @@ -import { useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { Columns, Eye, FileCode, Link2, Menu, Moon, Search, Share2, Sun } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { toast } from 'sonner' import { useTheme } from '@/shared/contexts/theme-context' +import { useIsMobile } from '@/shared/hooks/use-mobile' import { useShortcut } from '@/shared/hooks/use-shortcut' +import { MOSAIC_CURRENT_VIEW_MODE_EVENT, dispatchMosaicSetViewMode, dispatchOpenBacklinksTile } from '@/shared/lib/mosaic-events' import { cn } from '@/shared/lib/utils' import type { DocumentHeaderAction } from '@/shared/types/document' import type { HeaderRealtimeState } from '@/shared/types/header' @@ -14,7 +16,7 @@ import { Button } from '@/shared/ui/button' import { SidebarTrigger, useSidebar } from '@/shared/ui/sidebar' import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' -import { createDocument, documentKeys } from '@/entities/document' +import { createDocument, documentKeys, fetchDocumentMeta } from '@/entities/document' import { useAuthContext } from '@/features/auth' import { useEditorContext, useViewController } from '@/features/edit-document' @@ -32,33 +34,108 @@ interface HeaderProps { variant?: 'overlay' | 'mobile' } +const PLUGIN_USES_SPLIT_EDITOR_EVENT = 'refmd:plugin:uses-split-editor' + const defaultRealtimeState: HeaderRealtimeState = { connected: false, showEditorFeatures: false, documentTitle: undefined, documentId: undefined, documentPath: undefined, + documentPluginId: undefined, documentStatus: undefined, documentBadge: undefined, documentActions: [], onlineUsers: [], } +type ViewMode = 'editor' | 'split' | 'preview' +type ViewModeButtonItem = { mode: ViewMode; icon: ReactElement; tooltip: string } + +const HeaderViewModeControls = memo(function HeaderViewModeControls({ + buttons, + activeMode, + onChange, + showBacklinksButton, + onBacklinksClick, + iconClass, +}: { + buttons: ViewModeButtonItem[] + activeMode: ViewMode + onChange: (mode: ViewMode) => void + showBacklinksButton: boolean + onBacklinksClick: () => void + iconClass: string +}) { + return ( +
+ {buttons.map((item, idx) => { + const first = idx === 0 + const last = idx === buttons.length - 1 + const isActive = activeMode === item.mode + return ( + + + + + + + {item.tooltip} + + ) + })} + {showBacklinksButton && ( + + + + + + + Open backlinks tile + + )} +
+ ) +}) + export function Header({ className, realtime, variant = 'overlay' }: HeaderProps) { const { isDarkMode, toggleTheme } = useTheme() const { signOut } = useAuthContext() const queryClient = useQueryClient() const rt = realtime ?? defaultRealtimeState + const isMobile = useIsMobile() const vc = useViewController() const { editor } = useEditorContext() const { toggleSidebar } = useSidebar() const navigate = useNavigate() + const focusedDocumentIdRef = useRef(undefined) + const mosaicViewModeRef = useRef>(new Map()) const [mounted, setMounted] = useState(false) + const [isCompact, setIsCompact] = useState(false) + const [headerViewMode, setHeaderViewMode] = useState<'editor' | 'split' | 'preview'>(() => { + const initial = vc.viewMode + return initial === 'editor' || initial === 'split' || initial === 'preview' ? initial : 'split' + }) const [searchOpenLocal, setSearchOpenLocal] = useState(false) const [searchPresetTag, setSearchPresetTag] = useState(null) const [shareOpen, setShareOpen] = useState(false) - const [headerViewMode, setHeaderViewMode] = useState<'editor' | 'split' | 'preview'>('split') - const [isCompact, setIsCompact] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const documentBadge = rt.documentBadge const documentStatus = rt.documentStatus @@ -85,9 +162,50 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps }, []) const canShare = Boolean(rt.documentId) + focusedDocumentIdRef.current = rt.documentId const iconClass = 'h-[18px] w-[18px]' useEffect(() => { setMounted(true) }, []) + useEffect(() => { + if (rt.documentId) return + const mode = vc.viewMode + if (mode === 'editor' || mode === 'split' || mode === 'preview') { + setHeaderViewMode(mode) + } + }, [rt.documentId, vc.viewMode]) + + useEffect(() => { + if (!rt.documentId) return + const mode = mosaicViewModeRef.current.get(rt.documentId) + if (!mode) return + setHeaderViewMode(mode) + }, [rt.documentId]) + + useEffect(() => { + if (typeof window === 'undefined') return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string; mode?: string }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + const mode = detail?.mode + if (!documentId) return + if (mode !== 'editor' && mode !== 'split' && mode !== 'preview') return + mosaicViewModeRef.current.set(documentId, mode) + if (focusedDocumentIdRef.current !== documentId) return + setHeaderViewMode(mode) + } + window.addEventListener(MOSAIC_CURRENT_VIEW_MODE_EVENT, handler as EventListener) + return () => window.removeEventListener(MOSAIC_CURRENT_VIEW_MODE_EVENT, handler as EventListener) + }, []) + useEffect(() => { + if (typeof window === 'undefined') return + const mq = window.matchMedia('(max-width: 1024px)') + const update = (event?: MediaQueryListEvent) => { + setIsCompact(event ? event.matches : mq.matches) + } + update() + mq.addEventListener('change', update) + return () => mq.removeEventListener('change', update) + }, []) useShortcut( 'global.search.open', useCallback( @@ -168,37 +286,94 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps toggleSidebar() }, [toggleSidebar]), ) - useEffect(() => { setHeaderViewMode(vc.viewMode) }, [vc.viewMode]) useEffect(() => { setSearchPresetTag(vc.searchPresetTag) if (vc.searchNonce > 0) setSearchOpenLocal(true) }, [vc.searchNonce, vc.searchPresetTag]) + // Dropped save-status pill and compatibility props + + const effectiveViewMode = headerViewMode + const [splitCapablePluginDocs, setSplitCapablePluginDocs] = useState>(() => new Set()) useEffect(() => { if (typeof window === 'undefined') return - const mq = window.matchMedia('(max-width: 1024px)') - const update = (event?: MediaQueryListEvent) => { - setIsCompact(event ? event.matches : mq.matches) + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ docId?: string }>).detail + const docId = typeof detail?.docId === 'string' ? detail.docId.trim() : '' + if (!docId) return + setSplitCapablePluginDocs((prev) => { + if (prev.has(docId)) return prev + const next = new Set(prev) + next.add(docId) + return next + }) } - update() - mq.addEventListener('change', update) - return () => mq.removeEventListener('change', update) + window.addEventListener(PLUGIN_USES_SPLIT_EDITOR_EVENT, handler as EventListener) + return () => window.removeEventListener(PLUGIN_USES_SPLIT_EDITOR_EVENT, handler as EventListener) }, []) + + const pluginDocMetaQuery = useQuery({ + queryKey: ['document-meta', rt.documentId ?? null, 'header'], + queryFn: async () => { + const docId = typeof rt.documentId === 'string' ? rt.documentId.trim() : '' + if (!docId) return null as any + const token = (() => { + if (typeof window === 'undefined') return undefined + try { + const params = new URLSearchParams(window.location.search) + const raw = params.get('token') + return raw && raw.trim().length > 0 ? raw.trim() : undefined + } catch { + return undefined + } + })() + return fetchDocumentMeta(docId, token) + }, + staleTime: 60_000, + enabled: Boolean(rt.documentId), + }) + const pluginIdFromMeta = useMemo(() => { + const raw = (pluginDocMetaQuery.data as any)?.created_by_plugin + return typeof raw === 'string' && raw.trim() ? raw.trim() : '' + }, [pluginDocMetaQuery.data]) + const pluginIdHint = typeof rt.documentPluginId === 'string' ? rt.documentPluginId.trim() : '' + const isPluginDocument = Boolean(pluginIdFromMeta || pluginIdHint) + const pluginViewPolicy = useMemo<'normal' | 'splitCapable' | 'previewOnly'>(() => { + if (!rt.documentId) return 'normal' + if (!isPluginDocument) return 'normal' + return splitCapablePluginDocs.has(rt.documentId) ? 'splitCapable' : 'previewOnly' + }, [isPluginDocument, rt.documentId, splitCapablePluginDocs]) + const disallowSplit = pluginViewPolicy === 'previewOnly' + const changeView = useCallback( + (mode: ViewMode) => { + const normalized = pluginViewPolicy === 'previewOnly' ? 'preview' : mode + const nextMode = normalized === 'split' && isCompact ? 'preview' : normalized + setHeaderViewMode(nextMode) + if (isMobile) { + vc.setViewMode(nextMode) + return + } + const focusedDocumentId = focusedDocumentIdRef.current + if (focusedDocumentId) { + dispatchMosaicSetViewMode(focusedDocumentId, nextMode) + } + vc.setViewMode(nextMode) + }, + [isCompact, isMobile, pluginViewPolicy, vc], + ) + useEffect(() => { if (!mounted) return - if (isCompact && vc.viewMode === 'split') { - vc.setViewMode('preview') + if (pluginViewPolicy !== 'previewOnly') return + setHeaderViewMode('preview') + const focusedDocumentId = rt.documentId + if (focusedDocumentId) { + dispatchMosaicSetViewMode(focusedDocumentId, 'preview') } - }, [isCompact, vc, vc.viewMode, mounted]) - // Dropped save-status pill and compatibility props - - const effectiveViewMode = headerViewMode - const changeView = useCallback((mode: 'editor' | 'split' | 'preview') => { - if (mode === 'split' && isCompact) { + if (isMobile) { vc.setViewMode('preview') - return } - vc.setViewMode(mode) - }, [isCompact, vc]) + }, [isMobile, mounted, pluginViewPolicy, rt.documentId, vc]) + const shareHandler = () => { if (!rt.documentId) return setShareOpen(true) @@ -207,11 +382,38 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps void signOut() }, [signOut]) const handleBacklinksClick = useCallback(() => { - if (!isCompact) { + const focusedDocumentId = focusedDocumentIdRef.current + if (!focusedDocumentId) return + dispatchOpenBacklinksTile(focusedDocumentId) + }, []) + + useShortcut( + 'view.mode.editor', + useCallback(() => { + changeView('editor') + }, [changeView]), + ) + + useShortcut( + 'view.mode.preview', + useCallback(() => { + changeView('preview') + }, [changeView]), + ) + + useShortcut( + 'view.mode.split', + useCallback(() => { changeView('split') - } - vc.toggleBacklinks() - }, [vc, changeView, isCompact]) + }, [changeView]), + ) + + useShortcut( + 'view.backlinks.toggle', + useCallback(() => { + handleBacklinksClick() + }, [handleBacklinksClick]), + ) useEffect(() => { if (!rt.documentId && shareOpen) { @@ -242,16 +444,31 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps [editor], ) + useEffect(() => { + if (!mounted) return + if (isCompact && headerViewMode === 'split') { + setHeaderViewMode('preview') + } + }, [headerViewMode, isCompact, mounted]) + + useEffect(() => { + if (!isMobile) return + const next = vc.viewMode === 'split' ? 'preview' : vc.viewMode + if (next === headerViewMode) return + setHeaderViewMode(next) + }, [headerViewMode, isMobile, vc.viewMode]) + const viewModeButtons = useMemo(() => { - const items: Array<{ mode: 'editor' | 'split' | 'preview'; icon: ReactElement; tooltip: string }> = [ - { mode: 'editor', icon: , tooltip: 'Editor only' }, - ] - if (!isCompact) { - items.push({ mode: 'split', icon: , tooltip: 'Split view' }) + if (pluginViewPolicy === 'previewOnly') { + return [{ mode: 'preview', icon: , tooltip: 'Preview only' }] satisfies ViewModeButtonItem[] } - items.push({ mode: 'preview', icon: , tooltip: 'Preview only' }) - return items - }, [isCompact]) + const order: ViewMode[] = disallowSplit || isCompact ? ['editor', 'preview'] : ['editor', 'split', 'preview'] + return order.map((mode) => { + if (mode === 'editor') return { mode, icon: , tooltip: 'Editor only' } + if (mode === 'split') return { mode, icon: , tooltip: 'Split view' } + return { mode, icon: , tooltip: 'Preview only' } + }) + }, [disallowSplit, iconClass, isCompact, pluginViewPolicy]) const desktopToolbar = (
@@ -300,6 +517,16 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps
+ {rt.showEditorFeatures && ( + + )} {textActions.length > 0 && (
{textActions.map((action) => ( @@ -315,55 +542,6 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps ))}
)} - {rt.showEditorFeatures && ( -
- {viewModeButtons.map((item, idx) => { - const first = idx === 0 - const last = idx === viewModeButtons.length - 1 - const isActive = effectiveViewMode === item.mode - return ( - - - - - - - {item.tooltip} - - ) - })} - {rt.documentId && ( - - - - - - - Toggle backlinks - - )} -
- )} {iconActions.map((action) => ( @@ -422,7 +600,11 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps
-
@@ -472,15 +654,17 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps setMobileMenuOpen(false)} - showEditorFeatures={rt.showEditorFeatures} - headerViewMode={headerViewMode} - changeView={changeView} - isCompact={isCompact} canShare={canShare} onShare={shareHandler} onToggleTheme={() => { toggleTheme(); setMobileMenuOpen(false) }} onSignOut={() => { handleSignOut(); setMobileMenuOpen(false) }} documentActions={documentActions} + viewMode={ + rt.showEditorFeatures && pluginViewPolicy !== 'previewOnly' + ? (effectiveViewMode === 'editor' ? 'editor' : 'preview') + : undefined + } + onChangeViewMode={rt.showEditorFeatures && pluginViewPolicy !== 'previewOnly' ? (mode) => changeView(mode) : undefined} /> {rt.documentId && ( diff --git a/app/src/widgets/plugins/SplitEditorHost.tsx b/app/src/widgets/plugins/SplitEditorHost.tsx index d26ac12a..5f28a36e 100644 --- a/app/src/widgets/plugins/SplitEditorHost.tsx +++ b/app/src/widgets/plugins/SplitEditorHost.tsx @@ -1,255 +1,12 @@ "use client" -import { useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' +export type { + SplitEditorDocumentApi, + SplitEditorPreviewDelegate, + SplitEditorPreviewDelegateResult, + SplitEditorStageOptions, +} from '@/features/plugins/ui/SplitEditorHost' -import type { ViewMode } from '@/shared/types/view-mode' +export { mountSplitEditorStage, SplitEditorPortalRenderer } from '@/features/plugins/ui/SplitEditorHost' -import { useAuthContext } from '@/features/auth' -import { EditorOverlay, MarkdownEditor, useCollaborativeDocument } from '@/features/edit-document' -import type { PreviewPaneProps } from '@/features/edit-document/ui/PreviewPane' - -export type SplitEditorPreviewDelegateResult = { - update?: (payload: { content: string; viewMode: ViewMode }) => void - dispose?: () => void -} - -export type SplitEditorPreviewDelegate = (ctx: { - container: HTMLElement - docId: string - token?: string | null - host: any -}) => SplitEditorPreviewDelegateResult | void - -export type SplitEditorDocumentApi = { - docId: string - token?: string | null - getContent: () => string - setContent: (value: string) => void -} - -export type SplitEditorStageOptions = { - docId?: string | null - token?: string | null - host: any - previewDelegate?: SplitEditorPreviewDelegate - onDocumentReady?: (api: SplitEditorDocumentApi) => void | (() => void) -} - -type MountRecord = { - id: symbol - container: HTMLElement - options: SplitEditorStageOptions -} - -const activeMounts = new Map() -const listeners = new Set<() => void>() - -const emit = () => { - listeners.forEach((listener) => { - try { - listener() - } catch { - /* noop */ - } - }) -} - -export function mountSplitEditorStage(container: HTMLElement, options: SplitEditorStageOptions) { - const id = Symbol('split-editor') - activeMounts.set(id, { id, container, options }) - emit() - return () => { - activeMounts.delete(id) - emit() - try { - container.innerHTML = '' - } catch { - /* noop */ - } - } -} - -function useSplitEditorMounts() { - const [, forceUpdate] = useState(0) - useEffect(() => { - const listener = () => forceUpdate((n) => n + 1) - listeners.add(listener) - return () => { - listeners.delete(listener) - } - }, []) - return Array.from(activeMounts.values()) -} - -export function SplitEditorPortalRenderer() { - const mounts = useSplitEditorMounts() - if (mounts.length === 0) return null - return ( - <> - {mounts.map((mount) => { - if (!mount.container || !mount.container.isConnected) { - return null - } - return createPortal( - , - mount.container, - ) - })} - - ) -} - -function PluginSplitEditorStage({ docId, token, host, previewDelegate, onDocumentReady }: SplitEditorStageOptions) { - if (!docId) { - return ( -
- No document selected. -
- ) - } - return ( - - ) -} - -type StageInnerProps = { - docId: string - token: string | null - host: any - previewDelegate?: SplitEditorPreviewDelegate - onDocumentReady?: (api: SplitEditorDocumentApi) => void | (() => void) -} - -function PluginSplitEditorStageInner({ docId, token, host, previewDelegate, onDocumentReady }: StageInnerProps) { - const { user } = useAuthContext() - const { status, doc, awareness, isReadOnly, error } = useCollaborativeDocument(docId, token ?? undefined) - const [anonIdentity] = useState(() => { - if (user) return null - try { - const keyName = 'refmd_anon_identity' - const saved = localStorage.getItem(keyName) - if (saved) return JSON.parse(saved) as { id: string; name: string } - const rnd = Math.random().toString(36).slice(-4) - const ident = { id: `guest:${rnd}`, name: `Guest-${rnd}` } - localStorage.setItem(keyName, JSON.stringify(ident)) - return ident - } catch { - const rnd = Math.random().toString(36).slice(-4) - return { id: `guest:${rnd}`, name: `Guest-${rnd}` } - } - }) - - const shouldShowOverlay = Boolean(error) || !doc || !awareness - const overlayLabel = error || (status === 'connecting' ? 'Connecting…' : 'Loading…') - - const renderPreview = previewDelegate - ? (props: PreviewPaneProps) => ( - - ) - : undefined - - useEffect(() => { - if (!doc || !onDocumentReady) return - const ytext = doc.getText('content') - const api: SplitEditorDocumentApi = { - docId, - token: token ?? undefined, - getContent: () => String(ytext?.toString?.() ?? ''), - setContent: (value: string) => { - if (!ytext) return - doc.transact(() => { - try { - const length = ytext.length - ytext.delete(0, length) - ytext.insert(0, value ?? '') - } catch { - /* noop */ - } - }) - }, - } - const cleanup = onDocumentReady(api) - return () => { - try { cleanup && cleanup() } catch {} - } - }, [doc, onDocumentReady, docId, token]) - - return ( -
- {shouldShowOverlay && } - {doc && awareness && !error && ( - - )} -
- ) -} - -type PreviewBridgeProps = PreviewPaneProps & { - delegate: SplitEditorPreviewDelegate - docId: string - token: string | null - host: any -} - -function PluginPreviewBridge({ delegate, docId, token, host, content, viewMode = 'preview' }: PreviewBridgeProps) { - const containerRef = useRef(null) - const delegateRef = useRef(null) - const contextKey = useMemo(() => `${docId}:${token ?? ''}`, [docId, token]) - - useEffect(() => { - const container = containerRef.current - if (!container) return - container.innerHTML = '' - const result = delegate({ - container, - docId, - token, - host, - }) - delegateRef.current = result || null - return () => { - try { - container.innerHTML = '' - } catch { - /* noop */ - } - delegateRef.current?.dispose?.() - delegateRef.current = null - } - }, [delegate, contextKey, host]) - - useEffect(() => { - delegateRef.current?.update?.({ content, viewMode }) - }, [content, viewMode]) - - return ( -
-
-
- ) -} +export * from '@/features/plugins/ui/SplitEditorHost' diff --git a/app/src/widgets/routes/PluginFallback.tsx b/app/src/widgets/routes/PluginFallback.tsx index 69d2595d..0c3af124 100644 --- a/app/src/widgets/routes/PluginFallback.tsx +++ b/app/src/widgets/routes/PluginFallback.tsx @@ -11,12 +11,10 @@ import { type RoutePluginMatch, } from '@/features/plugins' -import { SplitEditorPortalRenderer } from '@/widgets/plugins/SplitEditorHost' - export default function PluginFallback() { const navigate = useNavigate() - const { user, loading: authLoading } = useAuthContext() + const { user, loading: authLoading, activeWorkspaceId } = useAuthContext() const realtime = useRealtime() const shareTokenFromContext = useShareToken() const routerState = useRouterState() @@ -55,6 +53,16 @@ export default function PluginFallback() { const [plugin, setPlugin] = React.useState(null) const containerRef = React.useRef(null) const disposeRef = React.useRef void)>(null) + const routeKey = React.useMemo(() => { + const pathname = routerState.location?.pathname ?? '' + const hash = routerState.location?.hash ?? '' + const searchPart = search ? (search.startsWith('?') ? search : `?${search}`) : '' + return `${pathname}${searchPart}${hash}` + }, [routerState.location?.hash, routerState.location?.pathname, search]) + const mountNodeKey = React.useMemo(() => { + const pluginId = plugin?.manifest?.id ? String(plugin.manifest.id) : 'none' + return `${pluginId}:${routeKey}` + }, [plugin, routeKey]) React.useEffect(() => { if (allowAnonymous || authLoading || authReady) return @@ -99,7 +107,7 @@ export default function PluginFallback() { ;(async () => { try { - const match = await resolvePluginForRoute(path, { token: shareToken ?? undefined }) + const match = await resolvePluginForRoute(path, { token: shareToken ?? undefined, workspaceId: activeWorkspaceId ?? null }) if (cancelled) return if (!match) { setError('Not Found') @@ -120,7 +128,7 @@ export default function PluginFallback() { return () => { cancelled = true } - }, [pluginAccessReady, shareToken]) + }, [activeWorkspaceId, pluginAccessReady, shareToken]) React.useEffect(() => { if (!pluginAccessReady) return @@ -232,8 +240,9 @@ export default function PluginFallback() { return (
- -
+
+
+
{(pluginMounting || manifestLoading) && (

Preparing plugin…

diff --git a/app/src/widgets/secondary-viewer/SecondaryViewer.tsx b/app/src/widgets/secondary-viewer/SecondaryViewer.tsx deleted file mode 100644 index 6b09dee8..00000000 --- a/app/src/widgets/secondary-viewer/SecondaryViewer.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client" - -import { X, Loader2 } from 'lucide-react' -import { useEffect, useRef, useState, type MutableRefObject } from 'react' - -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/ui/button' - -import { PreviewPane } from '@/features/edit-document' -import { - matchesMount, - mountResolvedPlugin, - type DocumentPluginMatch, -} from '@/features/plugins' -import { - type SecondaryViewerItemType, - useSecondaryViewerContent, -} from '@/features/secondary-viewer' - -type Props = { - documentId: string | null - documentType?: SecondaryViewerItemType - className?: string - onClose?: () => void - onDocumentChange?: (id: string, type?: SecondaryViewerItemType) => void -} - -export function SecondaryViewer({ - documentId, - documentType = 'document', - className, - onClose, - onDocumentChange, -}: Props) { - const { - content, - error, - currentType, - isInitialLoading, - pluginMatch, - setError, - } = useSecondaryViewerContent(documentId, documentType) - - const pluginContainerRef = useRef(null) - const pluginDisposeRef = useRef void)>(null) - const previousRouteRef = useRef(null) - const [pluginLoading, setPluginLoading] = useState(false) - - useEffect(() => { - let cancelled = false - let shouldRestoreRoute = false - const container = pluginContainerRef.current - - if (!pluginMatch || !documentId) { - if (pluginDisposeRef.current) { - try { - pluginDisposeRef.current() - } catch { - /* noop */ - } - pluginDisposeRef.current = null - } - cleanupPlugin(container) - setPluginLoading(false) - return - } - - if (!container) return - - container.innerHTML = '' - setPluginLoading(true) - setError(null) - - ;(async () => { - shouldRestoreRoute = ensurePluginRoute(pluginMatch, previousRouteRef) - - try { - const dispose = await mountResolvedPlugin(pluginMatch, container, 'secondary') - if (cancelled) { - if (typeof dispose === 'function') { - try { - dispose() - } catch { - /* noop */ - } - } - return - } - pluginDisposeRef.current = dispose - } catch (err: any) { - if (!cancelled) { - console.error('[plugins] secondary viewer mount failed', err) - setError(err?.message || 'Failed to load plugin view') - } - } finally { - if (!cancelled) { - setPluginLoading(false) - } - } - })() - - return () => { - cancelled = true - if (shouldRestoreRoute && previousRouteRef.current != null) { - try { - window.history.replaceState({}, '', previousRouteRef.current) - } catch { - /* noop */ - } - previousRouteRef.current = null - } else if (!shouldRestoreRoute) { - previousRouteRef.current = null - } - - if (pluginDisposeRef.current) { - try { - pluginDisposeRef.current() - } catch { - /* noop */ - } - pluginDisposeRef.current = null - } - - cleanupPlugin(container) - } - }, [documentId, pluginMatch, setError]) - - if (!documentId) return null - - const loading = isInitialLoading || (currentType === 'plugin' && pluginLoading) - - return ( -
- {onClose && ( - - )} -
-
- {error ? ( -
{error}
- ) : currentType === 'plugin' ? ( - <> -
- {loading && ( -
- -

Preparing plugin…

-
- )} - - ) : loading ? ( -
- -
- ) : currentType === 'scrap' ? ( -
Scrap preview is not supported yet.
- ) : ( -
- onDocumentChange?.(id, 'document')} - taskToggleDisabled - /> -
- )} -
-
-
- ) -} - -export default SecondaryViewer - -function cleanupPlugin(container: HTMLDivElement | null) { - if (!container) return - try { - container.innerHTML = '' - } catch { - /* noop */ - } -} - -function ensurePluginRoute( - match: DocumentPluginMatch, - previousRouteRef: MutableRefObject, -) { - if (typeof window === 'undefined') return false - - const mounts = Array.isArray(match.manifest?.mounts) ? match.manifest.mounts : [] - const currentPath = (() => { - try { - return window.location.pathname - } catch { - return null - } - })() - if (!currentPath) return false - - const isOnMount = mounts.some((mount) => matchesMount(mount, currentPath)) - if (!isOnMount) return false - - let target: URL - try { - target = new URL(match.route, window.location.origin) - } catch { - return false - } - - const currentFull = (() => { - try { - return window.location.pathname + window.location.search + window.location.hash - } catch { - return null - } - })() - const targetFull = `${target.pathname}${target.search}${target.hash}` - if (!currentFull || currentFull === targetFull) return false - - if (previousRouteRef.current == null) { - previousRouteRef.current = currentFull - } - - try { - window.history.replaceState({}, '', target.toString()) - return true - } catch { - return false - } -} diff --git a/app/src/widgets/share/ShareFolderPage.tsx b/app/src/widgets/share/ShareFolderPage.tsx index 197c5ee9..de38cad7 100644 --- a/app/src/widgets/share/ShareFolderPage.tsx +++ b/app/src/widgets/share/ShareFolderPage.tsx @@ -24,7 +24,7 @@ export function ShareFolderPage({ token, title, items }: ShareFolderPageProps) { navigate({ to: '/document/$id', params: { id }, - search: (prev: Record) => ({ ...prev, token }), + search: (prev: Record) => ({ ...prev, token, shareScope: 'folder' }), }) } diff --git a/app/src/widgets/sidebar/FileTree.tsx b/app/src/widgets/sidebar/FileTree.tsx index b0e18f3d..313bd25c 100644 --- a/app/src/widgets/sidebar/FileTree.tsx +++ b/app/src/widgets/sidebar/FileTree.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import type { GitPullConflictItem, WorkspaceMembershipResponse } from '@/shared/api' import { useShortcut } from '@/shared/hooks/use-shortcut' +import { dispatchOpenPreviewTile } from '@/shared/lib/mosaic-events' import { overlayMenuClass, overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' @@ -33,7 +34,6 @@ import FileNode from '@/features/file-tree/ui/FileNode' import FolderNode from '@/features/file-tree/ui/FolderNode' import { GitSyncButton } from '@/features/git-sync' import { GIT_CONFLICT_EVENT, readConflicts, readSessionId, setConflicts as setGlobalConflicts, setSessionId, clearSession, clearResolutions } from '@/features/git-sync/lib/git-conflict-store' -import { useSecondaryViewer } from '@/features/secondary-viewer' import { ShareDialog } from '@/features/sharing' import { TEMPORARY_DOCUMENT_TTL_MS, @@ -242,7 +242,6 @@ function WorkspaceSwitcher() { function FileTreeInner() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const router = useRouter() - const { openSecondaryViewer } = useSecondaryViewer() const { documents, archivedDocuments, @@ -657,8 +656,34 @@ function FileTreeInner() { toggleTreeFocus() }) + useShortcut( + 'file-tree.open.tile', + useCallback( + (event) => { + const treeEl = treeFocusRef.current + if (!treeEl) return + if (typeof document === 'undefined') return + const active = document.activeElement as HTMLElement | null + if (active !== treeEl) return + if (!selectedDocId) return + const idx = nodeIndexMap.get(selectedDocId) + if (typeof idx !== 'number') return + const node = visibleNodes[idx]?.node + if (!node || node.type !== 'file') return + + const targetId = node.sourceId ?? node.id + dispatchOpenPreviewTile(targetId) + event.preventDefault() + event.stopPropagation() + }, + [nodeIndexMap, selectedDocId, visibleNodes], + ), + { preventDefault: false }, + ) + const handleTreeKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.currentTarget !== event.target) return + if (event.defaultPrevented) return const normalizedKey = event.key === 'Spacebar' ? ' ' : event.key if (!TREE_NAV_KEYS.has(normalizedKey)) { return @@ -831,12 +856,11 @@ function FileTreeInner() { onDragOver={drag.handleDragOver} onDrop={async (e, id, type) => { await handleDrop(e, id, type, parent) }} pluginRules={fileTreeRules} - onOpenSecondaryViewer={openSecondaryViewer} gitEnabled conflict={conflict} /> ) - }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) const renderNestedNode = useCallback((node: DocumentNode, parentId?: string, depth = 1): React.ReactNode => { const isExpanded = expandedFolders.has(node.id) @@ -891,12 +915,11 @@ function FileTreeInner() { onDragOver={drag.handleDragOver} onDrop={async (e, id, type) => { await handleDrop(e, id, type, parentId) }} pluginRules={fileTreeRules} - onOpenSecondaryViewer={openSecondaryViewer} gitEnabled conflict={conflictForNode(node)} /> ) - }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) return (
diff --git a/app/src/widgets/temporary/TemporaryDocumentPage.tsx b/app/src/widgets/temporary/TemporaryDocumentPage.tsx index 3bbfed7e..c9267dae 100644 --- a/app/src/widgets/temporary/TemporaryDocumentPage.tsx +++ b/app/src/widgets/temporary/TemporaryDocumentPage.tsx @@ -139,7 +139,7 @@ export default function TemporaryDocumentPage({ tempId }: Props) { doc={doc} awareness={awareness} connected={false} - initialView="split" + initialView="editor" documentId={tempId} readOnly={false} /> diff --git a/app/vite.config.ts b/app/vite.config.ts index 40b023e8..0540fb9e 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -7,20 +7,58 @@ import { defineConfig } from 'vite' import { VitePWA } from 'vite-plugin-pwa' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import type { Plugin } from 'vite' +import type { Plugin as EsbuildPlugin } from 'esbuild' const __dirname = dirname(fileURLToPath(import.meta.url)) -export default defineConfig({ +function tanstackStartStorageContextClientStub(): Plugin { + const stubId = resolve(__dirname, './src/shared/lib/stubs/tanstack-start-storage-context.ts') + return { + name: 'refmd:tanstack-start-storage-context-client-stub', + enforce: 'pre', + resolveId(source, _importer, options) { + if (options?.ssr) return null + if (source === '@tanstack/start-storage-context') { + return stubId + } + return null + }, + } +} + +function tanstackStartStorageContextOptimizeDepsStub(): EsbuildPlugin { + const stubId = resolve(__dirname, './src/shared/lib/stubs/tanstack-start-storage-context.ts') + return { + name: 'refmd:tanstack-start-storage-context-optimize-deps-stub', + setup(build) { + build.onResolve({ filter: /^@tanstack\/start-storage-context$/ }, () => ({ + path: stubId, + })) + }, + } +} + +export default defineConfig(() => { + const enablePwaDev = process.env.VITE_PWA_DEV === 'true' + + return { optimizeDeps: { exclude: [ 'nitropack', 'nitropack/runtime', + '@tanstack/start-client-core', + '@tanstack/start-storage-context', '@resvg/resvg-js', '@resvg/resvg-js-linux-x64-gnu', '@resvg/resvg-js-linux-x64-musl', ], + esbuildOptions: { + plugins: [tanstackStartStorageContextOptimizeDepsStub()], + }, }, plugins: [ + tanstackStartStorageContextClientStub(), tanstackStart(), nitroV2Plugin({ preset: 'node-server', @@ -102,7 +140,7 @@ export default defineConfig({ ], }, devOptions: { - enabled: true, + enabled: enablePwaDev, navigateFallback: '/', suppressWarnings: true, }, @@ -112,6 +150,7 @@ export default defineConfig({ alias: { '@': resolve(__dirname, './src'), }, + dedupe: ['react', 'react-dom'], }, build: { rollupOptions: { @@ -136,4 +175,5 @@ export default defineConfig({ }, }, }, + } }) diff --git a/app/vite.wc.config.ts b/app/vite.wc.config.ts index 39b597c3..bec1d518 100644 --- a/app/vite.wc.config.ts +++ b/app/vite.wc.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ alias: { '@': resolve(__dirname, 'src'), '@tanstack/start-client-core': resolve(__dirname, 'src/shared/lib/stubs/tanstack-start-client-core.ts'), + '@tanstack/start-storage-context': resolve(__dirname, 'src/shared/lib/stubs/tanstack-start-storage-context.ts'), }, }, build: {