Compare commits

...
Author SHA1 Message Date
Anthony Stirling b5b0c9c0ec animations test 2025-12-20 18:08:45 +00:00
16 changed files with 1128 additions and 88 deletions
+61 -41
View File
@@ -51,6 +51,7 @@
"@tauri-apps/plugin-http": "^2.5.4",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"framer-motion": "^12.23.26",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
@@ -457,7 +458,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -501,7 +501,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -582,7 +581,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
@@ -682,7 +680,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -699,7 +696,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -717,7 +713,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -771,7 +766,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -806,7 +800,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -843,7 +836,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -919,7 +911,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -1075,7 +1066,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1119,7 +1109,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2150,7 +2139,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2201,7 +2189,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2269,7 +2256,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3202,7 +3188,6 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3321,6 +3306,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4097,7 +4083,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4426,7 +4411,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4437,7 +4421,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4507,7 +4490,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5221,6 +5203,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5230,6 +5213,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5240,6 +5224,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5252,6 +5237,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5278,7 +5264,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5686,6 +5671,7 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5962,7 +5948,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -7010,8 +6995,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7406,7 +7390,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7577,7 +7560,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7744,7 +7726,8 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7809,6 +7792,7 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8272,6 +8256,37 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/framer-motion/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/fs-extra": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz",
@@ -8899,7 +8914,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9376,6 +9390,7 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9696,7 +9711,6 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10283,7 +10297,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -10678,6 +10693,19 @@
"node": ">=18"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11442,7 +11470,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11722,7 +11749,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12105,7 +12131,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12115,7 +12140,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13627,6 +13651,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13835,7 +13860,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14137,7 +14161,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14219,7 +14242,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14424,7 +14446,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14595,7 +14616,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14609,7 +14629,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15221,7 +15240,8 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.76",
+1
View File
@@ -47,6 +47,7 @@
"@tauri-apps/plugin-http": "^2.5.4",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"framer-motion": "^12.23.26",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
@@ -0,0 +1,30 @@
import { useMorphElement } from '@app/hooks/useMorphElement';
interface FileCardMorphProps {
fileId: string;
children: React.ReactNode;
className?: string;
}
/**
* Wrapper for file cards that registers them for morphing animations
*/
export function FileCardMorph({ fileId, children, className }: FileCardMorphProps) {
const morphRef = useMorphElement<HTMLDivElement>(
`file-${fileId}`,
{ fileId, type: 'file' },
{ onlyIn: ['fileEditor'] }
);
return (
<div
ref={morphRef}
className={className}
data-morph-id={`file-${fileId}`}
data-morph-file-id={fileId}
data-morph-type="file"
>
{children}
</div>
);
}
@@ -399,3 +399,20 @@
font-size: 0.875rem;
opacity: 0.8;
}
/* =========================
Grid Animation Styles
========================= */
.gridContainer {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
row-gap: 1.5rem;
padding: 1rem;
pointer-events: auto;
}
.fileCardItem {
/* Target for stagger animations */
will-change: transform, opacity;
}
@@ -5,16 +5,19 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useMorphElement } from '@app/hooks/useMorphElement';
import { zipFileService } from '@app/services/zipFileService';
import { detectFileExtension } from '@app/utils/fileUtils';
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
import AddFileCard from '@app/components/fileEditor/AddFileCard';
import FilePickerModal from '@app/components/shared/FilePickerModal';
import { FileCardMorph } from '@app/components/fileEditor/FileCardMorph';
import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadBlob } from '@app/utils/downloadUtils';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import styles from '@app/components/fileEditor/FileEditor.module.css';
interface FileEditorProps {
@@ -379,42 +382,37 @@ const FileEditor = ({
</Stack>
</Center>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
rowGap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
<div className={styles.gridContainer}>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
<div className={styles.fileCardItem}>
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
</div>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
key={record.id}
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
<FileCardMorph key={record.id} fileId={record.id} className={styles.fileCardItem}>
<FileEditorThumbnail
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
</FileCardMorph>
);
})}
</div>
@@ -1,12 +1,16 @@
import { Box } from '@mantine/core';
import { useState, useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileState } from '@app/contexts/FileContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { isBaseWorkbench } from '@app/types/workbench';
import { isBaseWorkbench, type BaseWorkbenchType } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useWorkbenchMorphTransition } from '@app/hooks/useWorkbenchMorphTransition';
import { useWorkbenchTransition } from '@app/contexts/WorkbenchTransitionContext';
import styles from '@app/components/layout/Workbench.module.css';
import TopControls from '@app/components/shared/TopControls';
@@ -28,6 +32,14 @@ export default function Workbench() {
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
// Track the displayed workbench (for transitions)
const [displayedView, setDisplayedView] = useState(currentView);
const [isTransitioning, setIsTransitioning] = useState(false);
// Workbench morphing transition hook
const { transition } = useWorkbenchMorphTransition();
const { transitionState } = useWorkbenchTransition();
const activeFiles = selectors.getFiles();
const {
previewFile,
@@ -52,6 +64,33 @@ export default function Workbench() {
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
// Trigger morphing transition when workbench changes
useEffect(() => {
if (currentView === displayedView || isTransitioning) return;
const performTransition = async () => {
if (isBaseWorkbench(currentView) && isBaseWorkbench(displayedView)) {
// Both are base workbenches - perform morphing transition
setIsTransitioning(true);
await transition(displayedView as BaseWorkbenchType, currentView as BaseWorkbenchType, () => {
// Swap content after source snapshots are captured
setDisplayedView(currentView);
});
// Transition completes automatically after animation
setTimeout(() => {
setIsTransitioning(false);
}, 550);
} else {
// One is custom view - just swap without morph
setDisplayedView(currentView);
}
};
performTransition();
}, [currentView, displayedView, isTransitioning, transition]);
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem('previousMode');
@@ -71,12 +110,15 @@ export default function Workbench() {
};
const renderMainContent = () => {
// Use displayedView for rendering (sync with transition)
const viewToRender = displayedView;
// Check for custom workbench views first
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (!isBaseWorkbench(viewToRender)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === viewToRender && view.data != null);
if (customView) {
// PDF text editor handles its own empty state (shows dropzone when no document)
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
const handlesOwnEmptyState = viewToRender === 'custom:pdfTextEditor';
if (handlesOwnEmptyState || activeFiles.length > 0) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@@ -92,7 +134,7 @@ export default function Workbench() {
);
}
switch (currentView) {
switch (viewToRender) {
case "fileEditor":
return (
@@ -189,15 +231,20 @@ export default function Workbench() {
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<Box
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
}}
>
{renderMainContent()}
</Box>
{/* Main content area with workbench transitions */}
<AnimatePresence mode="wait">
<motion.div
key={displayedView}
initial={{ opacity: 0 }}
animate={{ opacity: transitionState.isTransitioning ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
style={{ display: 'flex', flexDirection: 'column' }}
>
{renderMainContent()}
</motion.div>
</AnimatePresence>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
@@ -469,6 +469,7 @@ const PageEditor = ({
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
fileId={page.originalFileId || displayDocument.id}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
@@ -1,6 +1,7 @@
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { useMorphElement } from '@app/hooks/useMorphElement';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
@@ -23,6 +24,7 @@ interface PageThumbnailProps {
index: number;
totalPages: number;
originalFile?: File;
fileId: string;
fileColorIndex: number;
selectedPageIds: string[];
selectionMode: boolean;
@@ -56,6 +58,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
index: _index,
totalPages,
originalFile,
fileId,
fileColorIndex,
selectedPageIds,
selectionMode,
@@ -82,6 +85,24 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}: PageThumbnailProps) => {
const pageIndex = page.pageNumber - 1;
// Register for morphing - ALL pages register for split/merge animations
const morphId = page.originalFileId
? `page-${page.originalFileId}-${page.pageNumber}`
: `page-${page.id}`;
const morphRef = useMorphElement<HTMLDivElement>(
morphId,
{
pageId: page.id,
fileId: fileId || page.originalFileId, // Used for grouping in morph pairs
pageNumber: page.pageNumber,
type: 'page',
},
{
onlyIn: ['pageEditor'],
}
);
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const [isHovered, setIsHovered] = useState(false);
@@ -162,11 +183,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pageRefs.current.delete(page.id);
}
// Assign to morph ref for animation system
if (morphRef) {
(morphRef as React.MutableRefObject<HTMLDivElement | null>).current = element;
}
// Call dnd-kit's ref if provided
if (dragHandleProps?.ref) {
dragHandleProps.ref(element);
}
}, [page.id, pageRefs, dragHandleProps]);
}, [page.id, pageRefs, dragHandleProps, morphRef]);
// DOM command handlers
@@ -352,6 +378,10 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
{...restDragProps}
data-page-id={page.id}
data-page-number={page.pageNumber}
data-morph-id={morphId}
data-morph-file-id={fileId || page.originalFileId}
data-morph-page-number={page.pageNumber}
data-morph-type="page"
className={`
${styles.pageContainer}
!rounded-lg
@@ -0,0 +1,196 @@
import { motion } from 'framer-motion';
import { useWorkbenchTransition } from '@app/contexts/WorkbenchTransitionContext';
import { useEffect, useMemo } from 'react';
/**
* MorphOverlay renders animated clones of elements during workbench transitions
*
* Uses Framer Motion to smoothly morph elements from source to target positions,
* creating the illusion that pages/files are transforming between view modes.
*/
export function MorphOverlay() {
const { transitionState, getMorphPairs, completeTransition } = useWorkbenchTransition();
const morphPairs = getMorphPairs();
// Debug logging
useEffect(() => {
if (transitionState.isTransitioning) {
console.log('[MorphOverlay] Transition state:', {
from: transitionState.fromWorkbench,
to: transitionState.toWorkbench,
sources: transitionState.sourceSnapshots.length,
targets: transitionState.targetSnapshots.length,
pairs: morphPairs.length,
});
if (morphPairs.length > 0) {
const pairsBySource = new Map<string, number>();
morphPairs.forEach(({ source }) => {
pairsBySource.set(source.id, (pairsBySource.get(source.id) || 0) + 1);
});
const hasSplit = Array.from(pairsBySource.values()).some(count => count > 1);
const type = hasSplit ? 'SPLIT' : 'MERGE';
console.log(`[MorphOverlay] ${type} animation with ${morphPairs.length} pairs`);
// Log first pair details
const first = morphPairs[0];
console.log('[MorphOverlay] First pair:', {
source: first.source.id,
target: first.target.id,
sourcePos: { x: Math.round(first.source.rect.left), y: Math.round(first.source.rect.top) },
targetPos: { x: Math.round(first.target.rect.left), y: Math.round(first.target.rect.top) },
});
} else {
console.log('[MorphOverlay] No morph pairs created!');
}
}
}, [transitionState, morphPairs]);
useEffect(() => {
if (!transitionState.isTransitioning) return;
// Auto-complete transition after animation duration.
// Give extra time when pairs are not ready so targets can register.
const timeout = setTimeout(() => {
completeTransition();
}, morphPairs.length > 0 ? 1400 : 3500); // allow time for targets to register
return () => clearTimeout(timeout);
}, [transitionState.isTransitioning, morphPairs.length, completeTransition]);
const isSplit = morphPairs.some(({ source }) => {
const sourceFile = source.metadata?.fileId;
const counts = morphPairs.filter(p => p.source.metadata?.fileId === sourceFile);
return counts.length > 1;
});
// For split animations, we want all outgoing clones to originate near the first page spot
const firstTargetRectByFile = useMemo(() => {
const map = new Map<string, DOMRect>();
morphPairs.forEach(({ source, target }) => {
const fileId = source.metadata?.fileId || target.metadata?.fileId;
if (fileId && !map.has(fileId)) {
map.set(fileId, target.rect);
}
});
return map;
}, [morphPairs]);
const preferSplitTargetOrigin = transitionState.toWorkbench === 'pageEditor';
if (!transitionState.isTransitioning || morphPairs.length === 0) {
return null;
}
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10000,
overflow: 'hidden',
}}
>
{morphPairs.map(({ source, target }, index) => {
const sourceRect = source.rect;
const targetRect = target.rect;
const fileId = source.metadata?.fileId || target.metadata?.fileId;
// If splitting (file -> pages), start all clones from the first target rect for that file
const initialRect =
source.metadata?.type === 'file' &&
isSplit &&
preferSplitTargetOrigin &&
fileId &&
firstTargetRectByFile.get(fileId)
? firstTargetRectByFile.get(fileId)!
: sourceRect;
// Use combination of source and target IDs for unique keys (supports split/merge)
const key = `${source.id}-to-${target.id}`;
// Use page thumbnail when flying pages -> file, and target when file -> pages
const thumbnail =
source.metadata?.type === 'page'
? source.thumbnail || target.thumbnail
: target.thumbnail || source.thumbnail;
return (
<motion.div
key={key}
initial={{
position: 'absolute',
left: initialRect.left,
top: initialRect.top,
width: initialRect.width,
height: initialRect.height,
borderRadius: '4px',
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
opacity: 0.9,
scale: 0.9,
}}
animate={{
left: targetRect.left,
top: targetRect.top,
width: targetRect.width,
height: targetRect.height,
opacity: 1,
scale: 1,
}}
transition={{
type: 'spring',
stiffness: 220,
damping: 26,
mass: 1.05,
bounce: 0.3,
restDelta: 0.1,
restSpeed: 0.1,
delay: isSplit ? index * 0.05 : 0,
}}
style={{ willChange: 'transform, width, height, opacity' }}
>
{/* Render thumbnail if available */}
{thumbnail && (
<motion.img
src={thumbnail}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
backgroundColor: 'white',
}}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
{/* Fallback: render a placeholder */}
{!source.thumbnail && (
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--file-card-bg, #f5f5f5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: '24px', opacity: 0.5 }}>📄</span>
</div>
)}
</motion.div>
);
})}
</div>
);
}
@@ -17,6 +17,8 @@ import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
import { ViewerMorphAnchor } from '@app/components/viewer/ViewerMorphAnchor';
import { ViewerPageMorphs } from '@app/components/viewer/ViewerPageMorphs';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@@ -134,6 +136,13 @@ const EmbedPdfViewerContent = ({
return null;
}, [previewFile, activeFiles, activeFileIndex]);
const currentFileId = React.useMemo(() => {
if (currentFile && isStirlingFile(currentFile)) {
return currentFile.fileId;
}
return undefined;
}, [currentFile]);
// Get file with URL for rendering
const fileWithUrl = useFileWithUrl(currentFile);
@@ -150,6 +159,13 @@ const EmbedPdfViewerContent = ({
}
}, [previewFile, fileWithUrl]);
const fileForThumbnails = React.useMemo(() => {
if (effectiveFile?.file instanceof File) {
return effectiveFile.file;
}
return null;
}, [effectiveFile]);
const bookmarkCacheKey = React.useMemo(() => {
if (currentFile && isStirlingFile(currentFile)) {
return currentFile.fileId;
@@ -335,6 +351,8 @@ const EmbedPdfViewerContent = ({
overflow: 'hidden',
contain: 'layout style paint'
}}>
<ViewerMorphAnchor fileId={currentFileId} />
<ViewerPageMorphs fileId={currentFileId} file={fileForThumbnails} />
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
<ActionIcon
@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import { useMorphElement } from '@app/hooks/useMorphElement';
interface ViewerMorphAnchorProps {
fileId?: string;
}
/**
* Invisible anchor that registers the current viewer file for morph animations.
* Gives the transition system a source/target element when coming from the viewer.
*/
export function ViewerMorphAnchor({ fileId }: ViewerMorphAnchorProps) {
const morphId = fileId ? `file-${fileId}` : undefined;
const metadata = useMemo(() => {
if (!fileId) return undefined;
return { fileId, type: 'file' };
}, [fileId]);
const ref = useMorphElement<HTMLDivElement>(
morphId ?? 'viewer-file-anchor',
metadata,
{ onlyIn: ['viewer'], disabled: !fileId }
);
if (!fileId) return null;
return (
<div
ref={ref}
data-morph-id={morphId}
data-morph-file-id={fileId}
data-morph-type="file"
style={{
position: 'fixed',
left: '50%',
top: '45%',
width: '180px',
height: '220px',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
opacity: 0,
borderRadius: '6px',
background: 'transparent',
zIndex: 5,
}}
/>
);
}
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMorphElement } from '@app/hooks/useMorphElement';
import { useViewer } from '@app/contexts/ViewerContext';
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
interface ViewerPageMorphsProps {
fileId?: string;
file?: File | null;
}
interface PageMorphProps {
fileId: string;
pageNumber: number;
indexOffset: number;
thumbnail?: string;
}
function ViewerPageMorph({ fileId, pageNumber, indexOffset, thumbnail }: PageMorphProps) {
const morphId = `page-${fileId}-${pageNumber}`;
const ref = useMorphElement<HTMLDivElement>(
morphId,
{ fileId, type: 'page', pageNumber, thumbnail },
{ onlyIn: ['viewer'] }
);
return (
<div
key={morphId}
ref={ref}
data-morph-id={morphId}
data-morph-file-id={fileId}
data-morph-page-number={pageNumber}
data-morph-type="page"
style={{
position: 'fixed',
left: '50%',
top: `${40 + indexOffset * 26}%`,
width: '140px',
height: '180px',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
opacity: 0,
borderRadius: '4px',
background: 'transparent',
zIndex: 5,
}}
/>
);
}
/**
* Registers the currently visible pages in the viewer for morph animations.
* Uses limited slots to avoid excessive clones on long documents.
*/
export function ViewerPageMorphs({ fileId, file }: ViewerPageMorphsProps) {
const { getScrollState, getThumbnailAPI } = useViewer();
const scrollState = getScrollState();
const thumbnailAPI = getThumbnailAPI();
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const [thumbnailsByPage, setThumbnailsByPage] = useState<Record<number, string>>({});
const pendingRequestsRef = useRef(new Map<number, Promise<string | null>>());
const objectUrlsRef = useRef(new Map<number, string>());
// Derive a small window of visible pages around the current page
const visiblePages = useMemo(() => {
const { currentPage, totalPages } = scrollState;
if (!currentPage || totalPages === 0) return [];
const pages: number[] = [];
for (let i = currentPage - 1; i <= currentPage + 2 && i <= totalPages; i++) {
if (i >= 1) pages.push(i);
}
return pages;
}, [scrollState.currentPage, scrollState.totalPages]);
useEffect(() => {
return () => {
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
objectUrlsRef.current.clear();
pendingRequestsRef.current.clear();
setThumbnailsByPage({});
};
}, [fileId]);
useEffect(() => {
if (!fileId || visiblePages.length === 0) return;
visiblePages.forEach((pageNumber) => {
if (thumbnailsByPage[pageNumber] || pendingRequestsRef.current.has(pageNumber)) return;
const cacheKey = `${fileId}-page-${pageNumber}`;
const cached = getThumbnailFromCache(cacheKey);
if (cached) {
setThumbnailsByPage((prev) => ({ ...prev, [pageNumber]: cached }));
return;
}
let requestPromise: Promise<string | null> | null = null;
if (thumbnailAPI?.renderThumb) {
requestPromise = thumbnailAPI
.renderThumb(pageNumber - 1, 0.18)
.toPromise()
.then((blob) => {
if (!blob) return null;
const url = URL.createObjectURL(blob);
objectUrlsRef.current.set(pageNumber, url);
return url;
})
.catch(() => null);
}
if (!requestPromise && file instanceof File) {
requestPromise = requestThumbnail(cacheKey, file, pageNumber).catch(() => null);
}
if (!requestPromise) return;
pendingRequestsRef.current.set(pageNumber, requestPromise);
requestPromise.then((thumbnail) => {
pendingRequestsRef.current.delete(pageNumber);
if (!thumbnail) return;
setThumbnailsByPage((prev) => ({ ...prev, [pageNumber]: thumbnail }));
});
});
}, [
file,
fileId,
getThumbnailFromCache,
requestThumbnail,
thumbnailAPI,
thumbnailsByPage,
visiblePages,
]);
if (!fileId) return null;
return (
<>
{visiblePages.map((pageNumber, idx) => {
const thumbSrc = thumbnailsByPage[pageNumber];
return (
<ViewerPageMorph
key={`morph-${fileId}-${pageNumber}`}
fileId={fileId}
pageNumber={pageNumber}
indexOffset={idx}
thumbnail={thumbSrc}
/>
);
})}
</>
);
}
@@ -0,0 +1,344 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import type { BaseWorkbenchType } from '../types/workbench';
/**
* Element snapshot for morphing animations
*/
export interface ElementSnapshot {
id: string;
rect: DOMRect;
element: HTMLElement;
thumbnail?: string;
metadata?: Record<string, any>;
}
/**
* Transition state tracking
*/
interface TransitionState {
isTransitioning: boolean;
fromWorkbench: BaseWorkbenchType | null;
toWorkbench: BaseWorkbenchType | null;
sourceSnapshots: ElementSnapshot[];
targetSnapshots: ElementSnapshot[];
}
interface WorkbenchTransitionContextValue {
// Transition state
transitionState: TransitionState;
// Register elements for morphing
registerSourceElement: (id: string, element: HTMLElement, metadata?: Record<string, any>) => void;
registerTargetElement: (id: string, element: HTMLElement, metadata?: Record<string, any>) => void;
// Initiate transition
startTransition: (from: BaseWorkbenchType, to: BaseWorkbenchType, onViewChange?: () => void) => Promise<void>;
completeTransition: () => void;
// Get morph pairs
getMorphPairs: () => Array<{ source: ElementSnapshot; target: ElementSnapshot }>;
}
const WorkbenchTransitionContext = createContext<WorkbenchTransitionContextValue | null>(null);
export function useWorkbenchTransition() {
const context = useContext(WorkbenchTransitionContext);
if (!context) {
throw new Error('useWorkbenchTransition must be used within WorkbenchTransitionProvider');
}
return context;
}
interface WorkbenchTransitionProviderProps {
children: React.ReactNode;
}
const MAX_MORPH_ELEMENTS = 40; // cap clones to what can reasonably be on screen
export function WorkbenchTransitionProvider({ children }: WorkbenchTransitionProviderProps) {
const [transitionState, setTransitionState] = useState<TransitionState>({
isTransitioning: false,
fromWorkbench: null,
toWorkbench: null,
sourceSnapshots: [],
targetSnapshots: [],
});
// Use refs to store element registrations
const sourceElementsRef = useRef<Map<string, { element: HTMLElement; metadata?: Record<string, any> }>>(new Map());
const targetElementsRef = useRef<Map<string, { element: HTMLElement; metadata?: Record<string, any> }>>(new Map());
const registerSourceElement = useCallback((id: string, element: HTMLElement, metadata?: Record<string, any>) => {
sourceElementsRef.current.set(id, { element, metadata });
if (process.env.NODE_ENV === 'development') {
console.log('[WorkbenchTransition] Source registered', { id, fileId: metadata?.fileId, type: metadata?.type });
}
}, []);
const registerTargetElement = useCallback((id: string, element: HTMLElement, metadata?: Record<string, any>) => {
targetElementsRef.current.set(id, { element, metadata });
if (process.env.NODE_ENV === 'development') {
console.log('[WorkbenchTransition] Target registered', { id, fileId: metadata?.fileId, type: metadata?.type, page: metadata?.pageNumber });
}
}, []);
const captureSnapshot = useCallback((id: string, element: HTMLElement, metadata?: Record<string, any>): ElementSnapshot => {
const rect = element.getBoundingClientRect();
// Try to capture thumbnail if it's an image or provided metadata
let thumbnail: string | undefined;
const img = element.querySelector('img');
if (img && img.src) {
thumbnail = img.src;
} else if (metadata?.thumbnail) {
thumbnail = metadata.thumbnail;
}
return {
id,
rect,
element,
thumbnail,
metadata,
};
}, []);
const startTransition = useCallback(async (from: BaseWorkbenchType, to: BaseWorkbenchType, onViewChange?: () => void) => {
const waitForNextPaint = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
const hasTargetsOfType = (type: string) => {
for (const [, data] of targetElementsRef.current) {
if (data.metadata?.type === type) return true;
}
return false;
};
// Capture source snapshots
const sourceSnapshots: ElementSnapshot[] = [];
sourceElementsRef.current.forEach((data, id) => {
if (sourceSnapshots.length >= MAX_MORPH_ELEMENTS) return;
if (data.element.offsetParent !== null) { // Only capture visible elements
sourceSnapshots.push(captureSnapshot(id, data.element, data.metadata));
}
});
if (process.env.NODE_ENV === 'development') {
console.log('[WorkbenchTransition] Captured sources', sourceSnapshots.length);
}
setTransitionState({
isTransitioning: true,
fromWorkbench: from,
toWorkbench: to,
sourceSnapshots,
targetSnapshots: [],
});
// Call the view change callback to mount the new view
onViewChange?.();
// Give the new view a tick to mount before we look for targets
await waitForNextPaint();
// Helper to scan DOM as a fallback in case targets haven't registered yet
const scanForTargets = () => {
let added = 0;
const elements = document.querySelectorAll('[data-morph-id]');
elements.forEach((el) => {
const htmlEl = el as HTMLElement;
const morphId = htmlEl.dataset.morphId;
const fileId = htmlEl.dataset.morphFileId;
const pageNumber = htmlEl.dataset.morphPageNumber;
const type = htmlEl.dataset.morphType;
if (morphId && fileId) {
const metadata: Record<string, any> = { fileId, type };
if (pageNumber) {
metadata.pageNumber = parseInt(pageNumber);
}
// Preserve any explicit registration, otherwise add from scan
if (!targetElementsRef.current.has(morphId)) {
targetElementsRef.current.set(morphId, { element: htmlEl, metadata });
added++;
}
}
});
if (process.env.NODE_ENV === 'development') {
console.log('[WorkbenchTransition] DOM scan found', elements.length, 'morphables; added', added);
}
};
// Wait for target registrations coming from useMorphElement (and fall back to DOM scan)
const startTime = performance.now();
const maxWaitMs = 1200; // shorter wait so animation can start quicker
while (performance.now() - startTime < maxWaitMs) {
if (targetElementsRef.current.size > 0) break;
scanForTargets();
await waitForNextPaint();
}
// If we are transitioning to pageEditor, prefer to wait for page targets
if (to === 'pageEditor') {
const typeWaitStart = performance.now();
const typeWaitMs = 400; // brief grace period to pick up page targets
while (!hasTargetsOfType('page') && performance.now() - typeWaitStart < typeWaitMs) {
scanForTargets();
await waitForNextPaint();
}
}
// If we are transitioning to fileEditor, prefer to wait for file targets
if (to === 'fileEditor') {
const typeWaitStart = performance.now();
const typeWaitMs = 400; // brief grace period to pick up file targets
while (!hasTargetsOfType('file') && performance.now() - typeWaitStart < typeWaitMs) {
scanForTargets();
await waitForNextPaint();
}
}
// If we are transitioning to viewer, also wait briefly for file targets (viewer anchor)
if (to === 'viewer') {
const typeWaitStart = performance.now();
const typeWaitMs = 400;
while (!hasTargetsOfType('file') && performance.now() - typeWaitStart < typeWaitMs) {
scanForTargets();
await waitForNextPaint();
}
}
// One final scan after wait to pick up any late mounts
scanForTargets();
if (process.env.NODE_ENV === 'development') {
console.log('[WorkbenchTransition] Targets registered after wait:', targetElementsRef.current.size);
}
// Give targets one more paint so layout stabilizes before measuring
await waitForNextPaint();
// Allow layout to settle so we capture accurate positions
await waitForNextPaint();
// Capture target snapshots
console.log('[WorkbenchTransition] Capturing targets. Found:', targetElementsRef.current.size);
const targetSnapshots: ElementSnapshot[] = [];
targetElementsRef.current.forEach((data, id) => {
if (targetSnapshots.length >= MAX_MORPH_ELEMENTS) return;
if (data.element.offsetParent !== null) {
targetSnapshots.push(captureSnapshot(id, data.element, data.metadata));
}
});
// If nothing was visible, fall back to first few elements regardless of viewport
if (targetSnapshots.length === 0 && targetElementsRef.current.size > 0) {
let count = 0;
targetElementsRef.current.forEach((data, id) => {
if (count >= Math.min(MAX_MORPH_ELEMENTS, 12)) return;
const snap = captureSnapshot(id, data.element, data.metadata);
targetSnapshots.push(snap);
count++;
});
}
console.log('[WorkbenchTransition] Captured', targetSnapshots.length, 'visible targets');
setTransitionState(prev => ({
...prev,
targetSnapshots,
}));
}, [captureSnapshot]);
const completeTransition = useCallback(() => {
setTransitionState({
isTransitioning: false,
fromWorkbench: null,
toWorkbench: null,
sourceSnapshots: [],
targetSnapshots: [],
});
// Clear registrations
sourceElementsRef.current.clear();
targetElementsRef.current.clear();
}, []);
const getMorphPairs = useCallback(() => {
const pairs: Array<{ source: ElementSnapshot; target: ElementSnapshot }> = [];
// Group snapshots by fileId for split/merge animations
const sourcesByFile = new Map<string, ElementSnapshot[]>();
const targetsByFile = new Map<string, ElementSnapshot[]>();
// Group sources by fileId
transitionState.sourceSnapshots.forEach(source => {
const fileId = source.metadata?.fileId;
if (fileId) {
if (!sourcesByFile.has(fileId)) {
sourcesByFile.set(fileId, []);
}
sourcesByFile.get(fileId)!.push(source);
}
});
// Group targets by fileId
transitionState.targetSnapshots.forEach(target => {
const fileId = target.metadata?.fileId;
if (fileId) {
if (!targetsByFile.has(fileId)) {
targetsByFile.set(fileId, []);
}
targetsByFile.get(fileId)!.push(target);
}
});
// Create morph pairs for each file group
const allFileIds = new Set([...sourcesByFile.keys(), ...targetsByFile.keys()]);
allFileIds.forEach(fileId => {
const sources = sourcesByFile.get(fileId) || [];
const targets = targetsByFile.get(fileId) || [];
if (sources.length === 0 || targets.length === 0) return;
// Sort targets by page number for consistent animation
targets.sort((a, b) => (a.metadata?.pageNumber || 0) - (b.metadata?.pageNumber || 0));
if (sources.length === 1 && targets.length > 1) {
// SPLIT: 1 file card → N page thumbnails
// All targets morph from the same source (file card)
const source = sources[0];
targets.forEach(target => {
pairs.push({ source, target });
});
} else if (sources.length > 1 && targets.length === 1) {
// MERGE: N page thumbnails → 1 file card
// All sources morph to the same target (file card)
const target = targets[0];
sources.forEach(source => {
pairs.push({ source, target });
});
} else if (sources.length === targets.length) {
// 1:1 mapping (e.g., page editor to page editor, or same view)
sources.sort((a, b) => (a.metadata?.pageNumber || 0) - (b.metadata?.pageNumber || 0));
sources.forEach((source, i) => {
pairs.push({ source, target: targets[i] });
});
}
});
return pairs;
}, [transitionState.sourceSnapshots, transitionState.targetSnapshots]);
const value: WorkbenchTransitionContextValue = {
transitionState,
registerSourceElement,
registerTargetElement,
startTransition,
completeTransition,
getMorphPairs,
};
return (
<WorkbenchTransitionContext.Provider value={value}>
{children}
</WorkbenchTransitionContext.Provider>
);
}
@@ -0,0 +1,99 @@
import { useEffect, useRef, useMemo } from 'react';
import { useWorkbenchTransition } from '@app/contexts/WorkbenchTransitionContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { isBaseWorkbench } from '@app/types/workbench';
/**
* Hook to register an element for morphing animations during workbench transitions
*
* @param morphId - Unique identifier for this element (should be consistent across views)
* @param metadata - Optional metadata (file ID, page number, etc.)
* @param options - Configuration options
* @returns Ref to attach to the element that should morph
*
* @example
* ```tsx
* // In FileEditor
* const ref = useMorphElement(`file-${fileId}`, { fileId, type: 'file' });
* return <div ref={ref}>File Card</div>;
*
* // In PageEditor (same fileId)
* const ref = useMorphElement(`file-${fileId}`, { fileId, type: 'page' });
* return <div ref={ref}>Page Thumbnail</div>;
* ```
*/
export function useMorphElement<T extends HTMLElement = HTMLDivElement>(
morphId: string,
metadata?: Record<string, any>,
options?: {
/**
* Only register in specific workbench types
*/
onlyIn?: ('viewer' | 'pageEditor' | 'fileEditor')[];
/**
* Disable morphing for this element
*/
disabled?: boolean;
}
) {
const elementRef = useRef<T>(null);
const { registerSourceElement, registerTargetElement, transitionState } = useWorkbenchTransition();
const { workbench } = useNavigationState();
const currentWorkbench = isBaseWorkbench(workbench) ? workbench : null;
// Stabilize metadata object to prevent unnecessary re-registrations
const stableMetadata = useMemo(() => metadata, [JSON.stringify(metadata)]);
useEffect(() => {
if (!elementRef.current || !currentWorkbench || options?.disabled) return;
// Check if we should register in this workbench
if (options?.onlyIn && !options.onlyIn.includes(currentWorkbench)) {
return;
}
const element = elementRef.current;
// ALWAYS register as source when mounted in current workbench
// This ensures elements are available as sources before transition starts
registerSourceElement(morphId, element, stableMetadata);
// Cleanup is handled by the context when transition completes
}, [
morphId,
currentWorkbench,
registerSourceElement,
stableMetadata,
options?.disabled,
options?.onlyIn,
]);
// Separate effect for target registration during transitions
useEffect(() => {
if (!elementRef.current || !currentWorkbench || options?.disabled) return;
// Check if we should register in this workbench
if (options?.onlyIn && !options.onlyIn.includes(currentWorkbench)) {
return;
}
// Only register as target if we're transitioning TO this workbench
if (transitionState.isTransitioning && transitionState.toWorkbench === currentWorkbench) {
const element = elementRef.current;
registerTargetElement(morphId, element, stableMetadata);
}
}, [
morphId,
currentWorkbench,
transitionState.isTransitioning,
transitionState.toWorkbench,
registerTargetElement,
stableMetadata,
options?.disabled,
options?.onlyIn,
]);
return elementRef;
}
@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { useWorkbenchTransition as useMorphContext } from '@app/contexts/WorkbenchTransitionContext';
import type { BaseWorkbenchType } from '../types/workbench';
/**
* Hook for initiating morphing transitions between workbenches
*
* Coordinates the animation sequence:
* 1. Register source elements (current view)
* 2. Start transition (capture snapshots)
* 3. Swap view content
* 4. Register target elements (new view)
* 5. Morph animation plays
* 6. Complete transition
*/
export function useWorkbenchMorphTransition() {
const { startTransition } = useMorphContext();
const transition = useCallback(
async (from: BaseWorkbenchType, to: BaseWorkbenchType, onViewChange?: () => void) => {
// Start transition and pass view change callback
// The callback will be called after source snapshots are captured
// and before target snapshots are captured
await startTransition(from, to, onViewChange);
},
[startTransition]
);
return { transition };
}
+7 -2
View File
@@ -22,6 +22,8 @@ import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import { WorkbenchTransitionProvider } from "@app/contexts/WorkbenchTransitionContext";
import { MorphOverlay } from "@app/components/shared/MorphOverlay";
import "@app/pages/HomePage.css";
@@ -175,7 +177,9 @@ export default function HomePage() {
// Note: File selection limits are now handled directly by individual tools
return (
<div className="h-screen overflow-hidden">
<WorkbenchTransitionProvider>
<MorphOverlay />
<div className="h-screen overflow-hidden">
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
@@ -288,6 +292,7 @@ export default function HomePage() {
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
)}
</div>
</div>
</WorkbenchTransitionProvider>
);
}