diff --git a/web/bun.lock b/web/bun.lock index 79bbc8791..e4611ce89 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -31,6 +31,7 @@ "dayjs": "^1.11.21", "fflate": "^0.8.3", "file-saver": "^2.0.5", + "leafer-ui": "^2.2.7", "localforage": "^1.10.0", "lucide-react": "^1.25.0", "mammoth": "^1.12.0", @@ -206,6 +207,102 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@leafer-in/interface": ["@leafer-in/interface@2.2.7", "", { "peerDependencies": { "@leafer-ui/interface": "^2.2.7", "@leafer/interface": "^2.2.7" } }, "sha512-TmvIE3kNBsS2KIiNgul2VgnhArwLLxAs4pPcMVQ4ABqByMCr9sWEW8u5LEos2Mc6ykK/9Ty+M/M1DVHAFaGwkg=="], + + "@leafer-ui/app": ["@leafer-ui/app@2.2.7", "", { "dependencies": { "@leafer-ui/data": "2.2.7", "@leafer-ui/display": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-ZTQlwOdZgmyuZnKcljfdtVKkggHE6BYwhUn3RzRks3Qfnwe9MiMoAqaqSWrtbPKdzfKoXvqznEuW7Pm58q9Jgw=="], + + "@leafer-ui/bounds": ["@leafer-ui/bounds@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-1VKCKJ2GQXdNaU4baPOp9pGKB3L3rC+lCj82+Tg3STryu3mLKFX7qe+t+8k0fYBbKB5GoZ6tuS26l21AjjFHrA=="], + + "@leafer-ui/color": ["@leafer-ui/color@2.2.7", "", { "dependencies": { "@leafer-ui/draw": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-bmKnXubR/yVuXtMwgAZPxfiZwekuNWlt/uqs+F15w3Q9sBYaSMBYknHRYHK5ypDIGNfnh2mhu2P/6lMw2RtF9Q=="], + + "@leafer-ui/core": ["@leafer-ui/core@2.2.7", "", { "dependencies": { "@leafer-ui/app": "2.2.7", "@leafer-ui/draw": "2.2.7", "@leafer-ui/event": "2.2.7", "@leafer-ui/hit": "2.2.7", "@leafer-ui/interaction": "2.2.7" } }, "sha512-aZ1r0M+lWtbbeqZbxhBT0sSQMpVG/P9sPD3QwlpZ10v1PuWXvo9F7khsuSqU6vjcWOEo9BmjhAohTBSI3Gqdpw=="], + + "@leafer-ui/data": ["@leafer-ui/data@2.2.7", "", { "dependencies": { "@leafer-ui/external": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-P2LVoKIYtQhj6S+lGnM+q7yqgwyOErrFc7gOHGeJHiSJW0eH4WD/W67CutPFmBhXRHrGS1lya2xz9xqUHwzBPA=="], + + "@leafer-ui/decorator": ["@leafer-ui/decorator@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-CSStGc3KcmFq6JmBLnPyx9rBG++rEu6T0khKmrCUg94gDDg/C4oQcuMQOFePJNJkY8hhXvfq/EJL0xAouDCg6Q=="], + + "@leafer-ui/display": ["@leafer-ui/display@2.2.7", "", { "dependencies": { "@leafer-ui/data": "2.2.7", "@leafer-ui/decorator": "2.2.7", "@leafer-ui/display-module": "2.2.7", "@leafer-ui/external": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-9rxvMjj55DbPd4GOJqYTwPzPSVf+FyAUkCIuL8v3g3MQJTPTENLKv4eRJ8NI3ehf6jQdfVDrEbd8pFejR//h/w=="], + + "@leafer-ui/display-module": ["@leafer-ui/display-module@2.2.7", "", { "dependencies": { "@leafer-ui/bounds": "2.2.7", "@leafer-ui/data": "2.2.7", "@leafer-ui/render": "2.2.7" } }, "sha512-llkL9So2CszgO+i3B+nQk0qp1s1yF++PjibimChT5Y/SXUXgDVLgxvZ+7lAn6USse/t3qMKEYSXDW7RU+nVoxg=="], + + "@leafer-ui/draw": ["@leafer-ui/draw@2.2.7", "", { "dependencies": { "@leafer-ui/decorator": "2.2.7", "@leafer-ui/display": "2.2.7", "@leafer-ui/display-module": "2.2.7", "@leafer-ui/external": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-1smoh0dLLS17RIqD88xJeyDZoo4eB1SAE9BkPuaxjhbDJHMXuFoob9Oem4GCpfCHFCWzJhyLlrblwAnDeZSZLw=="], + + "@leafer-ui/effect": ["@leafer-ui/effect@2.2.7", "", { "dependencies": { "@leafer-ui/draw": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-x6FZVbNYjD3/buG5nM6Zb4vSil83ZVAatpz5skBRUoR7eF+JhiETmMMEY6mkHaJENpWSnWI2RNdukaK26E0IDw=="], + + "@leafer-ui/event": ["@leafer-ui/event@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-DtfPmYILi2FayhxcYJ+XQ2AMQ2KfNjb5gNtrJFnmgE/gjHLTM7nRXdfhHDH1kF3fCHgT3Q3Trxnz4ZULyIvlpA=="], + + "@leafer-ui/external": ["@leafer-ui/external@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-uKROw1Rn1Z88Q28Ikz0lwnFchO+oydcOwlD4JV9qExBBVv6ZORGxfUP/QHDjp2hsQ+tet7VupnfPMmRsnb6Ojw=="], + + "@leafer-ui/hit": ["@leafer-ui/hit@2.2.7", "", { "dependencies": { "@leafer-ui/draw": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-ha04FMjkS8wuH9XFHUvUBfEZwRwq7GBHoTfxr552hYefAgW4/4F1Jyb0XArqoMbrytrPot/aMXSlVsFBaNUpzA=="], + + "@leafer-ui/interaction": ["@leafer-ui/interaction@2.2.7", "", { "dependencies": { "@leafer-ui/draw": "2.2.7", "@leafer-ui/event": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-yxIP+VvLCSG6S6ar+2LmF03la4FC1ajXf9e0ai9Aj7qONoIZ/9PhxFXggkBR8OUAQFsdXRyg86m0IOQ7c5VyVA=="], + + "@leafer-ui/interaction-web": ["@leafer-ui/interaction-web@2.2.7", "", { "dependencies": { "@leafer-ui/core": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-9L8VaJZUMvtK4mHKVYyHiYVqaHqCnKMsoV3JKWLGdfGPaTNH7Qd/uQnvzZyudGz8ACDCZ44HwCqLjwMuj0EC8A=="], + + "@leafer-ui/interface": ["@leafer-ui/interface@2.2.7", "", { "dependencies": { "@leafer/interface": "2.2.7" } }, "sha512-KF6ycuAy7OFuFFyKLOw69PntIxKtgfZYtKhA+TyjZCeQyZmXof7rpG/zdkyUxmZZRltmvQL54zNl0QhIJwLWeA=="], + + "@leafer-ui/paint": ["@leafer-ui/paint@2.2.7", "", { "dependencies": { "@leafer-ui/draw": "2.2.7", "@leafer/core": "2.2.7" } }, "sha512-sMnhyy/LGPH80r+0lHjIaOoFU+CWX0B7+vQ1I9cUfRy9wBufPmIT7TeGmkqahqwOdlmICz5DxQsHfwsI+uBO4g=="], + + "@leafer-ui/partner": ["@leafer-ui/partner@2.2.7", "", { "dependencies": { "@leafer-ui/color": "2.2.7", "@leafer-ui/draw": "2.2.7", "@leafer-ui/effect": "2.2.7", "@leafer-ui/paint": "2.2.7", "@leafer-ui/text": "2.2.7" } }, "sha512-FzpQAXDL4OLOilhvFNBZLSvJZITVezC9PuateRer/KWOY1GiJCbKEeL5uz9BEs6+XfCQV8BUOe6yBfv+7kCuEw=="], + + "@leafer-ui/render": ["@leafer-ui/render@2.2.7", "", { "dependencies": { "@leafer-ui/external": "2.2.7" } }, "sha512-4Ep5jJRf+0d5gWMGeh6AIPUoRpuyuPMs5Bf1+BSB/mOuhxJJX9SGClKMiuYoyYWF4D8G52vUBflgtOp9v13x6g=="], + + "@leafer-ui/text": ["@leafer-ui/text@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-SppAEzIwX/Jfgfwe75xrtV+vSfUdGUDG5v01emZvuqDPfl9NctTMlHxFx3H3JLxLz6R3wt040ZiaPvF3mN/jqg=="], + + "@leafer-ui/web": ["@leafer-ui/web@2.2.7", "", { "dependencies": { "@leafer-in/interface": "2.2.7", "@leafer-ui/core": "2.2.7", "@leafer-ui/draw": "2.2.7", "@leafer-ui/interaction-web": "2.2.7", "@leafer-ui/interface": "2.2.7", "@leafer-ui/partner": "2.2.7", "@leafer/interface": "2.2.7", "@leafer/partner": "2.2.7", "@leafer/web-core": "2.2.7" } }, "sha512-uJHexdwGSOQWNsyG5CU3lFeCz7UePBRh8MLGjdRulq+ttPLgSEu+iQ2sHCcGt3boGIGJxWHoOP7kD3PKahBBSA=="], + + "@leafer/canvas": ["@leafer/canvas@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/file": "2.2.7", "@leafer/list": "2.2.7", "@leafer/math": "2.2.7", "@leafer/path": "2.2.7", "@leafer/platform": "2.2.7" } }, "sha512-H0vmt+YoQ1/+UDiobHE0eqdmQ0EY+4nkwGRXPly/D0TWdj0lMhrPtQhP2iAtmMYQxpV9nDaCF5DbLLR9DSjLyw=="], + + "@leafer/canvas-web": ["@leafer/canvas-web@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-7RmkzxiW5hvXUs8lCnDc21VwM4LlcGPmmI/OGZ4at0bca317h6Auuq2zz/PRGButup/NDFmHP4S3vm6FCZTzFg=="], + + "@leafer/core": ["@leafer/core@2.2.7", "", { "dependencies": { "@leafer/canvas": "2.2.7", "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/decorator": "2.2.7", "@leafer/display": "2.2.7", "@leafer/display-module": "2.2.7", "@leafer/event": "2.2.7", "@leafer/file": "2.2.7", "@leafer/helper": "2.2.7", "@leafer/image": "2.2.7", "@leafer/layout": "2.2.7", "@leafer/list": "2.2.7", "@leafer/math": "2.2.7", "@leafer/path": "2.2.7", "@leafer/platform": "2.2.7", "@leafer/task": "2.2.7" } }, "sha512-AexqNYarSNuJOoiuxhrFTU1Y5HNLlL9hURKVM1p/XkxZy/69WY0eW0Lk6lGoAhQ+Qj3youMny5YU6NT2suZ9fw=="], + + "@leafer/data": ["@leafer/data@2.2.7", "", {}, "sha512-To2E54rIGQRtkoHDJRiw7nLsZJW5Co+zKAYu1LCOr2Sq1SaWkY6SxcR3ShHDvWgChBYtpY/6mFmdz7hQkr8NLA=="], + + "@leafer/debug": ["@leafer/debug@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/math": "2.2.7" } }, "sha512-JvYazHVjyvDkn/5z77OmKhVe4Uhc1+vwTJL7hLIczYYY31rPJFqd7qm4eToHMGsr/VQHdUDGReAYNBgUsTpTDg=="], + + "@leafer/decorator": ["@leafer/decorator@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/platform": "2.2.7" } }, "sha512-BecQ0AZHhqnbvS8bjrzPyWNIuwHWKLAOOfLgrSYgzp2qHCpadopQgyZ1+bvm6V84PTlX5Q3ui20S3+gXOI2JxA=="], + + "@leafer/display": ["@leafer/display@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/decorator": "2.2.7", "@leafer/display-module": "2.2.7", "@leafer/event": "2.2.7", "@leafer/helper": "2.2.7", "@leafer/image": "2.2.7", "@leafer/layout": "2.2.7", "@leafer/math": "2.2.7", "@leafer/platform": "2.2.7" } }, "sha512-Q0venUeZRWTVpEOJkSttSxnV8lbgYRAwqXCLIzzIneHhgVNKWWRIWqhizldRhMlNYwCqRUqlxfGQUbWUt2dF/Q=="], + + "@leafer/display-module": ["@leafer/display-module@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/event": "2.2.7", "@leafer/helper": "2.2.7", "@leafer/math": "2.2.7" } }, "sha512-yGgmXd6a1tL2pUqx7wZ0GRm+EwHihroDr7lXwuTx+tKWl0oO+NOln/37/dlc6pU4kO3inYktKBp5nx6qlQyWIw=="], + + "@leafer/event": ["@leafer/event@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/decorator": "2.2.7", "@leafer/math": "2.2.7", "@leafer/platform": "2.2.7" } }, "sha512-WirCEvs67dh8w4Gd9iFlqbwMavFQ+Lja/EBXnW5EaQ/bu6Opg3+6BUDDWd4vordDdvg9tkI3TINwl6UXy/v+0w=="], + + "@leafer/file": ["@leafer/file@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/platform": "2.2.7", "@leafer/task": "2.2.7" } }, "sha512-2c30wQSRRoNxaQJHHbXSYETL0Nr9kx1sJoDGvEY75u+wk4nRZJK0ifIEkl7gdKCPi/YaVc5Yl6szELlySA7yFA=="], + + "@leafer/helper": ["@leafer/helper@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/math": "2.2.7", "@leafer/platform": "2.2.7" } }, "sha512-yVcknYfmo5o006+j5UuU4/P+t9ZOWVo/ZAK9lbCGHglywqxadRAHQ097hZRXkran2hZb2LnfMsJMXaGjPeAtZw=="], + + "@leafer/image": ["@leafer/image@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/file": "2.2.7", "@leafer/math": "2.2.7", "@leafer/platform": "2.2.7", "@leafer/task": "2.2.7" } }, "sha512-/aeqkZOoHVA+wBLAX7EQRZQiGul7NBUkegUa9GXNF/VRkC5d4QSmWKM3ry2O+pMP63Rvl8DA1oC8jhFBOgkFmA=="], + + "@leafer/image-web": ["@leafer/image-web@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-ZALsNtsjAiv7RYTH8oDVcdz6hJyakPCXk9nnM5C/k1VY+g+Cj2kVu3u4Ld5GWXxijyHR1TEoLNohZkQ47GtlSA=="], + + "@leafer/interface": ["@leafer/interface@2.2.7", "", {}, "sha512-S8SJNj7F1UGYewIBPcJkfxBMMerVgbG4MO58KK8NC+H4+0nPef+AoRt95H7HFWjNOgD1WULqRX+P0lPhxny8hg=="], + + "@leafer/layout": ["@leafer/layout@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/helper": "2.2.7", "@leafer/math": "2.2.7", "@leafer/platform": "2.2.7" } }, "sha512-qO6yY8KcCOKj4bzCUP+ZCssTiP+1pVhzzTnYiEpz5Bmqhs5OMT5PR5+tGMFuSxnczK6DF+TR9mfU8mDFFltTIA=="], + + "@leafer/layouter": ["@leafer/layouter@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-CPj6MTvxwSqb4gVl0LkAAFdjRLRsKO7KzIggN1uqjhTcKtyexc7g+ytWbWEaSAhPeGsflf9RUvse6boX0ffdcg=="], + + "@leafer/list": ["@leafer/list@2.2.7", "", {}, "sha512-ihVc3B7qIHoiVkqq/i8zlwBl4o3dTwR3DRUVxC7DdjU5avuD+9Pbjem5dCVS2uovP7dapDV89aZOjQ/3tMOSmg=="], + + "@leafer/math": ["@leafer/math@2.2.7", "", {}, "sha512-aHWyjC+Jts+EmOywIuPd4NExzLx3JQjjPMZG6iP+PDseJL20o371mtNYhJoMASdS0Q2uc+cYMjF+vZylfMSjqw=="], + + "@leafer/partner": ["@leafer/partner@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7", "@leafer/layouter": "2.2.7", "@leafer/renderer": "2.2.7", "@leafer/selector": "2.2.7", "@leafer/watcher": "2.2.7" } }, "sha512-KP4kxL0sNxZFi7Z+QNPNNT4qrhs/rblsnrl0CDhn2v6tDOk4cD7B9ysmWwOjMilB2qG29IHeVjZH9wcuLDHapA=="], + + "@leafer/path": ["@leafer/path@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7", "@leafer/math": "2.2.7" } }, "sha512-rOSvkUAQVRF00lJewzSt8JIpMbuWwsD1b3BXUNgzWGzzqLDAoQfzd+YZ70zvrczLukds90wvHv22vlFuSX76fQ=="], + + "@leafer/platform": ["@leafer/platform@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/debug": "2.2.7" } }, "sha512-z2mw/6xAoN4i4FdWf7ksY14O/OIHNJJgSUDlKwCuwpF55wI+5YXM++66NCNZxBB/dgrjFsfM+JxRCdKiDxR9bw=="], + + "@leafer/renderer": ["@leafer/renderer@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-YMGpKcB1357Z/4MhJNfMrTYteps3FrY7hfki/0vDmMp0UdHS1KPuvR8tdOGKB/mXj99Bec3R8WYMm+KHA/v7ug=="], + + "@leafer/selector": ["@leafer/selector@2.2.7", "", { "dependencies": { "@leafer/core": "2.2.7" } }, "sha512-qPvBGCunuH7lQacePBcRipm/EJXOA6VUlTLyh5oFlwX0/Oeem0yNOrK/99SkJ8dNvbgbKKjQqNi2PIwdsgK3bA=="], + + "@leafer/task": ["@leafer/task@2.2.7", "", { "dependencies": { "@leafer/debug": "2.2.7", "@leafer/math": "2.2.7" } }, "sha512-fK2NfBIWre6TF6ZgSN6vBwf4stAliKNcGuKwe9dERF8Olig9AaawiOa/adBg4kUHSuPgTnQbi/K32XrFIcROMw=="], + + "@leafer/watcher": ["@leafer/watcher@2.2.7", "", { "dependencies": { "@leafer/data": "2.2.7", "@leafer/event": "2.2.7", "@leafer/list": "2.2.7" } }, "sha512-OV2YkWXbH2hCfIOLy80jOO+gB/LKc2iMkL3IKHTiJ0ZrnNlDPR5xoHcD13fnhBrOolqTfqnHtvem5NTTaxyZtA=="], + + "@leafer/web-core": ["@leafer/web-core@2.2.7", "", { "dependencies": { "@leafer/canvas-web": "2.2.7", "@leafer/core": "2.2.7", "@leafer/image-web": "2.2.7", "@leafer/interface": "2.2.7", "@leafer/partner": "2.2.7" } }, "sha512-/N1OVI8bAbZjMkmgCI4E43qMVQXOJRzBwY4iQf7PlYiKv7R67yT21lYulE8ysTX4P690sfjo9UxG32zOJaHx4w=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], @@ -1152,6 +1249,8 @@ "kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "leafer-ui": ["leafer-ui@2.2.7", "", { "dependencies": { "@leafer-in/interface": "2.2.7", "@leafer-ui/core": "2.2.7", "@leafer-ui/draw": "2.2.7", "@leafer-ui/interaction-web": "2.2.7", "@leafer-ui/interface": "2.2.7", "@leafer-ui/partner": "2.2.7", "@leafer-ui/web": "2.2.7", "@leafer/core": "2.2.7", "@leafer/interface": "2.2.7", "@leafer/partner": "2.2.7" } }, "sha512-G9PYMImGFlDr2yd6WVjgirQqywb3Z6fRokZYq8m8Bq1cujUfXNCiPojd5Ol2S0NGgGTHCKwIdyTmmV/buHH0XQ=="], + "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], diff --git a/web/package.json b/web/package.json index bf02bfe87..55ef902c9 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "dayjs": "^1.11.21", "fflate": "^0.8.3", "file-saver": "^2.0.5", + "leafer-ui": "^2.2.7", "localforage": "^1.10.0", "lucide-react": "^1.25.0", "mammoth": "^1.12.0", diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index 462574d15..d49221340 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -13,6 +13,7 @@ export const ConnectionPath = React.memo(function ConnectionPath({ fromScrollTop = 0, toScrollTop = 0, active, + visualMode = "full", onSelect, onContextMenu, }: { @@ -22,19 +23,15 @@ export const ConnectionPath = React.memo(function ConnectionPath({ fromScrollTop?: number; toScrollTop?: number; active: boolean; + visualMode?: "full" | "hover-only"; onSelect: () => void; onContextMenu?: (event: ReactMouseEvent) => void; }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [hovered, setHovered] = useState(false); - const startX = from.position.x + from.width; - const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop); - const endX = to.position.x; - const endY = connectionHandleY(to, connection.toHandleId, toScrollTop); - const dx = Math.abs(endX - startX); - const curvature = Math.max(dx * 0.5, 50); - const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`; + const { pathD, startX, startY, endX, endY } = canvasConnectionPath(connection, from, to, fromScrollTop, toScrollTop); const emphasized = active || hovered; + const showVisual = visualMode === "full" || hovered; const gradientId = `canvas-flow-${connection.id.replace(/[^a-zA-Z0-9_-]/g, "")}`; return ( @@ -66,7 +63,7 @@ export const ConnectionPath = React.memo(function ConnectionPath({ onContextMenu?.(event); }} /> - - {emphasized ? : null} + {showVisual && emphasized ? : null} ); -}, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.fromScrollTop === next.fromScrollTop && previous.toScrollTop === next.toScrollTop); +}, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.visualMode === next.visualMode && previous.fromScrollTop === next.fromScrollTop && previous.toScrollTop === next.toScrollTop); -export function ActiveConnectionPath({ node, handle, mouseWorld, target, nodeScrollTop = 0 }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position; target?: CanvasNodeData; nodeScrollTop?: number }) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - if (!node) return null; +export function canvasConnectionPath(connection: CanvasConnection, from: CanvasNodeData, to: CanvasNodeData, fromScrollTop = 0, toScrollTop = 0) { + const startX = from.position.x + from.width; + const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop); + const endX = to.position.x; + const endY = connectionHandleY(to, connection.toHandleId, toScrollTop); + const dx = Math.abs(endX - startX); + const curvature = Math.max(dx * 0.5, 50); + return { pathD: `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`, startX, startY, endX, endY }; +} +export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0) { + if (!node) return ""; const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x; const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop) : mouseWorld.y; const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x; @@ -105,12 +110,10 @@ export function ActiveConnectionPath({ node, handle, mouseWorld, target, nodeScr const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX; const snappedEndY = handle.handleType === "source" && target ? target.position.y + target.height / 2 : endY; const distance = Math.abs(snappedEndX - snappedStartX); - const pathD = `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`; - - return ; + return `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`; } -function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) { +export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) { if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2; if (!handleId?.startsWith("row:")) return node.position.y + node.height / 2; const rowId = handleId.slice(4); diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx new file mode 100644 index 000000000..424475dd7 --- /dev/null +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -0,0 +1,325 @@ +import { useLayoutEffect, useRef, type RefObject } from "react"; +import { Group, Leafer, Path, Rect } from "leafer-ui"; + +import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections"; +import { subscribeCanvasSelectionPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; +import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport"; +import type { CanvasTheme } from "@/lib/canvas-theme"; +import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas"; + +type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null; + +type CanvasLeaferGraphicsLayerProps = { + containerRef: RefObject; + viewport: ViewportTransform; + theme: CanvasTheme; + displayConnections: CanvasDisplayConnection[]; + selectedConnectionId: string | null; + relatedConnectionIds: Set; + scriptScrollTopById: Record; + connectingParams: ConnectionHandle | null; + mouseWorld: Position; + connectionTargetNodeId: string | null; + nodeById: Map; + selectionBox: SelectionBox | null; + selectedNodeBounds: NodeBounds; + alignmentGuides: { vertical?: number; horizontal?: number }; +}; + +type LeaferScene = { + leafer: Leafer; + world: Group; + host: HTMLDivElement; +}; + +type UnderlayScene = LeaferScene & { + connections: Group; +}; + +type OverlayScene = LeaferScene & { + selection: Rect; + selectionBounds: Rect; + guides: Path; + draft: Path; +}; + +export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) { + const underlayHostRef = useRef(null); + const overlayHostRef = useRef(null); + const underlayRef = useRef(null); + const overlayRef = useRef(null); + const viewportRef = useRef(props.viewport); + const rasterViewportRef = useRef(props.viewport); + const propsRef = useRef(props); + propsRef.current = props; + + useLayoutEffect(() => { + const underlayHost = underlayHostRef.current; + const overlayHost = overlayHostRef.current; + // 子组件 layout effect 可能早于父层 ref 对外可见,host 的直接父元素才是此刻最可靠的画布容器。 + const container = (props.containerRef.current || underlayHost?.parentElement) as HTMLDivElement | null; + if (!underlayHost || !overlayHost || !container) return; + + const underlay = createUnderlayScene(underlayHost); + const overlay = createOverlayScene(overlayHost); + underlayRef.current = underlay; + overlayRef.current = overlay; + + const resize = () => { + const rect = container.getBoundingClientRect(); + const size = { width: Math.max(1, rect.width), height: Math.max(1, rect.height), pixelRatio: canvasPixelRatio() }; + underlay.leafer.resize(size); + overlay.leafer.resize(size); + syncViewport(rasterViewportRef.current, size.width, size.height, underlay, overlay, propsRef.current); + if (isZoomPreview(container, viewportRef.current, rasterViewportRef.current)) { + applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay); + } + }; + const resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(container); + window.addEventListener("resize", resize); + const unsubscribe = subscribeCanvasViewportPreview(container, (next) => { + viewportRef.current = next; + const rect = container.getBoundingClientRect(); + if (isZoomPreview(container, next, rasterViewportRef.current)) { + if (shouldRebaseCanvasRaster(next, rasterViewportRef.current)) { + syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current); + rasterViewportRef.current = next; + forceSceneRender(underlay, overlay); + resetScenePreview(underlay, overlay); + return; + } + applyScenePreview(next, rasterViewportRef.current, underlay, overlay); + return; + } + resetScenePreview(underlay, overlay); + if (sameCanvasViewport(next, rasterViewportRef.current)) return; + syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current); + rasterViewportRef.current = next; + }); + const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => { + syncSelection(overlay.selection, selection, propsRef.current.theme); + }); + resize(); + + return () => { + unsubscribe(); + unsubscribeSelection(); + resizeObserver.disconnect(); + window.removeEventListener("resize", resize); + underlay.leafer.destroy(true); + overlay.leafer.destroy(true); + underlayRef.current = null; + overlayRef.current = null; + }; + }, [props.containerRef]); + + useLayoutEffect(() => { + const underlay = underlayRef.current; + if (!underlay) return; + rebuildConnections(underlay, props); + }, [props.displayConnections, props.relatedConnectionIds, props.scriptScrollTopById, props.selectedConnectionId, props.theme]); + + useLayoutEffect(() => { + const overlay = overlayRef.current; + if (!overlay) return; + syncOverlayContent(overlay, props, viewportRef.current.k); + }, [props.connectingParams, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]); + + useLayoutEffect(() => { + const underlay = underlayRef.current; + const overlay = overlayRef.current; + const container = props.containerRef.current; + if (!underlay || !overlay || !container) return; + viewportRef.current = props.viewport; + const rect = container.getBoundingClientRect(); + const hadPreview = hasScenePreview(underlay, overlay); + if (hadPreview || !sameCanvasViewport(props.viewport, rasterViewportRef.current)) { + syncViewport(props.viewport, rect.width, rect.height, underlay, overlay, props); + } + rasterViewportRef.current = props.viewport; + // 新视口先同步到真实 DPR backing store,再撤销交互期的合成变换,避免出现跳帧。 + if (hadPreview) forceSceneRender(underlay, overlay); + resetScenePreview(underlay, overlay); + }, [props.containerRef, props.viewport]); + + useLayoutEffect(() => { + const underlay = underlayRef.current; + const overlay = overlayRef.current; + const container = props.containerRef.current; + if (!underlay || !overlay || !container) return; + const rect = container.getBoundingClientRect(); + syncViewport(rasterViewportRef.current, rect.width, rect.height, underlay, overlay, props); + if (isZoomPreview(container, viewportRef.current, rasterViewportRef.current)) { + applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay); + } + }, [props.alignmentGuides, props.containerRef, props.theme]); + + return ( + <> +
+
+ + ); +} + +function createUnderlayScene(host: HTMLDivElement): UnderlayScene { + const leafer = new Leafer({ view: host, width: 1, height: 1, pixelRatio: canvasPixelRatio(), fill: "transparent", hittable: false, smooth: true }); + const world = new Group({ hittable: false }); + const connections = new Group({ hittable: false }); + world.add(connections); + leafer.add(world); + return { leafer, world, host, connections }; +} + +function createOverlayScene(host: HTMLDivElement): OverlayScene { + const leafer = new Leafer({ view: host, width: 1, height: 1, pixelRatio: canvasPixelRatio(), fill: "transparent", hittable: false, smooth: true }); + const world = new Group({ hittable: false }); + const selection = new Rect({ visible: false, hittable: false }); + const selectionBounds = new Rect({ visible: false, hittable: false, fill: "transparent" }); + const guides = new Path({ visible: false, hittable: false }); + const draft = new Path({ visible: false, hittable: false }); + world.add(selection); + world.add(selectionBounds); + world.add(guides); + world.add(draft); + leafer.add(world); + return { leafer, world, host, selection, selectionBounds, guides, draft }; +} + +function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) { + scene.connections.removeAll(true); + props.displayConnections.forEach(({ connection, from, to }) => { + const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id); + const path = new Path({ + path: canvasConnectionPath(connection, from, to, props.scriptScrollTopById[from.id] || 0, props.scriptScrollTopById[to.id] || 0).pathD, + stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted, + strokeWidth: emphasized ? 1.6 : 1, + strokeScaleFixed: true, + strokeCap: "round", + opacity: emphasized ? 0.52 : 0.24, + hittable: false, + }); + scene.connections.add(path); + }); +} + +function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) { + const selection = props.selectionBox; + scene.selection.visible = Boolean(selection); + if (selection) { + syncSelection(scene.selection, selection, props.theme); + } + + const bounds = props.selectedNodeBounds; + scene.selectionBounds.visible = Boolean(bounds && !selection); + if (bounds && !selection) { + syncSelectionBounds(scene.selectionBounds, bounds, viewportScale); + scene.selectionBounds.stroke = props.theme.accent.primary; + } + + const connecting = props.connectingParams; + scene.draft.visible = Boolean(connecting); + if (connecting) { + scene.draft.set({ + path: activeConnectionPath( + props.nodeById.get(connecting.nodeId), + connecting, + props.mouseWorld, + props.connectionTargetNodeId ? props.nodeById.get(props.connectionTargetNodeId) : undefined, + props.scriptScrollTopById[connecting.nodeId] || 0, + ), + stroke: props.theme.accent.primary, + strokeCap: "round", + opacity: 0.72, + }); + } +} + +function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme) { + rect.set({ + x: Math.min(selection.startWorldX, selection.currentWorldX), + y: Math.min(selection.startWorldY, selection.currentWorldY), + width: Math.abs(selection.currentWorldX - selection.startWorldX), + height: Math.abs(selection.currentWorldY - selection.startWorldY), + fill: theme.canvas.selectionFill, + stroke: theme.accent.primary, + }); +} + +function syncViewport(viewport: ViewportTransform, width: number, height: number, underlay: UnderlayScene, overlay: OverlayScene, props: CanvasLeaferGraphicsLayerProps) { + const scale = Math.max(viewport.k, 0.05); + for (const scene of [underlay, overlay]) scene.world.set({ x: viewport.x, y: viewport.y, scaleX: scale, scaleY: scale }); + + overlay.selection.strokeWidth = 1 / scale; + overlay.selection.cornerRadius = 2 / scale; + if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale); + overlay.selectionBounds.set({ strokeWidth: 1 / scale, cornerRadius: 12 / scale }); + overlay.draft.set({ strokeWidth: 1.4 / scale, dashPattern: [8 / scale, 8 / scale] }); + overlay.guides.set({ + visible: typeof props.alignmentGuides.vertical === "number" || typeof props.alignmentGuides.horizontal === "number", + path: guidePath(viewport, width, height, props.alignmentGuides), + stroke: props.theme.accent.primary, + strokeWidth: 1 / scale, + dashPattern: [5 / scale, 5 / scale], + opacity: 0.72, + }); +} + +function isZoomPreview(container: HTMLDivElement, viewport: ViewportTransform, rasterViewport: ViewportTransform) { + return container.dataset.canvasViewportInteracting === "true" && Math.abs(viewport.k - rasterViewport.k) > 0.0001; +} + +function applyScenePreview(viewport: ViewportTransform, rasterViewport: ViewportTransform, ...scenes: LeaferScene[]) { + // 将已栅格画面的屏幕坐标映射到实时视口,缩放手势期间不触碰 Leafer 场景树。 + const { ratio, x, y } = calculateCanvasPreviewTransform(viewport, rasterViewport); + for (const scene of scenes) { + scene.host.style.transformOrigin = "0 0"; + scene.host.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${ratio})`; + scene.host.style.willChange = "transform"; + scene.host.dataset.canvasLeaferPreview = "true"; + } +} + +function hasScenePreview(...scenes: LeaferScene[]) { + return scenes.some((scene) => scene.host.dataset.canvasLeaferPreview === "true"); +} + +function resetScenePreview(...scenes: LeaferScene[]) { + for (const scene of scenes) { + scene.host.style.transform = ""; + scene.host.style.transformOrigin = ""; + scene.host.style.willChange = ""; + delete scene.host.dataset.canvasLeaferPreview; + } +} + +function forceSceneRender(...scenes: LeaferScene[]) { + for (const scene of scenes) scene.leafer.forceRender(undefined, true); +} + +function syncSelectionBounds(rect: Rect, bounds: NonNullable, viewportScale: number) { + const padding = 12 / Math.max(viewportScale, 0.05); + rect.set({ + x: bounds.left - padding, + y: bounds.top - padding, + width: bounds.width + padding * 2, + height: bounds.height + padding * 2, + }); +} + +function guidePath(viewport: ViewportTransform, width: number, height: number, guides: { vertical?: number; horizontal?: number }) { + const scale = Math.max(viewport.k, 0.05); + const left = -viewport.x / scale; + const top = -viewport.y / scale; + const right = left + width / scale; + const bottom = top + height / scale; + const commands: string[] = []; + if (typeof guides.vertical === "number") commands.push(`M ${guides.vertical} ${top} L ${guides.vertical} ${bottom}`); + if (typeof guides.horizontal === "number") commands.push(`M ${left} ${guides.horizontal} L ${right} ${guides.horizontal}`); + return commands.join(" "); +} + +function canvasPixelRatio() { + return Math.min(3, Math.max(1, window.devicePixelRatio || 1)); +} diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index e9262ae56..46d8e671b 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -180,29 +180,6 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co ); } -export function CanvasAlignmentGuides({ guides, viewport, containerRef, color }: { guides: { vertical: number | null; horizontal: number | null }; viewport: ViewportTransform; containerRef: RefObject; color: string }) { - const verticalRef = useRef(null); - const horizontalRef = useRef(null); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) return; - const update = (nextViewport: ViewportTransform) => { - if (verticalRef.current && typeof guides.vertical === "number") verticalRef.current.style.left = `${nextViewport.x + guides.vertical * nextViewport.k}px`; - if (horizontalRef.current && typeof guides.horizontal === "number") horizontalRef.current.style.top = `${nextViewport.y + guides.horizontal * nextViewport.k}px`; - }; - update(viewport); - return subscribeCanvasViewportPreview(container, update); - }, [containerRef, guides.horizontal, guides.vertical, viewport]); - - return ( - <> - {typeof guides.vertical === "number" ?
: null} - {typeof guides.horizontal === "number" ?
: null} - - ); -} - function ConnectionCreateOption({ motionEnabled, icon, title, description, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; onClick: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return ( diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index 7f057f540..f99e2d9bd 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; @@ -20,11 +20,12 @@ type InfiniteCanvasProps = { onFileDragLeave?: (event: React.DragEvent) => void; onFileDragOver?: (event: React.DragEvent) => void; children: React.ReactNode; + graphicsLayer?: React.ReactNode; }; const CANVAS_WHEEL_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-canvas-wheel-scroll],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown"; -const WHEEL_ZOOM_DELTA = 100; -const TRACKPAD_PINCH_ZOOM_DELTA = 36; +const WHEEL_ZOOM_DELTA = 72; +const TRACKPAD_PINCH_ZOOM_DELTA = 24; type TouchPoint = { x: number; y: number }; @@ -37,7 +38,7 @@ type PinchState = { initialScale: number; }; -export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, children }: InfiniteCanvasProps) { +export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const panState = useRef({ isPanning: false, @@ -61,7 +62,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines const [isSpacePressed, setIsSpacePressed] = useState(false); const [isPanning, setIsPanning] = useState(false); - useEffect(() => { + useLayoutEffect(() => { if (interactingRef.current) return; viewportRef.current = viewport; scaleRef.current = viewport.k; @@ -373,6 +374,8 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines "--canvas-live-x": `${viewport.x}px`, "--canvas-live-y": `${viewport.y}px`, "--canvas-live-scale": viewport.k, + "--canvas-committed-scale": viewport.k, + "--canvas-live-scale-ratio": 1, "--canvas-grid-size": `${48 * viewport.k}px`, "--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`, "--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`, @@ -393,15 +396,14 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines onDrop={onDrop} > + {graphicsLayer}
- {children} +
+ {children} +
); diff --git a/web/src/lib/canvas/canvas-leafer-viewport.ts b/web/src/lib/canvas/canvas-leafer-viewport.ts new file mode 100644 index 000000000..12bec85ac --- /dev/null +++ b/web/src/lib/canvas/canvas-leafer-viewport.ts @@ -0,0 +1,24 @@ +import type { ViewportTransform } from "@/types/canvas"; + +const MIN_VIEWPORT_SCALE = 0.05; +const MIN_PREVIEW_RATIO = 0.85; +const MAX_PREVIEW_RATIO = 1.25; + +export function calculateCanvasPreviewTransform(viewport: ViewportTransform, rasterViewport: ViewportTransform) { + const ratio = viewport.k / Math.max(rasterViewport.k, MIN_VIEWPORT_SCALE); + // 将旧 backing store 的屏幕坐标映射到实时视口,避免缩放手势逐帧触发矢量重绘。 + return { + ratio, + x: viewport.x - rasterViewport.x * ratio, + y: viewport.y - rasterViewport.y * ratio, + }; +} + +export function shouldRebaseCanvasRaster(viewport: ViewportTransform, rasterViewport: ViewportTransform) { + const { ratio } = calculateCanvasPreviewTransform(viewport, rasterViewport); + return ratio < MIN_PREVIEW_RATIO || ratio > MAX_PREVIEW_RATIO; +} + +export function sameCanvasViewport(left: ViewportTransform, right: ViewportTransform) { + return left.x === right.x && left.y === right.y && left.k === right.k; +} diff --git a/web/src/lib/canvas/canvas-live-viewport.ts b/web/src/lib/canvas/canvas-live-viewport.ts index e7337225a..7807ff9c6 100644 --- a/web/src/lib/canvas/canvas-live-viewport.ts +++ b/web/src/lib/canvas/canvas-live-viewport.ts @@ -1,13 +1,16 @@ -import type { ViewportTransform } from "@/types/canvas"; +import type { SelectionBox, ViewportTransform } from "@/types/canvas"; export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview"; +export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview"; export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) { if (!container) return; const gridSize = 48 * viewport.k; + const committedScale = Number(container.style.getPropertyValue("--canvas-committed-scale")) || viewport.k; container.style.setProperty("--canvas-live-x", `${viewport.x}px`); container.style.setProperty("--canvas-live-y", `${viewport.y}px`); container.style.setProperty("--canvas-live-scale", String(viewport.k)); + container.style.setProperty("--canvas-live-scale-ratio", String(viewport.k / committedScale)); container.style.setProperty("--canvas-grid-size", `${gridSize}px`); container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`); container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`); @@ -24,3 +27,13 @@ export function subscribeCanvasViewportPreview(container: HTMLDivElement, listen container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview); return () => container.removeEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview); } + +export function applyCanvasSelectionPreview(container: HTMLDivElement | null, selection: SelectionBox) { + container?.dispatchEvent(new CustomEvent(CANVAS_SELECTION_PREVIEW_EVENT, { detail: selection })); +} + +export function subscribeCanvasSelectionPreview(container: HTMLDivElement, listener: (selection: SelectionBox) => void) { + const handlePreview = (event: Event) => listener((event as CustomEvent).detail); + container.addEventListener(CANVAS_SELECTION_PREVIEW_EVENT, handlePreview); + return () => container.removeEventListener(CANVAS_SELECTION_PREVIEW_EVENT, handlePreview); +} diff --git a/web/src/pages/canvas/canvas-project-world-layers.tsx b/web/src/pages/canvas/canvas-project-world-layers.tsx index 773fb1487..c3cd9e723 100644 --- a/web/src/pages/canvas/canvas-project-world-layers.tsx +++ b/web/src/pages/canvas/canvas-project-world-layers.tsx @@ -1,27 +1,20 @@ import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"; -import { ActiveConnectionPath, ConnectionPath } from "@/components/canvas/canvas-connections"; +import { ConnectionPath } from "@/components/canvas/canvas-connections"; import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node"; import { CanvasNode } from "@/components/canvas/canvas-node"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { isFrameNode } from "@/lib/canvas/canvas-frame"; -import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas"; +import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas"; -type CanvasTheme = { - accent: { primary: string }; - canvas: { selectionFill: string }; -}; - -type DisplayConnection = { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData }; type DragPreview = { x: number; y: number; nodeIds: Set } | null; type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null; type CanvasProjectWorldLayersProps = { projectId: string; - theme: CanvasTheme; viewportScale: number; connectionLayerBounds: { left: number; top: number; width: number; height: number }; - displayConnections: DisplayConnection[]; + displayConnections: CanvasDisplayConnection[]; selectedConnectionId: string | null; relatedConnectionIds: Set; scriptScrollTopById: Record; @@ -49,7 +42,6 @@ type CanvasProjectWorldLayersProps = { selectedNodeBounds: NodeBounds; isNodeDragging: boolean; selectionBoundsElementRef: RefObject; - selectionBoxElementRef: RefObject; renderCanvasNodeContent: (node: CanvasNodeData) => ReactNode; onConnectionSelect: (connectionId: string) => void; onConnectionContextMenu: (event: ReactMouseEvent, connectionId: string) => void; @@ -79,7 +71,7 @@ const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = []; const EMPTY_CANVAS_NODES: CanvasNodeData[] = []; export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { - const { theme, viewportScale } = props; + const { viewportScale } = props; return ( <> props.onConnectionSelect(connection.id)} onContextMenu={(event) => props.onConnectionContextMenu(event, connection.id)} /> ))} - {props.connectingParams ? : null} {props.visibleNodes.map((node) => @@ -167,29 +159,12 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { {props.selectedNodeBounds && !props.selectionBox && !props.isNodeDragging ? (
- ) : null} - - {props.selectionBox ? ( -
) : null} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index da77d277b..652fffdb1 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -46,7 +46,8 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -import { CanvasAlignmentGuides, CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays"; +import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays"; +import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; import { CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry"; import { createCanvasNode, @@ -680,8 +681,8 @@ function InfiniteCanvasPage() { nodeDraggingRef, selectionBoundsElementRef, selectionBox, - selectionBoxElementRef, } = useCanvasSelectionController({ + containerRef, nodesRef, viewportRef, selectedNodeIdsRef, @@ -1400,6 +1401,24 @@ function InfiniteCanvasPage() { containerRef={containerRef} viewport={viewport} backgroundMode={backgroundMode} + graphicsLayer={( + + )} onViewportChange={handleViewportChange} onViewportPreviewChange={handleViewportPreviewChange} onCanvasMouseDown={handleCanvasMouseDown} @@ -1413,7 +1432,6 @@ function InfiniteCanvasPage() { > { setSelectedConnectionId(connectionId); setSelectedNodeIds(new Set()); setContextMenu(null); }} onConnectionContextMenu={(event, connectionId) => { setSelectedConnectionId(connectionId); setSelectedNodeIds(new Set()); closeConnectionCreateMenu(); setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); }} @@ -1501,8 +1518,6 @@ function InfiniteCanvasPage() { {selectedNodeBounds && !selectionBox && !isNodeDragging ? void mergeSelectedVideos()} /> : null} - - {uploadStatus ? : null} {mergeVideoProgress ? : null} {lastAgentChange ? { undoAgentOps(); }} onClose={dismissLastAgentChange} /> : null} diff --git a/web/src/pages/canvas/use-canvas-selection-controller.ts b/web/src/pages/canvas/use-canvas-selection-controller.ts index b8f57b98c..8e4a7dcc0 100644 --- a/web/src/pages/canvas/use-canvas-selection-controller.ts +++ b/web/src/pages/canvas/use-canvas-selection-controller.ts @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction } from "react"; +import { applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport"; import { calculateNodeAlignment, createNodeAlignmentContext, isHiddenBatchChild, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain"; import { applyFrameDrop, findFrameDropTarget, getFrameChildIds, isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame"; import type { CanvasNodeData, Position, SelectionBox, ViewportTransform } from "@/types/canvas"; type UseCanvasSelectionControllerOptions = { + containerRef: RefObject; nodesRef: { current: CanvasNodeData[] }; viewportRef: { current: ViewportTransform }; selectedNodeIdsRef: { current: Set }; @@ -41,6 +43,7 @@ const EMPTY_DRAG_STATE: DragState = { }; export function useCanvasSelectionController({ + containerRef, nodesRef, viewportRef, selectedNodeIdsRef, @@ -61,7 +64,6 @@ export function useCanvasSelectionController({ const alignmentContextRef = useRef(null); const lastFrameDropCheckRef = useRef(0); const selectionFrameRef = useRef(null); - const selectionBoxElementRef = useRef(null); const selectionBoundsElementRef = useRef(null); const selectionCandidatesRef = useRef>([]); const pendingSelectionPointRef = useRef(null); @@ -274,13 +276,9 @@ export function useCanvasSelectionController({ const rectY = Math.min(selection.startWorldY, world.y); const rectW = Math.abs(world.x - selection.startWorldX); const rectH = Math.abs(world.y - selection.startWorldY); - const element = selectionBoxElementRef.current; - if (element) { - element.style.setProperty("--selection-x", `${rectX}px`); - element.style.setProperty("--selection-y", `${rectY}px`); - element.style.setProperty("--selection-width", `${rectW}px`); - element.style.setProperty("--selection-height", `${rectH}px`); - } + selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y }; + selectionBoxRef.current = selection; + applyCanvasSelectionPreview(containerRef.current, selection); const nextSelected = new Set(selection.additive || selection.subtractive ? selection.initialSelectedNodeIds : []); selectionCandidatesRef.current.forEach((node) => { if (rectX >= node.right || rectX + rectW <= node.left || rectY >= node.bottom || rectY + rectH <= node.top) return; @@ -291,7 +289,7 @@ export function useCanvasSelectionController({ selectedNodeIdsRef.current = nextSelected; setSelectedNodeIds(nextSelected); }); - }, [cancelSelectionBox, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]); + }, [cancelSelectionBox, containerRef, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]); const finishSelection = useCallback(() => { const hadPendingSelection = Boolean(selectionBoxRef.current); @@ -337,6 +335,5 @@ export function useCanvasSelectionController({ nodeDraggingRef, selectionBoundsElementRef, selectionBox, - selectionBoxElementRef, }; } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index de225ca28..9aab727f1 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1278,6 +1278,21 @@ transition: none !important; } + /* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */ + .canvas-world-layer { + transform: translate(var(--canvas-live-x), var(--canvas-live-y)) scale(var(--canvas-live-scale-ratio)); + transform-origin: 0 0; + } + + .canvas-world-raster-layer { + zoom: var(--canvas-committed-scale); + } + + [data-canvas-viewport-interacting="true"] .canvas-world-layer { + transform: translate3d(var(--canvas-live-x), var(--canvas-live-y), 0) scale(var(--canvas-live-scale-ratio)); + will-change: transform; + } + [data-canvas-viewport-interacting="true"] .canvas-node-shell.aceternity-comet-card, [data-canvas-viewport-interacting="true"] .canvas-node-shell.aceternity-comet-card:hover { box-shadow: none !important; diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 3030a9cc4..438c60b20 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -278,6 +278,12 @@ export type CanvasConnection = { toHandleId?: string; }; +export type CanvasDisplayConnection = { + connection: CanvasConnection; + from: CanvasNodeData; + to: CanvasNodeData; +}; + export type CanvasAssistantReference = { id: string; type: CanvasNodeType; diff --git a/web/test/canvas-leafer-viewport.test.ts b/web/test/canvas-leafer-viewport.test.ts new file mode 100644 index 000000000..9bc319619 --- /dev/null +++ b/web/test/canvas-leafer-viewport.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "../src/lib/canvas/canvas-leafer-viewport"; + +describe("calculateCanvasPreviewTransform", () => { + test("把已栅格视口映射到实时缩放视口", () => { + expect(calculateCanvasPreviewTransform( + { x: 80, y: 25, k: 1.5 }, + { x: 100, y: 50, k: 1 }, + )).toEqual({ ratio: 1.5, x: -70, y: -50 }); + }); + + test("仅平移时保持单位缩放", () => { + expect(calculateCanvasPreviewTransform( + { x: 135, y: 70, k: 1.25 }, + { x: 100, y: 50, k: 1.25 }, + )).toEqual({ ratio: 1, x: 35, y: 20 }); + }); +}); + +describe("shouldRebaseCanvasRaster", () => { + const rasterViewport = { x: 100, y: 50, k: 1 }; + + test("常规缩放复用当前栅格", () => { + expect(shouldRebaseCanvasRaster({ x: 90, y: 45, k: 0.85 }, rasterViewport)).toBe(false); + expect(shouldRebaseCanvasRaster({ x: 80, y: 40, k: 1.25 }, rasterViewport)).toBe(false); + }); + + test("超出缓存阈值时重建栅格基线", () => { + expect(shouldRebaseCanvasRaster({ x: 90, y: 45, k: 0.849 }, rasterViewport)).toBe(true); + expect(shouldRebaseCanvasRaster({ x: 80, y: 40, k: 1.251 }, rasterViewport)).toBe(true); + }); +}); + +test("sameCanvasViewport 比较完整视口", () => { + expect(sameCanvasViewport({ x: 1, y: 2, k: 1.5 }, { x: 1, y: 2, k: 1.5 })).toBe(true); + expect(sameCanvasViewport({ x: 1, y: 2, k: 1.5 }, { x: 1, y: 3, k: 1.5 })).toBe(false); +});