feat(canvas): add leafer GPU renderer and interactive color-boundary crop tool
Introduce a leafer-ui canvas layer that mirrors rendered nodes and drives viewport, node-drag, and alignment-guide updates imperatively, committing React state on idle via startTransition to keep pan/zoom/drag at 60fps. Wire the imperative viewport through wheel zoom, zoom-to, fit-view, and panning. Add a dedicated crop tool: click a node to auto-detect a color-boundary crop region or drag to preselect one, previewed with an on-canvas draft overlay before entering the crop editor. Route uploads through renderContent so the transparent WebP texture feeds the fast display path. Add node --test coverage for the leafer renderer and color-boundary detection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-switch": "^1.3.2",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"ag-psd": "31.0.2",
|
||||
"leafer-ui": "2.2.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.7",
|
||||
|
||||
Generated
+404
@@ -29,6 +29,9 @@ dependencies:
|
||||
ag-psd:
|
||||
specifier: 31.0.2
|
||||
version: 31.0.2
|
||||
leafer-ui:
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2
|
||||
lucide-react:
|
||||
specifier: ^1.23.0
|
||||
version: 1.23.0(react@19.2.7)
|
||||
@@ -353,6 +356,392 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@leafer-in/interface@2.2.2(@leafer-ui/interface@2.2.2)(@leafer/interface@2.2.2):
|
||||
resolution: {integrity: sha512-7fN7hEd2d+0glIH12ijjPx+XSqn+Q9RwoCkUpATNynfimv4u7ezWoJQFa76Aj0uusWW3ghpzW5vbO07IR2tJmA==}
|
||||
peerDependencies:
|
||||
'@leafer-ui/interface': ^2.2.2
|
||||
'@leafer/interface': ^2.2.2
|
||||
dependencies:
|
||||
'@leafer-ui/interface': 2.2.2
|
||||
'@leafer/interface': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/app@2.2.2:
|
||||
resolution: {integrity: sha512-mN0ItJqbtJPwWiqo7rvl8GpVGMNUd8/VgZ0MUZxad8kxkKkjnnCoawczj+URF61ebDgO2U6OUSnhi4bPSmh5QQ==}
|
||||
dependencies:
|
||||
'@leafer-ui/data': 2.2.2
|
||||
'@leafer-ui/display': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/bounds@2.2.2:
|
||||
resolution: {integrity: sha512-pvZEkvJMeticrV1X5YekBw/7RO8VTpXSrAFWAdyKxJGXMdkeo7RbUx1FHiT8rg83AcXzj+jQn6LptyOZLeGEUQ==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/color@2.2.2:
|
||||
resolution: {integrity: sha512-zgEPxuc50mSx04SMrK2s/u2PNIrOr929HVuwynrgtVQrQyBhU9FIZ5+0qrxt73mdKa/TfrzMY39N/X9Z6iYIGw==}
|
||||
dependencies:
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/core@2.2.2:
|
||||
resolution: {integrity: sha512-oglxh4eZBg8j5j8uDbTxO4dSeBgB7icG1ofo1pcBmKzs/f570IIIjR95IJ6fYM97zPy9MD6zxi49G56Yr6vd2g==}
|
||||
dependencies:
|
||||
'@leafer-ui/app': 2.2.2
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/event': 2.2.2
|
||||
'@leafer-ui/hit': 2.2.2
|
||||
'@leafer-ui/interaction': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/data@2.2.2:
|
||||
resolution: {integrity: sha512-FMGhKc5aiMvxifdSSxEsn4SxUdGuXX8i1doby1OUg2phGe69HH/7ZQD2FkyQ7XJRbGgGGfp+F7YfKJNQ5juCEw==}
|
||||
dependencies:
|
||||
'@leafer-ui/external': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/decorator@2.2.2:
|
||||
resolution: {integrity: sha512-lNXe8zwuH4t2gd2is3H0w5e9meAtwcNBCLmVt78f7AZzD1/m114Gj7ElDHnPDkhpFt2IzDnMhrk5tTEn/iMFFg==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/display-module@2.2.2:
|
||||
resolution: {integrity: sha512-mp+PfDNdbKduovmEZ2IcZWs63mw1BhXM+uC9TLd7YGuLxoYdq0B94q1ebfys15BhgNgh8fUSpYtwtJ9Bxu9FIg==}
|
||||
dependencies:
|
||||
'@leafer-ui/bounds': 2.2.2
|
||||
'@leafer-ui/data': 2.2.2
|
||||
'@leafer-ui/render': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/display@2.2.2:
|
||||
resolution: {integrity: sha512-7kX1ACzK443JhZM6OLgooSh2J2xZgM0lw+lnhr/zBJEfsWcw8mW9QZA3Sp6zsXjyK/h8Y4Jo4H4KJerhjXyAAQ==}
|
||||
dependencies:
|
||||
'@leafer-ui/data': 2.2.2
|
||||
'@leafer-ui/decorator': 2.2.2
|
||||
'@leafer-ui/display-module': 2.2.2
|
||||
'@leafer-ui/external': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/draw@2.2.2:
|
||||
resolution: {integrity: sha512-8OLZX5QYdNLFjWEneL4BSQDyzsH9BKX+Zofpy/9xV+LrtHjucNqvGq86CKy0lOtkS8UzTB7AFptOrWNL8ZF2dw==}
|
||||
dependencies:
|
||||
'@leafer-ui/decorator': 2.2.2
|
||||
'@leafer-ui/display': 2.2.2
|
||||
'@leafer-ui/display-module': 2.2.2
|
||||
'@leafer-ui/external': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/effect@2.2.2:
|
||||
resolution: {integrity: sha512-eIORcMiRoIydl9NMizkERGFyQAusjQlXkrdDcyJbuLizutUbuxwVs/uwF9zTjZ0ZJY5PaUtGNtLIA8SC4/xxVg==}
|
||||
dependencies:
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/event@2.2.2:
|
||||
resolution: {integrity: sha512-7glUMVmg5rWj64CJ0TQlgICsihK+rnG4L2EMdq6tWUzl5MO6NIQjjrCSWXNcaKHIppqdbC4OacTJacspiTeNsg==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/external@2.2.2:
|
||||
resolution: {integrity: sha512-EkmX3YMxmMPbuHMdjoYRkaf7s/qI4EKc2s2tCbYnANX1mTxTAgcP73Jsp3ToHoKb6N0MrP52mk+U1oMM3kAG9g==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/hit@2.2.2:
|
||||
resolution: {integrity: sha512-cJXJ0Ye4N3rHR6UKm1c3ZP37D7SeYwSkmb+Uijzj3Hv/dVGVQenN4p+OnVi3VPJqpv4TWCI+7HmPlLIJR07rUg==}
|
||||
dependencies:
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/interaction-web@2.2.2:
|
||||
resolution: {integrity: sha512-d/Ep/ldb7K4QtufInsPhCz2d+LNoHxhd/qUibuTMFLqZ8KlnP101eVkxQa7wPZ3KeCBSWTWZEDtT0P4BE/zY0w==}
|
||||
dependencies:
|
||||
'@leafer-ui/core': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/interaction@2.2.2:
|
||||
resolution: {integrity: sha512-ToSAbrCxVZy+a1S5pqmxBDblrP4Hn9fjSDgr9LSD6eQiHg1BpggOzGz4abranrdWerUROz2wdqjQWNQQYVzp4Q==}
|
||||
dependencies:
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/event': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/interface@2.2.2:
|
||||
resolution: {integrity: sha512-KTI6ecOYalIZlpUK0yy2tv/FoFRx8su4Gjgcdh+5YcjlV4Ptu85ZfcSgDIh6zI0prlyRXP39z+3ZV7PA8ckJwA==}
|
||||
dependencies:
|
||||
'@leafer/interface': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/paint@2.2.2:
|
||||
resolution: {integrity: sha512-vAw8o6S+VBxVOsOqCQoTJ7ek0o3AabxEAbys/0t6Vj2vuyRfDZu05Ivsoy72WoWKanuCD0KfhHLtmQ3Mlh8FmA==}
|
||||
dependencies:
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/partner@2.2.2:
|
||||
resolution: {integrity: sha512-grgH3GxCvLTs/KqBr6m519jU34A17lbY3Jnns6cQ7iJI9VxoblZZP30if75OitiNO5HZzAeLBp7GmKQAMPb5TA==}
|
||||
dependencies:
|
||||
'@leafer-ui/color': 2.2.2
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/effect': 2.2.2
|
||||
'@leafer-ui/paint': 2.2.2
|
||||
'@leafer-ui/text': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/render@2.2.2:
|
||||
resolution: {integrity: sha512-ug8SnQM9fvyKbu/X8D77BVVPGXYkrWhOxm59Dl+tqjGTmRmdHQm/T2tFQNcj+ErShKYnxxZdf2afklVeezIfxA==}
|
||||
dependencies:
|
||||
'@leafer-ui/external': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/text@2.2.2:
|
||||
resolution: {integrity: sha512-J1Ocy/nZuLiQWJa//UF3rbZmZRk9jPFSSD0I+U4qedQLPLi/ruY5J9i1yjJao6c3G535wueJlMAtoihQ32Ctsg==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/web@2.2.2:
|
||||
resolution: {integrity: sha512-r56TwRfRIRnMrLrEh8MDmKmPv/Ym87VKAKRmrLN5XUBP0N4oQVEsCKg7YbNs7gAzLLsROue95MEhplkLl8XLOA==}
|
||||
dependencies:
|
||||
'@leafer-in/interface': 2.2.2(@leafer-ui/interface@2.2.2)(@leafer/interface@2.2.2)
|
||||
'@leafer-ui/core': 2.2.2
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/interaction-web': 2.2.2
|
||||
'@leafer-ui/interface': 2.2.2
|
||||
'@leafer-ui/partner': 2.2.2
|
||||
'@leafer/interface': 2.2.2
|
||||
'@leafer/partner': 2.2.2
|
||||
'@leafer/web-core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/canvas-web@2.2.2:
|
||||
resolution: {integrity: sha512-iDy0GmMjvC7xyEOg3X3VJ+2iA0b83VaVvP1bDW9I3UC+GAJgy/PAObMx3LabY/dSLextgkmA7odcWEPgqre2AA==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/canvas@2.2.2:
|
||||
resolution: {integrity: sha512-6FdE1Lbe2ABaBmUzpiren42xY1pA3AtT2lWmND1T1TJw6lXhAHy2ATtngl2OVTg+GgpsFpgg9ultjOsTwlLJ8g==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/file': 2.2.2
|
||||
'@leafer/list': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/path': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/core@2.2.2:
|
||||
resolution: {integrity: sha512-SS/p4sRoxdtFKrgd90GbC8Uaoo+vgQNXbZ97k5keMkQH4Ab+Zb5FIUMAk6+LbzZbBPbf3qRg1wdjwgAT8zMmbA==}
|
||||
dependencies:
|
||||
'@leafer/canvas': 2.2.2
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/decorator': 2.2.2
|
||||
'@leafer/display': 2.2.2
|
||||
'@leafer/display-module': 2.2.2
|
||||
'@leafer/event': 2.2.2
|
||||
'@leafer/file': 2.2.2
|
||||
'@leafer/helper': 2.2.2
|
||||
'@leafer/image': 2.2.2
|
||||
'@leafer/layout': 2.2.2
|
||||
'@leafer/list': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/path': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
'@leafer/task': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/data@2.2.2:
|
||||
resolution: {integrity: sha512-yFVqUC/ZkHeVbmdm6B1WLFNqQcr/j//FdgZhZs93LBtiSMIALznj/Y7WGA+sZ9JCFTZHTip+9rM5pld0ZSndrw==}
|
||||
dev: false
|
||||
|
||||
/@leafer/debug@2.2.2:
|
||||
resolution: {integrity: sha512-7gPabrbUpdFnsn5Z/55P8uIpoXm5g/vrJ/Tqs07X3XTvKs+nx+1eWPQf43IilDTLZWTIRsW5P+9+ryFCdvmx+Q==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/decorator@2.2.2:
|
||||
resolution: {integrity: sha512-LCO63q3apDQrAsodpKHJuAxSqQvFll7fzsZTEkd2J+h0nR8+JMiLAXou3818KAguRZeczR4KW9jwEF4itYxKrA==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/display-module@2.2.2:
|
||||
resolution: {integrity: sha512-azwxaTADm8JSH59o8ipPMLVuQCOX8bVYlNUa8LAfLrW5jq2UzaOr9q2eF2VMADwe4QiGWw+GllDnVZla74LzSA==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/event': 2.2.2
|
||||
'@leafer/helper': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/display@2.2.2:
|
||||
resolution: {integrity: sha512-wv8z+Puh2dMm2szUUcsz7Nw4MDyYj7jwQSfCilcoPKDFxcs/tEKQ+Ew2D6ZZP2ofqKZRkLNEF5ODgZtCOSglug==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/decorator': 2.2.2
|
||||
'@leafer/display-module': 2.2.2
|
||||
'@leafer/event': 2.2.2
|
||||
'@leafer/helper': 2.2.2
|
||||
'@leafer/image': 2.2.2
|
||||
'@leafer/layout': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/event@2.2.2:
|
||||
resolution: {integrity: sha512-EWHdfq1FcbtAb0IXdUGpjhUFuiaM0qGTi6TQhE4irn909+Q7eX0f3Yh5XZjA1uSq3Hw0vKDLIel1WdPC/TYBCQ==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/decorator': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/file@2.2.2:
|
||||
resolution: {integrity: sha512-YbbPjUQIEfy9vxmYIy22Dfp+t6XdYoOWBP6k7M/wsUqduR/u904zFwe9RqqFDXzIM3TKjjsrEBGs9sFNt2YHJg==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
'@leafer/task': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/helper@2.2.2:
|
||||
resolution: {integrity: sha512-y695aHD16J5y4bQ1oLpwn3U2pEwQZwCbVdP2+6ju4ZRfqOd8BDoU6LXTdviQ3gqm0y/6AI8F/XVDTZaL1RDqqw==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/image-web@2.2.2:
|
||||
resolution: {integrity: sha512-mXyFHxm1KboqbV8gjK1bc3lBiQBovPPn64niOENLE8TCq3Po5dp7gc1EoW9qqOZPl9cSgOYN++8nnJbfypp0XA==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/image@2.2.2:
|
||||
resolution: {integrity: sha512-7KI8dws2OLMr/1woZX40uGy19P6iJlAtVUh72/Qb/5excXOtELEvlwAYNCz6iSg7s8C8qpk2l81XookDw7A4Aw==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/file': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
'@leafer/task': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/interface@2.2.2:
|
||||
resolution: {integrity: sha512-7gqUQZD/Q0gj6rWUSYF+hQtSPIaoMzhEQYGDbf/hmZJoluudoeMaBR5ZxW83Y5S8xEaEyEZhCnGxl5ybVM+1CQ==}
|
||||
dev: false
|
||||
|
||||
/@leafer/layout@2.2.2:
|
||||
resolution: {integrity: sha512-hEAfCJBEx9oF2+kN+pdxmerpdhV4Dcr/1B/WoOB/6BW3kLbGSRnnr7zu/TFCtXfYgafzy4YYVhwk5ksUH/N3dw==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/helper': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
'@leafer/platform': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/layouter@2.2.2:
|
||||
resolution: {integrity: sha512-ydJODco5pbBOYY1N3XM37BQJx0OmJicwqyf7IY9wmiaN48lXlgQv4iRsbA+5XQjnKN7Kug/nL49MnvxKzjMt5w==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/list@2.2.2:
|
||||
resolution: {integrity: sha512-QRByA0zxdHQBVyNwgeavOtNsnWmjFxrc50A0bV3hjfPCmHUZbxRBbUW9YbBnujImNdtwkOkoDkLu6BTJDKL2RA==}
|
||||
dev: false
|
||||
|
||||
/@leafer/math@2.2.2:
|
||||
resolution: {integrity: sha512-sg2vS2KIVlAVTKoQlKyJBIexLqjwVtut8iZscGRwEp6Kia190WagGUgwzk9MgPuRac7Chkx0DhK+B2Il0Q5mtQ==}
|
||||
dev: false
|
||||
|
||||
/@leafer/partner@2.2.2:
|
||||
resolution: {integrity: sha512-8n0YT+nJ6rRBk1tx5oAG/S587Bt/qmRFlnELdir8LxpaWfKzrLirsaKA9HSeH5S8szHb1Anfn8L/rGCngZDN3Q==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
'@leafer/layouter': 2.2.2
|
||||
'@leafer/renderer': 2.2.2
|
||||
'@leafer/selector': 2.2.2
|
||||
'@leafer/watcher': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/path@2.2.2:
|
||||
resolution: {integrity: sha512-NTurt6KKPWsqOLRIJhK+jgpldZ44If1Bc7iSC9QByyOd+eTBNIH4QnSi5eJgK2B3jrIBbeIqkDn5F91ePY3laA==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/platform@2.2.2:
|
||||
resolution: {integrity: sha512-uB347iO8TdDgnbsqZ5H5z6PVPrw8VRyQ4FA+KD5P7N94r0JJIG28sNp1Z5UM4LV5bA5wgac57RDi6Tt+RGz0rg==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/debug': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/renderer@2.2.2:
|
||||
resolution: {integrity: sha512-Z9+vPQKf87f1D7QnV3XEtt79/qvL9XgdJEwDeNIhtcIU5ui2uUiKPKj/G3p5Kybi4FQ5xmn1e8vqtWtooWD2DA==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/selector@2.2.2:
|
||||
resolution: {integrity: sha512-e7qQslXAMBEdlb9DawL0jGxM6upIrMStszWQ1RRojtZv8Y5Q0+BgRLr3j3h11CPS5MmMsVOb/ZSqtuFsYt324w==}
|
||||
dependencies:
|
||||
'@leafer/core': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/task@2.2.2:
|
||||
resolution: {integrity: sha512-VQmNSNW7DCjoe+hA6A77T2BGPSqOHfRzt2TDohcuEMnQLiYrM3ez+1PZzcE+JtUE2mLwnJdEjVNjh3QByncXrg==}
|
||||
dependencies:
|
||||
'@leafer/debug': 2.2.2
|
||||
'@leafer/math': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/watcher@2.2.2:
|
||||
resolution: {integrity: sha512-NrAnPRgeh8bN6b2LilBVjcRsv0sZh3pZCkSMm/QWIqXxJ9pYdw8uTSdxxEzdAXyA7b29HLa+IlCziwgnKPz1Og==}
|
||||
dependencies:
|
||||
'@leafer/data': 2.2.2
|
||||
'@leafer/event': 2.2.2
|
||||
'@leafer/list': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer/web-core@2.2.2:
|
||||
resolution: {integrity: sha512-u/jT9qFa5Cf5ajq+SHdi8a0CnX4VXZ12+CtcCNbwgp89UsWxR1zt8dnE4iFnPD6+r4Tv9XIpCQja2XgJ86VPaw==}
|
||||
dependencies:
|
||||
'@leafer/canvas-web': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
'@leafer/image-web': 2.2.2
|
||||
'@leafer/interface': 2.2.2
|
||||
'@leafer/partner': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1):
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
requiresBuild: true
|
||||
@@ -1328,6 +1717,21 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/leafer-ui@2.2.2:
|
||||
resolution: {integrity: sha512-hncxe8IwNL+7gpG3QrxSKtiOQtujSvVE40I20+MK0iFudkO0NBwAaRHf81K8Ys+fCt2XruRATlF4tMkCJ3AILA==}
|
||||
dependencies:
|
||||
'@leafer-in/interface': 2.2.2(@leafer-ui/interface@2.2.2)(@leafer/interface@2.2.2)
|
||||
'@leafer-ui/core': 2.2.2
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/interaction-web': 2.2.2
|
||||
'@leafer-ui/interface': 2.2.2
|
||||
'@leafer-ui/partner': 2.2.2
|
||||
'@leafer-ui/web': 2.2.2
|
||||
'@leafer/core': 2.2.2
|
||||
'@leafer/interface': 2.2.2
|
||||
'@leafer/partner': 2.2.2
|
||||
dev: false
|
||||
|
||||
/lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
export type NormalizedCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type NormalizedPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type PixelImageData = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
const defaultEdgeTolerance = 46;
|
||||
const defaultSeedTolerance = 108;
|
||||
|
||||
export function findColorBoundaryCrop(image: PixelImageData, point: NormalizedPoint): NormalizedCropRect {
|
||||
const width = Math.max(1, Math.floor(image.width));
|
||||
const height = Math.max(1, Math.floor(image.height));
|
||||
if (image.data.length < width * height * 4) return fullCrop();
|
||||
|
||||
const seedX = clamp(Math.floor(clamp(point.x, 0, 1) * width), 0, width - 1);
|
||||
const seedY = clamp(Math.floor(clamp(point.y, 0, 1) * height), 0, height - 1);
|
||||
const seedIndex = seedY * width + seedX;
|
||||
const visited = new Uint8Array(width * height);
|
||||
const queue = new Int32Array(width * height);
|
||||
const seedOffset = seedIndex * 4;
|
||||
const neighborOffsets = [-1, 1, -width, width];
|
||||
let head = 0;
|
||||
let tail = 1;
|
||||
let count = 0;
|
||||
let minX = seedX;
|
||||
let minY = seedY;
|
||||
let maxX = seedX;
|
||||
let maxY = seedY;
|
||||
queue[0] = seedIndex;
|
||||
visited[seedIndex] = 1;
|
||||
|
||||
while (head < tail) {
|
||||
const index = queue[head];
|
||||
head += 1;
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
const currentOffset = index * 4;
|
||||
count += 1;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
|
||||
for (const offset of neighborOffsets) {
|
||||
const neighbor = index + offset;
|
||||
if (neighbor < 0 || neighbor >= width * height || visited[neighbor]) continue;
|
||||
const neighborX = neighbor % width;
|
||||
if (Math.abs(neighborX - x) > 1) continue;
|
||||
if (!sameColorRegion(image.data, seedOffset, currentOffset, neighbor * 4)) continue;
|
||||
visited[neighbor] = 1;
|
||||
queue[tail] = neighbor;
|
||||
tail += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (count >= width * height * 0.96) return fullCrop();
|
||||
return {
|
||||
x: precise(minX / width),
|
||||
y: precise(minY / height),
|
||||
width: precise((maxX - minX + 1) / width),
|
||||
height: precise((maxY - minY + 1) / height)
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectColorBoundaryCrop(source: string, point: NormalizedPoint, limit: NormalizedCropRect): Promise<NormalizedCropRect> {
|
||||
const safeLimit = clampCrop(limit);
|
||||
const image = await loadImageSource(source);
|
||||
const naturalWidth = Math.max(1, image.naturalWidth || image.width);
|
||||
const naturalHeight = Math.max(1, image.naturalHeight || image.height);
|
||||
const sourceX = safeLimit.x * naturalWidth;
|
||||
const sourceY = safeLimit.y * naturalHeight;
|
||||
const sourceWidth = Math.max(1, safeLimit.width * naturalWidth);
|
||||
const sourceHeight = Math.max(1, safeLimit.height * naturalHeight);
|
||||
const scale = Math.min(1, 640 / Math.max(sourceWidth, sourceHeight));
|
||||
const width = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const height = Math.max(1, Math.round(sourceHeight * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Failed to create crop analysis canvas");
|
||||
context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, width, height);
|
||||
const localPoint = {
|
||||
x: clamp((point.x - safeLimit.x) / Math.max(safeLimit.width, Number.EPSILON), 0, 1),
|
||||
y: clamp((point.y - safeLimit.y) / Math.max(safeLimit.height, Number.EPSILON), 0, 1)
|
||||
};
|
||||
const localCrop = findColorBoundaryCrop(context.getImageData(0, 0, width, height), localPoint);
|
||||
return mapColorBoundaryCropToLimit(localCrop, safeLimit);
|
||||
}
|
||||
|
||||
export function mapColorBoundaryCropToLimit(crop: NormalizedCropRect, limit: NormalizedCropRect): NormalizedCropRect {
|
||||
const safeLimit = clampCrop(limit);
|
||||
return ensureMinimumCrop(
|
||||
{
|
||||
x: safeLimit.x + crop.x * safeLimit.width,
|
||||
y: safeLimit.y + crop.y * safeLimit.height,
|
||||
width: crop.width * safeLimit.width,
|
||||
height: crop.height * safeLimit.height
|
||||
},
|
||||
safeLimit
|
||||
);
|
||||
}
|
||||
|
||||
function sameColorRegion(data: Uint8ClampedArray, seed: number, current: number, candidate: number) {
|
||||
if (Math.abs(data[seed + 3] - data[candidate + 3]) > 72) return false;
|
||||
return colorDistance(data, seed, candidate) <= defaultSeedTolerance && colorDistance(data, current, candidate) <= defaultEdgeTolerance;
|
||||
}
|
||||
|
||||
function colorDistance(data: Uint8ClampedArray, left: number, right: number) {
|
||||
const red = data[left] - data[right];
|
||||
const green = data[left + 1] - data[right + 1];
|
||||
const blue = data[left + 2] - data[right + 2];
|
||||
return Math.sqrt(red * red * 0.3 + green * green * 0.59 + blue * blue * 0.11);
|
||||
}
|
||||
|
||||
async function loadImageSource(source: string) {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`Failed to load crop source: ${response.status}`);
|
||||
const objectUrl = URL.createObjectURL(await response.blob());
|
||||
try {
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Failed to decode crop source"));
|
||||
image.src = objectUrl;
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMinimumCrop(crop: NormalizedCropRect, limit: NormalizedCropRect) {
|
||||
const width = Math.min(limit.width, Math.max(crop.width, Math.min(0.02, limit.width)));
|
||||
const height = Math.min(limit.height, Math.max(crop.height, Math.min(0.02, limit.height)));
|
||||
const centerX = crop.x + crop.width / 2;
|
||||
const centerY = crop.y + crop.height / 2;
|
||||
return {
|
||||
x: precise(clamp(centerX - width / 2, limit.x, limit.x + limit.width - width)),
|
||||
y: precise(clamp(centerY - height / 2, limit.y, limit.y + limit.height - height)),
|
||||
width: precise(width),
|
||||
height: precise(height)
|
||||
};
|
||||
}
|
||||
|
||||
function clampCrop(crop: NormalizedCropRect) {
|
||||
const x = clamp(crop.x, 0, 1);
|
||||
const y = clamp(crop.y, 0, 1);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: clamp(crop.width, Number.EPSILON, 1 - x),
|
||||
height: clamp(crop.height, Number.EPSILON, 1 - y)
|
||||
};
|
||||
}
|
||||
|
||||
function fullCrop(): NormalizedCropRect {
|
||||
return { x: 0, y: 0, width: 1, height: 1 };
|
||||
}
|
||||
|
||||
function precise(value: number) {
|
||||
return Number(value.toFixed(12));
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -24,6 +24,11 @@ type StageInsets = {
|
||||
left: number;
|
||||
};
|
||||
|
||||
type ScreenPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const fullSelection: CropSelection = { x: 0, y: 0, width: 1, height: 1 };
|
||||
const defaultStageInsets: StageInsets = { top: 24, right: 24, bottom: 72, left: 24 };
|
||||
|
||||
@@ -55,6 +60,23 @@ export function cropSelectionScreenBounds(bounds: ScreenBounds, selection: CropS
|
||||
};
|
||||
}
|
||||
|
||||
export function cropSelectionFromScreenDrag(bounds: ScreenBounds, limit: CropSelection, start: ScreenPoint, end: ScreenPoint): CropSelection {
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return fullSelection;
|
||||
const limitBounds = cropSelectionScreenBounds(bounds, limit);
|
||||
const limitRight = limitBounds.left + limitBounds.width;
|
||||
const limitBottom = limitBounds.top + limitBounds.height;
|
||||
const startX = clamp(start.x, limitBounds.left, limitRight);
|
||||
const startY = clamp(start.y, limitBounds.top, limitBottom);
|
||||
const endX = clamp(end.x, limitBounds.left, limitRight);
|
||||
const endY = clamp(end.y, limitBounds.top, limitBottom);
|
||||
return {
|
||||
x: normalizedNumber((Math.min(startX, endX) - bounds.left) / bounds.width),
|
||||
y: normalizedNumber((Math.min(startY, endY) - bounds.top) / bounds.height),
|
||||
width: normalizedNumber(Math.abs(endX - startX) / bounds.width),
|
||||
height: normalizedNumber(Math.abs(endY - startY) / bounds.height)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedNumber(value: number) {
|
||||
return Number(clamp(value, 0, 1).toFixed(12));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { CanvasNode, CanvasViewport } from "@/domain/design";
|
||||
|
||||
type LeaferModule = typeof import("leafer-ui");
|
||||
type LeaferRoot = import("leafer-ui").Leafer;
|
||||
type LeaferGroup = import("leafer-ui").Group;
|
||||
type LeaferVisual = import("leafer-ui").Image | import("leafer-ui").Rect;
|
||||
|
||||
export type LeaferVisualDescriptor =
|
||||
| {
|
||||
kind: "image";
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
visible: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "frame";
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
dashPattern?: number[];
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
type LeaferVisualEntry = {
|
||||
leaf: LeaferVisual;
|
||||
kind: LeaferVisualDescriptor["kind"];
|
||||
signature: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const unsupportedImageRoles = new Set(["image-generator", "merge-layer", "mockup-print", "pen-stroke"]);
|
||||
|
||||
export function leaferVisualDescriptor(node: CanvasNode, hidden = false): LeaferVisualDescriptor | null {
|
||||
const transform = {
|
||||
opacity: node.opacity && node.opacity > 0 ? node.opacity : 1,
|
||||
rotation: node.rotation ?? 0,
|
||||
scaleX: node.flipX ? -1 : 1,
|
||||
scaleY: node.flipY ? -1 : 1,
|
||||
visible: !hidden
|
||||
};
|
||||
|
||||
if (node.type === "frame" && node.layerRole === "manual-frame") {
|
||||
return {
|
||||
kind: "frame",
|
||||
id: node.id,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
fill: frameFill(node.fillColor || "#ffffff", node.opacity),
|
||||
stroke: node.strokeColor || "#2f80ff",
|
||||
strokeWidth: node.strokeWidth || 1,
|
||||
dashPattern: frameDashPattern(node.strokeStyle),
|
||||
...transform,
|
||||
opacity: 1
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
node.type !== "image" ||
|
||||
node.status !== "success" ||
|
||||
!canvasImageSource(node.content) ||
|
||||
unsupportedImageRoles.has(node.layerRole ?? "") ||
|
||||
hasImageAdjustments(node.imageAdjustments) ||
|
||||
(isVectorSource(node) && !node.renderContent?.trim())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "image",
|
||||
id: node.id,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
url: node.renderContent?.trim() || node.content,
|
||||
...transform
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLeaferCanvasRenderer(mount: HTMLElement) {
|
||||
const module = await import("leafer-ui");
|
||||
return new LeaferCanvasRenderer(mount, module);
|
||||
}
|
||||
|
||||
export function leaferViewSize(view: Pick<HTMLElement, "clientWidth" | "clientHeight">, fallback: { width: number; height: number }) {
|
||||
return {
|
||||
width: view.clientWidth || fallback.width,
|
||||
height: view.clientHeight || fallback.height
|
||||
};
|
||||
}
|
||||
|
||||
class LeaferCanvasRenderer {
|
||||
private readonly root: LeaferRoot;
|
||||
private readonly world: LeaferGroup;
|
||||
private readonly module: LeaferModule;
|
||||
private readonly visuals = new Map<string, LeaferVisualEntry>();
|
||||
private readonly readyIds = new Set<string>();
|
||||
private overlayRoot: HTMLElement | null = null;
|
||||
|
||||
constructor(mount: HTMLElement, module: LeaferModule) {
|
||||
this.module = module;
|
||||
this.root = new module.Leafer({
|
||||
view: mount,
|
||||
fill: "transparent",
|
||||
hittable: false,
|
||||
wheel: { disabled: true },
|
||||
pointer: { preventDefault: false },
|
||||
touch: { preventDefault: false }
|
||||
});
|
||||
this.world = new module.Group();
|
||||
this.root.add(this.world);
|
||||
}
|
||||
|
||||
sync(nodes: CanvasNode[], hiddenNodeIds: Set<string>, overlayRoot: HTMLElement | null) {
|
||||
this.overlayRoot = overlayRoot;
|
||||
const descriptors = nodes.flatMap((node) => {
|
||||
const descriptor = leaferVisualDescriptor(node, hiddenNodeIds.has(node.id));
|
||||
return descriptor ? [descriptor] : [];
|
||||
});
|
||||
const activeIds = new Set(descriptors.map((descriptor) => descriptor.id));
|
||||
|
||||
for (const [id, entry] of this.visuals) {
|
||||
if (activeIds.has(id)) continue;
|
||||
this.world.remove(entry.leaf, true);
|
||||
this.visuals.delete(id);
|
||||
this.readyIds.delete(id);
|
||||
this.setDOMReady(id, false);
|
||||
}
|
||||
|
||||
descriptors.forEach((descriptor, index) => {
|
||||
const signature = JSON.stringify(descriptor);
|
||||
let entry = this.visuals.get(descriptor.id);
|
||||
if (!entry || entry.kind !== descriptor.kind) {
|
||||
if (entry) this.world.remove(entry.leaf, true);
|
||||
entry = {
|
||||
kind: descriptor.kind,
|
||||
leaf: this.createVisual(descriptor),
|
||||
signature: "",
|
||||
width: descriptor.width,
|
||||
height: descriptor.height
|
||||
};
|
||||
this.visuals.set(descriptor.id, entry);
|
||||
this.world.add(entry.leaf, index);
|
||||
}
|
||||
|
||||
if (entry.signature !== signature) {
|
||||
if (descriptor.kind === "image" && entry.signature && descriptor.url !== (entry.leaf as import("leafer-ui").Image).url) {
|
||||
this.readyIds.delete(descriptor.id);
|
||||
this.setDOMReady(descriptor.id, false);
|
||||
}
|
||||
entry.leaf.set(visualInput(descriptor));
|
||||
entry.signature = signature;
|
||||
entry.width = descriptor.width;
|
||||
entry.height = descriptor.height;
|
||||
}
|
||||
entry.leaf.dropTo(this.world, index);
|
||||
});
|
||||
|
||||
this.syncDOMReadyState();
|
||||
}
|
||||
|
||||
setViewport(viewport: CanvasViewport) {
|
||||
this.world.set({ x: viewport.x, y: viewport.y, scaleX: viewport.k, scaleY: viewport.k });
|
||||
}
|
||||
|
||||
setNodePosition(id: string, x: number, y: number) {
|
||||
const entry = this.visuals.get(id);
|
||||
if (!entry) return;
|
||||
entry.leaf.set({ x: x + entry.width / 2, y: y + entry.height / 2 }, "temp");
|
||||
}
|
||||
|
||||
resize(width: number, height: number) {
|
||||
if (width <= 0 || height <= 0) return;
|
||||
this.root.resize({ width, height, pixelRatio: window.devicePixelRatio || 1 });
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.readyIds.forEach((id) => this.setDOMReady(id, false));
|
||||
this.visuals.clear();
|
||||
this.readyIds.clear();
|
||||
this.root.destroy();
|
||||
}
|
||||
|
||||
private createVisual(descriptor: LeaferVisualDescriptor): LeaferVisual {
|
||||
if (descriptor.kind === "frame") {
|
||||
const rect = new this.module.Rect(visualInput(descriptor));
|
||||
this.readyIds.add(descriptor.id);
|
||||
return rect;
|
||||
}
|
||||
|
||||
const image = new this.module.Image(visualInput(descriptor));
|
||||
image.on(this.module.ImageEvent.LOADED, () => {
|
||||
this.readyIds.add(descriptor.id);
|
||||
this.setDOMReady(descriptor.id, true);
|
||||
});
|
||||
image.on(this.module.ImageEvent.ERROR, () => {
|
||||
this.readyIds.delete(descriptor.id);
|
||||
this.setDOMReady(descriptor.id, false);
|
||||
});
|
||||
if (image.ready) this.readyIds.add(descriptor.id);
|
||||
return image;
|
||||
}
|
||||
|
||||
private syncDOMReadyState() {
|
||||
this.visuals.forEach((_entry, id) => this.setDOMReady(id, this.readyIds.has(id)));
|
||||
}
|
||||
|
||||
private setDOMReady(id: string, ready: boolean) {
|
||||
const element = this.overlayRoot?.querySelector<HTMLElement>(`[data-canvas-node-id="${escapeSelector(id)}"]`);
|
||||
element?.classList.toggle("leafer-visual-ready", ready);
|
||||
}
|
||||
}
|
||||
|
||||
export function visualInput(descriptor: LeaferVisualDescriptor) {
|
||||
const base = {
|
||||
id: descriptor.id,
|
||||
x: descriptor.x + descriptor.width / 2,
|
||||
y: descriptor.y + descriptor.height / 2,
|
||||
width: descriptor.width,
|
||||
height: descriptor.height,
|
||||
opacity: descriptor.opacity,
|
||||
rotation: descriptor.rotation,
|
||||
scaleX: descriptor.scaleX,
|
||||
scaleY: descriptor.scaleY,
|
||||
around: "center" as const,
|
||||
visible: descriptor.visible,
|
||||
hittable: false
|
||||
};
|
||||
if (descriptor.kind === "image") return { ...base, url: descriptor.url };
|
||||
return {
|
||||
...base,
|
||||
fill: descriptor.fill,
|
||||
stroke: descriptor.stroke,
|
||||
strokeWidth: descriptor.strokeWidth,
|
||||
dashPattern: descriptor.dashPattern
|
||||
};
|
||||
}
|
||||
|
||||
function frameDashPattern(style?: string) {
|
||||
if (style === "dashed") return [12, 8];
|
||||
if (style === "dotted") return [2, 6];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function frameFill(color: string, opacity?: number) {
|
||||
if (color === "transparent" || opacity === undefined || opacity >= 1) return color;
|
||||
const match = color.match(/^#([0-9a-f]{6})$/i);
|
||||
if (!match) return color;
|
||||
const value = match[1];
|
||||
return `rgba(${Number.parseInt(value.slice(0, 2), 16)}, ${Number.parseInt(value.slice(2, 4), 16)}, ${Number.parseInt(value.slice(4, 6), 16)}, ${Math.max(0, opacity)})`;
|
||||
}
|
||||
|
||||
function canvasImageSource(value: string) {
|
||||
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
|
||||
}
|
||||
|
||||
function isVectorSource(node: CanvasNode) {
|
||||
if (node.layerRole === "vectorized-image" || node.layerRole === "vector-image" || node.layerRole === "cad-vector") return true;
|
||||
return [node.title, node.content].some((value) => /(?:\.svg(?:$|[?#])|^data:image\/svg\+xml)/i.test(value.trim()));
|
||||
}
|
||||
|
||||
function hasImageAdjustments(value?: string) {
|
||||
if (!value?.trim()) return false;
|
||||
try {
|
||||
return Object.values(JSON.parse(value) as Record<string, unknown>).some((item) => typeof item === "number" && item !== 0);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeSelector(value: string) {
|
||||
return typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CanvasTool = "select" | "pan" | "pin" | "image" | "frame" | "shape" | "pen" | "text";
|
||||
export type CanvasTool = "select" | "pan" | "pin" | "image" | "frame" | "shape" | "pen" | "text" | "crop";
|
||||
|
||||
export type NodeScreenBounds = {
|
||||
left: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Trash2, Type, User, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { openBrandKit, openHome } from "@/application/route";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -40,8 +40,9 @@ import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, s
|
||||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
||||
import { cadImportMessageKey, isCadFile, isCanvasImportFile, prepareCanvasImportAsset, type PreparedCanvasImportAsset } from "@/ui/lib/cadImport";
|
||||
import { cadSemanticContextFromSvgText } from "@/ui/lib/cadSemantics";
|
||||
import { uploadPreparedCanvasImportAsset } from "@/ui/lib/canvasImportUpload";
|
||||
import { isSvgSource, isVectorImageNode } from "@/ui/lib/vectorImage";
|
||||
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
||||
import { canvasImageUrl, canvasNodeRenderContent } from "@/ui/lib/imageDelivery";
|
||||
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
|
||||
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
||||
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
|
||||
@@ -51,7 +52,9 @@ import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as auto
|
||||
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
|
||||
import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop";
|
||||
import { installCanvasWheelListener } from "./canvas/canvasWheel";
|
||||
import { visibleCropSelectionForBounds } from "./canvas/cropViewport";
|
||||
import { detectColorBoundaryCrop } from "./canvas/colorBoundaryCrop";
|
||||
import { createLeaferCanvasRenderer, leaferViewSize } from "./canvas/leaferRenderer";
|
||||
import { cropSelectionFromScreenDrag, cropSelectionScreenBounds, visibleCropSelectionForBounds } from "./canvas/cropViewport";
|
||||
import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
|
||||
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
|
||||
import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
|
||||
@@ -115,6 +118,34 @@ const canvasWheelGuardSelector = [
|
||||
".account-management-dialog"
|
||||
].join(",");
|
||||
|
||||
function revokePreparedUploadUrls(item: { sourcePreviewUrl: string; renderPreviewUrl?: string }) {
|
||||
URL.revokeObjectURL(item.sourcePreviewUrl);
|
||||
if (item.renderPreviewUrl) URL.revokeObjectURL(item.renderPreviewUrl);
|
||||
}
|
||||
|
||||
function renderAlignmentGuidesImperatively(layer: HTMLElement | null, guides: AlignmentGuide[]) {
|
||||
if (!layer) return;
|
||||
if (guides.length === 0) {
|
||||
layer.replaceChildren();
|
||||
return;
|
||||
}
|
||||
while (layer.childElementCount < guides.length) layer.append(document.createElement("span"));
|
||||
Array.from(layer.children).forEach((child, index) => {
|
||||
const element = child as HTMLElement;
|
||||
const guide = guides[index];
|
||||
if (!guide) {
|
||||
element.style.display = "none";
|
||||
return;
|
||||
}
|
||||
element.className = `canvas-alignment-guide ${guide.orientation}`;
|
||||
element.style.display = "block";
|
||||
element.style.left = `${guide.left}px`;
|
||||
element.style.top = `${guide.top}px`;
|
||||
element.style.width = guide.orientation === "horizontal" ? `${guide.length}px` : "";
|
||||
element.style.height = guide.orientation === "vertical" ? `${guide.length}px` : "";
|
||||
});
|
||||
}
|
||||
|
||||
type LocalizedText = Record<Locale, string>;
|
||||
type HomeImageModel = {
|
||||
id: string;
|
||||
@@ -564,6 +595,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const { showToast } = useGlobalToast();
|
||||
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasWorldRef = useRef<HTMLDivElement | null>(null);
|
||||
const leaferMountRef = useRef<HTMLDivElement | null>(null);
|
||||
const leaferRendererRef = useRef<Awaited<ReturnType<typeof createLeaferCanvasRenderer>> | null>(null);
|
||||
const imperativeAlignmentGuideRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewportCommitTimerRef = useRef<number | null>(null);
|
||||
const canvasAssetInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const agentComposerRef = useRef<PromptComposerHandle | null>(null);
|
||||
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -582,6 +618,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const quickEditFocusRef = useRef<{ viewport: CanvasViewport; dirty: boolean } | null>(null);
|
||||
const webSearchHydratedRef = useRef(false);
|
||||
const pendingWorkspaceCropRef = useRef<{ nodeId: string; selection: NormalizedRect } | null>(null);
|
||||
const workspaceCropDetectionRequestRef = useRef(0);
|
||||
const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument();
|
||||
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
||||
@@ -662,6 +699,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const [moveRecognitionLoading, setMoveRecognitionLoading] = useState(false);
|
||||
const [cropPrompt, setCropPrompt] = useState("");
|
||||
const [cropSelection, setCropSelection] = useState<NormalizedRect | null>(null);
|
||||
const [workspaceCropTargetId, setWorkspaceCropTargetId] = useState<string | null>(null);
|
||||
const [workspaceCropDraftBounds, setWorkspaceCropDraftBounds] = useState<NodeScreenBounds | null>(null);
|
||||
const [workspaceCropDetecting, setWorkspaceCropDetecting] = useState(false);
|
||||
const [adjustOptions, setAdjustOptions] = useState<AdjustOptions>(defaultImageAdjustments);
|
||||
const [expandOptions, setExpandOptions] = useState<ExpandOptions>({
|
||||
ratio: "3:4",
|
||||
@@ -1028,6 +1068,40 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const visibleCanvasNodes = useMemo(() => getVisibleCanvasNodes(nodes, hiddenNodeIds), [hiddenNodeIds, nodes]);
|
||||
const renderOrderedCanvasNodes = useMemo(() => orderedCanvasNodesForRender(nodes), [nodes]);
|
||||
const renderedCanvasNodes = useMemo(() => renderOrderedCanvasNodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, renderOrderedCanvasNodes, selectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = leaferMountRef.current;
|
||||
if (!mount || !project?.id) return;
|
||||
let disposed = false;
|
||||
void createLeaferCanvasRenderer(mount).then((renderer) => {
|
||||
if (disposed) {
|
||||
renderer.destroy();
|
||||
return;
|
||||
}
|
||||
leaferRendererRef.current = renderer;
|
||||
const viewSize = leaferViewSize(mount, canvasStageSize);
|
||||
renderer.resize(viewSize.width, viewSize.height);
|
||||
renderer.setViewport(viewportRef.current);
|
||||
renderer.sync(renderedCanvasNodes, hiddenNodeIds, canvasWorldRef.current);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
leaferRendererRef.current?.destroy();
|
||||
leaferRendererRef.current = null;
|
||||
};
|
||||
}, [project?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
leaferRendererRef.current?.sync(renderedCanvasNodes, hiddenNodeIds, canvasWorldRef.current);
|
||||
}, [hiddenNodeIds, renderedCanvasNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
leaferRendererRef.current?.setViewport(viewport);
|
||||
}, [viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
leaferRendererRef.current?.resize(canvasStageSize.width, canvasStageSize.height);
|
||||
}, [canvasStageSize.height, canvasStageSize.width]);
|
||||
const imageModels = useMemo(() => getImageModels(locale), [locale]);
|
||||
const agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]);
|
||||
const filteredAgentHistoryThreads = useMemo(() => {
|
||||
@@ -1256,6 +1330,43 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [imageActionMode, locale, moveSelection, selectedImageNode?.content]);
|
||||
|
||||
const applyViewportImperatively = useCallback(
|
||||
(nextViewport: CanvasViewport) => {
|
||||
viewportRef.current = nextViewport;
|
||||
if (canvasWorldRef.current) {
|
||||
canvasWorldRef.current.style.transform = `translate(${nextViewport.x}px, ${nextViewport.y}px) scale(${nextViewport.k})`;
|
||||
}
|
||||
leaferRendererRef.current?.setViewport(nextViewport);
|
||||
const grid = containerRef.current?.querySelector<HTMLElement>(".canvas-grid");
|
||||
if (grid) {
|
||||
const gridSize = 48 * nextViewport.k;
|
||||
grid.style.backgroundSize = `${gridSize}px ${gridSize}px`;
|
||||
grid.style.backgroundPosition = `${nextViewport.x % gridSize}px ${nextViewport.y % gridSize}px`;
|
||||
}
|
||||
},
|
||||
[viewportRef]
|
||||
);
|
||||
|
||||
const commitImperativeViewport = useCallback(() => {
|
||||
viewportCommitTimerRef.current = null;
|
||||
const nextViewport = { ...viewportRef.current };
|
||||
containerRef.current?.classList.remove("is-viewport-transforming");
|
||||
startTransition(() => setViewport(nextViewport));
|
||||
setDirty(true);
|
||||
}, [setViewport, viewportRef]);
|
||||
|
||||
const scheduleViewportCommit = useCallback(() => {
|
||||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||||
viewportCommitTimerRef.current = window.setTimeout(commitImperativeViewport, 90);
|
||||
}, [commitImperativeViewport]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent) => {
|
||||
if (shouldBypassCanvasWheel(event.target)) {
|
||||
return;
|
||||
@@ -1270,13 +1381,15 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const mouseY = event.clientY - rect.top;
|
||||
const worldX = (mouseX - currentViewport.x) / currentViewport.k;
|
||||
const worldY = (mouseY - currentViewport.y) / currentViewport.k;
|
||||
setViewport({
|
||||
const nextViewport = {
|
||||
x: mouseX - worldX * nextScale,
|
||||
y: mouseY - worldY * nextScale,
|
||||
k: nextScale
|
||||
});
|
||||
setDirty(true);
|
||||
}, [setViewport, viewportRef]);
|
||||
};
|
||||
containerRef.current?.classList.add("is-viewport-transforming");
|
||||
applyViewportImperatively(nextViewport);
|
||||
scheduleViewportCommit();
|
||||
}, [applyViewportImperatively, scheduleViewportCommit, viewportRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = containerRef.current;
|
||||
@@ -1296,7 +1409,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
y: centerY - worldY * nextScale,
|
||||
k: nextScale
|
||||
};
|
||||
viewportRef.current = nextViewport;
|
||||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||||
viewportCommitTimerRef.current = null;
|
||||
applyViewportImperatively(nextViewport);
|
||||
containerRef.current?.classList.remove("is-viewport-transforming");
|
||||
setViewport(nextViewport);
|
||||
setDirty(true);
|
||||
};
|
||||
@@ -1314,7 +1430,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
y: canvasStageSize.height / 2 - (bounds.y + bounds.height / 2) * nextScale,
|
||||
k: nextScale
|
||||
};
|
||||
viewportRef.current = nextViewport;
|
||||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||||
viewportCommitTimerRef.current = null;
|
||||
applyViewportImperatively(nextViewport);
|
||||
containerRef.current?.classList.remove("is-viewport-transforming");
|
||||
setViewport(nextViewport);
|
||||
setDirty(true);
|
||||
};
|
||||
@@ -1328,20 +1447,20 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const startCanvasPan = (event: React.PointerEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
setCanvasPanning(true);
|
||||
const currentViewport = viewportRef.current;
|
||||
const start = {
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
x: viewport.x,
|
||||
y: viewport.y
|
||||
x: currentViewport.x,
|
||||
y: currentViewport.y
|
||||
};
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
setViewport((current) => ({
|
||||
...current,
|
||||
applyViewportImperatively({
|
||||
...viewportRef.current,
|
||||
x: start.x + moveEvent.clientX - start.pointerX,
|
||||
y: start.y + moveEvent.clientY - start.pointerY
|
||||
}));
|
||||
setDirty(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
@@ -1349,6 +1468,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleUp);
|
||||
setCanvasPanning(false);
|
||||
startTransition(() => setViewport({ ...viewportRef.current }));
|
||||
setDirty(true);
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
@@ -1517,6 +1638,83 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
[addShapeFromDraft, selectOnlyNode, viewportRef]
|
||||
);
|
||||
|
||||
const finishWorkspaceCropPreselection = (targetNode: CanvasNode, selection: NormalizedRect) => {
|
||||
workspaceCropDetectionRequestRef.current += 1;
|
||||
setWorkspaceCropDetecting(false);
|
||||
setWorkspaceCropDraftBounds(null);
|
||||
setWorkspaceCropTargetId(null);
|
||||
setActiveTool("select");
|
||||
setMode("select");
|
||||
if (selectedId !== targetNode.id || selectedIds.size !== 1) {
|
||||
pendingWorkspaceCropRef.current = { nodeId: targetNode.id, selection };
|
||||
selectOnlyNode(targetNode.id);
|
||||
return;
|
||||
}
|
||||
setCropSelection(selection);
|
||||
setImageActionMode("crop");
|
||||
};
|
||||
|
||||
const startWorkspaceCropPreselection = (event: React.PointerEvent<HTMLDivElement>, targetNode: CanvasNode) => {
|
||||
const stageRect = containerRef.current?.getBoundingClientRect();
|
||||
if (!stageRect) return;
|
||||
event.preventDefault();
|
||||
const bounds = canvasNodeScreenBounds(targetNode, viewportRef.current);
|
||||
const visibleSelection = visibleCropSelectionForBounds(bounds, { width: stageRect.width, height: stageRect.height });
|
||||
const startClient = { x: event.clientX, y: event.clientY };
|
||||
const start = { x: event.clientX - stageRect.left, y: event.clientY - stageRect.top };
|
||||
let dragged = false;
|
||||
|
||||
const selectionAt = (clientX: number, clientY: number) =>
|
||||
cropSelectionFromScreenDrag(bounds, visibleSelection, start, {
|
||||
x: clientX - stageRect.left,
|
||||
y: clientY - stageRect.top
|
||||
});
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
const movement = Math.hypot(moveEvent.clientX - startClient.x, moveEvent.clientY - startClient.y);
|
||||
if (!dragged && movement < 4) return;
|
||||
dragged = true;
|
||||
setWorkspaceCropDraftBounds(cropSelectionScreenBounds(bounds, selectionAt(moveEvent.clientX, moveEvent.clientY)));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleCancel);
|
||||
setWorkspaceCropDraftBounds(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const handleUp = (upEvent: PointerEvent) => {
|
||||
cleanup();
|
||||
if (dragged) {
|
||||
finishWorkspaceCropPreselection(targetNode, clampCropSelection(selectionAt(upEvent.clientX, upEvent.clientY)));
|
||||
return;
|
||||
}
|
||||
|
||||
const point = {
|
||||
x: clamp((start.x - bounds.left) / Math.max(bounds.width, 1), 0, 1),
|
||||
y: clamp((start.y - bounds.top) / Math.max(bounds.height, 1), 0, 1)
|
||||
};
|
||||
const requestId = workspaceCropDetectionRequestRef.current + 1;
|
||||
workspaceCropDetectionRequestRef.current = requestId;
|
||||
setWorkspaceCropDetecting(true);
|
||||
void detectColorBoundaryCrop(canvasImageUrl(canvasNodeRenderContent(targetNode)), point, visibleSelection)
|
||||
.catch(() => visibleSelection)
|
||||
.then((selection) => {
|
||||
if (workspaceCropDetectionRequestRef.current !== requestId) return;
|
||||
finishWorkspaceCropPreselection(targetNode, clampCropSelection(selection));
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp);
|
||||
window.addEventListener("pointercancel", handleCancel);
|
||||
};
|
||||
|
||||
const handleBackgroundPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -1590,13 +1788,18 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool === "crop") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const start = {
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
stageLeft: containerRef.current?.getBoundingClientRect().left ?? 0,
|
||||
stageTop: containerRef.current?.getBoundingClientRect().top ?? 0,
|
||||
x: viewport.x,
|
||||
y: viewport.y
|
||||
x: viewportRef.current.x,
|
||||
y: viewportRef.current.y
|
||||
};
|
||||
|
||||
if (mode === "select") {
|
||||
@@ -1641,7 +1844,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
pointerY: event.clientY,
|
||||
x: sourceNode.x,
|
||||
y: sourceNode.y,
|
||||
scale: viewport.k
|
||||
scale: viewportRef.current.k
|
||||
};
|
||||
let dragged = false;
|
||||
|
||||
@@ -1692,6 +1895,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const handleNodePointerDown = (event: React.PointerEvent<HTMLDivElement>, node: CanvasNode) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
if (activeTool === "crop") {
|
||||
if (!workspaceCropDetecting && workspaceCropTargetId === node.id && isRealCanvasImageNode(node) && !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)) {
|
||||
startWorkspaceCropPreselection(event, node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
imageActionMode === "mockup" &&
|
||||
selectedImageNode &&
|
||||
@@ -1768,8 +1977,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
positions: nodesRef.current
|
||||
.filter((item) => dragIds.has(item.id) && (!lockedNodeIds.has(item.id) || movesWithSelectedFrame(item)))
|
||||
.map((item) => ({ id: item.id, x: item.x, y: item.y })),
|
||||
scale: viewport.k
|
||||
scale: viewportRef.current.k
|
||||
};
|
||||
const draggedElements = new Map(
|
||||
start.positions.map((position) => [position.id, canvasWorldRef.current?.querySelector<HTMLElement>(`[data-canvas-node-id="${CSS.escape(position.id)}"]`) ?? null])
|
||||
);
|
||||
let dragged = false;
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
@@ -1779,16 +1991,26 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
|
||||
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
|
||||
const nextNodes = moveNodesByDelta(nodesRef.current, start.positions, dx, dy);
|
||||
const movedNodes = nextNodes.filter((item) => selectedDragIds.has(item.id) && !lockedNodeIds.has(item.id));
|
||||
const movedNodes = nextNodes.filter((item) => draggedElements.has(item.id));
|
||||
const alignmentNodes = movedNodes.filter((item) => selectedDragIds.has(item.id));
|
||||
nodesRef.current = nextNodes;
|
||||
setNodes(nextNodes);
|
||||
setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current));
|
||||
setDirty(true);
|
||||
movedNodes.forEach((item) => {
|
||||
const element = draggedElements.get(item.id);
|
||||
if (element) {
|
||||
element.style.left = `${item.x}px`;
|
||||
element.style.top = `${item.y}px`;
|
||||
}
|
||||
leaferRendererRef.current?.setNodePosition(item.id, item.x, item.y);
|
||||
});
|
||||
containerRef.current?.classList.add("is-node-dragging");
|
||||
renderAlignmentGuidesImperatively(imperativeAlignmentGuideRef.current, buildAlignmentGuides(alignmentNodes, nextNodes, viewportRef.current));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
containerRef.current?.classList.remove("is-node-dragging");
|
||||
renderAlignmentGuidesImperatively(imperativeAlignmentGuideRef.current, []);
|
||||
setAlignmentGuides([]);
|
||||
if (shouldEditTextOnClick && !dragged) {
|
||||
setEditingTextNodeId(node.id);
|
||||
@@ -1797,7 +2019,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
if (dragged) {
|
||||
const nextNodes = attachFrameChildren(nodesRef.current, dragIds);
|
||||
nodesRef.current = nextNodes;
|
||||
setNodes(nextNodes);
|
||||
startTransition(() => setNodes(nextNodes));
|
||||
setDirty(true);
|
||||
}
|
||||
if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) {
|
||||
void refreshMockupPrintTextures(Array.from(dragIds));
|
||||
@@ -2436,8 +2659,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId)) {
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId || activeTool === "crop")) {
|
||||
return void run(() => {
|
||||
chooseCanvasTool("select");
|
||||
setImageActionMode(null);
|
||||
setMockupDropActive(false);
|
||||
setMockupDragNodeId(null);
|
||||
@@ -2643,6 +2867,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
const chooseCanvasTool = (tool: CanvasTool) => {
|
||||
if (!canEdit) return;
|
||||
if (tool !== "crop") {
|
||||
workspaceCropDetectionRequestRef.current += 1;
|
||||
setWorkspaceCropDetecting(false);
|
||||
setWorkspaceCropDraftBounds(null);
|
||||
setWorkspaceCropTargetId(null);
|
||||
}
|
||||
setActiveTool(tool);
|
||||
setCanvasToolMenuOpen(false);
|
||||
if (tool !== "shape") setShapeToolMenuOpen(false);
|
||||
@@ -2670,6 +2900,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
if (tool === "crop") {
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
setMode("select");
|
||||
};
|
||||
|
||||
@@ -3371,7 +3605,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
const openWorkspaceCrop = () => {
|
||||
if (!workspaceCropTarget || workspaceCropTargetBusy) return;
|
||||
openCropForNode(workspaceCropTarget);
|
||||
if (activeTool === "crop" && workspaceCropTargetId === workspaceCropTarget.id) {
|
||||
chooseCanvasTool("select");
|
||||
return;
|
||||
}
|
||||
setImageActionMode(null);
|
||||
setCropSelection(null);
|
||||
setCropPrompt("");
|
||||
setWorkspaceCropTargetId(workspaceCropTarget.id);
|
||||
chooseCanvasTool("crop");
|
||||
if (selectedId !== workspaceCropTarget.id || selectedIds.size !== 1) selectOnlyNode(workspaceCropTarget.id);
|
||||
};
|
||||
|
||||
const applyCropImage = async () => {
|
||||
@@ -3492,7 +3735,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
|
||||
const preparedUploads: Array<{ asset: PreparedCanvasImportAsset; sourcePreviewUrl: string; renderPreviewUrl?: string; node: CanvasNode }> = [];
|
||||
try {
|
||||
const imageAssets: PreparedCanvasImportAsset[] = [];
|
||||
let cadImportCount = 0;
|
||||
@@ -3514,14 +3757,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
if (imageAssets.length === 0) return;
|
||||
|
||||
for (const [index, asset] of imageAssets.entries()) {
|
||||
const { file, semanticContext } = asset;
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
const { file, renderFile, semanticContext } = asset;
|
||||
const sourcePreviewUrl = URL.createObjectURL(file);
|
||||
const renderPreviewUrl = renderFile ? URL.createObjectURL(renderFile) : undefined;
|
||||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
||||
const size = originalCanvasImageSize(dimensions.width, dimensions.height);
|
||||
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
|
||||
preparedUploads.push({
|
||||
file,
|
||||
previewUrl,
|
||||
asset,
|
||||
sourcePreviewUrl,
|
||||
renderPreviewUrl,
|
||||
node: {
|
||||
id: cryptoFallbackId(),
|
||||
type: "image",
|
||||
@@ -3530,7 +3775,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
y: anchor.y - size.height / 2 + index * 36,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
content: previewUrl,
|
||||
content: sourcePreviewUrl,
|
||||
renderContent: renderPreviewUrl,
|
||||
tone,
|
||||
layerRole: semanticContext ? "cad-vector" : isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
|
||||
semanticContext,
|
||||
@@ -3547,17 +3793,17 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
setDirty(true);
|
||||
|
||||
const uploadedResults = await Promise.allSettled(
|
||||
preparedUploads.map(async ({ file, node }) => ({
|
||||
preparedUploads.map(async ({ asset, node }) => ({
|
||||
id: node.id,
|
||||
publicUrl: await designGateway.uploadImage(file)
|
||||
uploaded: await uploadPreparedCanvasImportAsset(asset, designGateway.uploadImage, designGateway.deleteAsset)
|
||||
}))
|
||||
);
|
||||
const uploadedById = new Map<string, string>();
|
||||
const uploadedById = new Map<string, { content: string; renderContent?: string }>();
|
||||
const failedIds = new Set<string>();
|
||||
uploadedResults.forEach((result, index) => {
|
||||
const nodeId = preparedUploads[index]?.node.id;
|
||||
if (!nodeId) return;
|
||||
if (result.status === "fulfilled") uploadedById.set(result.value.id, result.value.publicUrl);
|
||||
if (result.status === "fulfilled") uploadedById.set(result.value.id, result.value.uploaded);
|
||||
else failedIds.add(nodeId);
|
||||
});
|
||||
|
||||
@@ -3565,13 +3811,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const finalNodes = nodesRef.current
|
||||
.filter((node) => !failedIds.has(node.id))
|
||||
.map((node) => {
|
||||
const publicUrl = uploadedById.get(node.id);
|
||||
return publicUrl ? { ...node, content: publicUrl, status: "success" } : node;
|
||||
const uploaded = uploadedById.get(node.id);
|
||||
return uploaded ? { ...node, content: uploaded.content, renderContent: uploaded.renderContent, status: "success" } : node;
|
||||
});
|
||||
nodesRef.current = finalNodes;
|
||||
setNodes(finalNodes);
|
||||
selectNodes(finalNodes.filter((node) => finalUploadedNodeIds.has(node.id)).map((node) => node.id));
|
||||
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||||
preparedUploads.forEach(revokePreparedUploadUrls);
|
||||
|
||||
if (failedIds.size > 0) {
|
||||
setError(t("generateError"));
|
||||
@@ -3584,7 +3830,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
showCanvasToast(cadImportCount > 0 ? t("cadImportSuccess", { count: cadImportCount }) : t("imageUploadSuccess"));
|
||||
}
|
||||
} catch (err) {
|
||||
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||||
preparedUploads.forEach(revokePreparedUploadUrls);
|
||||
const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id));
|
||||
if (failedUploadIds.size > 0) {
|
||||
const nextNodes = nodesRef.current.filter((node) => !failedUploadIds.has(node.id));
|
||||
@@ -3667,7 +3913,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
<main className="workspace-main">
|
||||
<div
|
||||
className={`canvas-stage ${showGrid ? "show-grid" : ""} ${canvasFileDragging ? "dragging-upload" : ""} ${activeTool === "pan" ? "is-pan-tool" : ""} ${canvasPanning ? "is-panning" : ""} ${activeTool === "frame" ? "is-frame-tool" : ""} ${activeTool === "shape" ? "is-shape-tool" : ""} ${activeTool === "pen" ? "is-pen-tool" : ""} ${activeTool === "text" ? "is-text-tool" : ""}`}
|
||||
className={`canvas-stage ${showGrid ? "show-grid" : ""} ${canvasFileDragging ? "dragging-upload" : ""} ${activeTool === "pan" ? "is-pan-tool" : ""} ${canvasPanning ? "is-panning" : ""} ${activeTool === "frame" ? "is-frame-tool" : ""} ${activeTool === "shape" ? "is-shape-tool" : ""} ${activeTool === "pen" ? "is-pen-tool" : ""} ${activeTool === "text" ? "is-text-tool" : ""} ${activeTool === "crop" ? "is-crop-tool" : ""} ${workspaceCropDetecting ? "is-crop-detecting" : ""}`}
|
||||
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
|
||||
ref={containerRef}
|
||||
onPointerDown={canEdit ? handleBackgroundPointerDown : undefined}
|
||||
@@ -3743,12 +3989,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
/>
|
||||
)}
|
||||
{showGrid && <CanvasGrid viewport={viewport} />}
|
||||
<div className="canvas-world" style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})` }}>
|
||||
<div className="leafer-canvas-layer" ref={leaferMountRef} aria-hidden="true" />
|
||||
<div ref={canvasWorldRef} className="canvas-world" style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})` }}>
|
||||
{renderedCanvasNodes.map((node) => {
|
||||
const hidden = hiddenNodeIds.has(node.id);
|
||||
const locked = lockedNodeIds.has(node.id);
|
||||
const canOperateNode = canEdit && !locked;
|
||||
const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id;
|
||||
const suppressSelectionFrame = ((imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id) || (activeTool === "crop" && workspaceCropTargetId === node.id);
|
||||
const imageGeneratorTargetSize =
|
||||
node.type === "image" && node.layerRole === "image-generator"
|
||||
? node.id === selectedImageGeneratorNode?.id
|
||||
@@ -3794,6 +4041,14 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
<FrameDraftOverlay draft={frameDraft} label={t("newFrameNode")} />
|
||||
<ShapeDraftOverlay draft={shapeDraft} kind={selectedShapeKind} label={shapeTitle(selectedShapeKind, t)} />
|
||||
<PenStrokeDraftOverlay draft={penDraft} />
|
||||
{workspaceCropDraftBounds && (
|
||||
<div className="workspace-crop-draft" style={workspaceCropDraftBounds} aria-hidden="true">
|
||||
<span className="workspace-crop-rule vertical first" />
|
||||
<span className="workspace-crop-rule vertical second" />
|
||||
<span className="workspace-crop-rule horizontal first" />
|
||||
<span className="workspace-crop-rule horizontal second" />
|
||||
</div>
|
||||
)}
|
||||
{selectedImageGeneratorBounds && selectedImageGeneratorNode?.status === "draft" && (
|
||||
<ImageGeneratorFloatingComposer
|
||||
bounds={selectedImageGeneratorBounds}
|
||||
@@ -3813,6 +4068,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
)}
|
||||
|
||||
{selectionBox && <div className="canvas-selection-marquee" style={selectionBox} />}
|
||||
<div ref={imperativeAlignmentGuideRef} className="canvas-imperative-alignment-guides" aria-hidden="true" />
|
||||
{alignmentGuides.map((guide) => (
|
||||
<span
|
||||
key={guide.id}
|
||||
@@ -3865,7 +4121,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
<>
|
||||
{isVectorImageNode(selectedImageNode) ? (
|
||||
<>
|
||||
{imageActionMode !== "crop" && (
|
||||
{imageActionMode !== "crop" && activeTool !== "crop" && (
|
||||
<VectorizedImageToolbar
|
||||
bounds={selectedNodeBounds}
|
||||
leftInset={floatingToolbarLeftInset}
|
||||
@@ -3893,7 +4149,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{imageActionMode !== "quick-edit" && imageActionMode !== "visual-annotation" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && (
|
||||
{activeTool !== "crop" && imageActionMode !== "quick-edit" && imageActionMode !== "visual-annotation" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && (
|
||||
<ImageNodeToolbar
|
||||
bounds={selectedNodeBounds}
|
||||
leftInset={floatingToolbarLeftInset}
|
||||
@@ -4202,7 +4458,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
selectedShapeKind={selectedShapeKind}
|
||||
showGrid={showGrid}
|
||||
cropEnabled={Boolean(workspaceCropTarget && !workspaceCropTargetBusy)}
|
||||
cropActive={imageActionMode === "crop"}
|
||||
cropActive={imageActionMode === "crop" || activeTool === "crop"}
|
||||
disabled={!canEdit}
|
||||
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
|
||||
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
|
||||
@@ -4454,6 +4710,7 @@ function CanvasNodeView({
|
||||
const nodeElement = (
|
||||
<div
|
||||
className={`canvas-node node-${node.type} tone-${node.tone} ${manualFrame ? "manual-frame-node" : ""} ${shapeNode || booleanShapeGroup ? "shape-node" : ""} ${penStroke ? "pen-stroke-node" : ""} ${visual ? "visual-node" : textOverlay ? "text-overlay-node" : "note-node"} ${selected ? "selected" : ""} ${mockupDragging ? "is-mockup-dragging" : ""} ${hidden ? "is-hidden" : ""} ${locked ? "is-locked" : ""}`}
|
||||
data-canvas-node-id={node.id}
|
||||
style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
|
||||
onPointerDown={onPointerDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
|
||||
@@ -1622,6 +1622,65 @@ button:disabled {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.canvas-stage.is-crop-tool,
|
||||
.canvas-stage.is-crop-tool .canvas-world,
|
||||
.canvas-stage.is-crop-tool .canvas-node,
|
||||
.canvas-stage.is-crop-tool .artboard-image,
|
||||
.canvas-stage.is-crop-tool .artboard-preview {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.canvas-stage.is-crop-detecting,
|
||||
.canvas-stage.is-crop-detecting .canvas-world,
|
||||
.canvas-stage.is-crop-detecting .canvas-node {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.workspace-crop-draft {
|
||||
position: absolute;
|
||||
z-index: 37;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid #fff;
|
||||
background: rgba(47, 128, 255, 0.08);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(17, 24, 39, 0.24),
|
||||
0 2px 10px rgba(17, 24, 39, 0.18);
|
||||
}
|
||||
|
||||
.workspace-crop-rule {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.workspace-crop-rule.vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.workspace-crop-rule.horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.workspace-crop-rule.vertical.first {
|
||||
left: 33.333%;
|
||||
}
|
||||
|
||||
.workspace-crop-rule.vertical.second {
|
||||
left: 66.666%;
|
||||
}
|
||||
|
||||
.workspace-crop-rule.horizontal.first {
|
||||
top: 33.333%;
|
||||
}
|
||||
|
||||
.workspace-crop-rule.horizontal.second {
|
||||
top: 66.666%;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1639,6 +1698,16 @@ button:disabled {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.leafer-canvas-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leafer-canvas-layer > canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.workspace-readonly-shield {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1778,6 +1847,17 @@ button:disabled {
|
||||
background: #ff4d43;
|
||||
}
|
||||
|
||||
.canvas-imperative-alignment-guides {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-stage.is-node-dragging :is(.image-node-toolbar, .vector-result-toolbar, .frame-style-toolbar, .shape-style-toolbar, .text-style-toolbar, .multi-selection-toolbar),
|
||||
.canvas-stage.is-viewport-transforming :is(.image-node-toolbar, .vector-result-toolbar, .frame-style-toolbar, .shape-style-toolbar, .text-style-toolbar, .multi-selection-toolbar) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.canvas-alignment-guide.horizontal {
|
||||
height: 1px;
|
||||
transform: translateY(-0.5px);
|
||||
@@ -2219,6 +2299,14 @@ button:disabled {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.canvas-node.leafer-visual-ready {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.canvas-node.leafer-visual-ready .node-content-layer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createVectorCropSiblingNode, croppedSvgViewBox, vectorCropRasterSize, vectorCropSiblingSize } from "../src/ui/pages/CanvasWorkspace/canvas/cadCrop.ts";
|
||||
import { cropSelectionScreenBounds, visibleCropSelectionForBounds } from "../src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts";
|
||||
import { cropSelectionFromScreenDrag, cropSelectionScreenBounds, visibleCropSelectionForBounds } from "../src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts";
|
||||
|
||||
const source = {
|
||||
id: "cad-source",
|
||||
@@ -74,3 +74,17 @@ test("keeps the full crop when the image already fits the safe viewport", () =>
|
||||
|
||||
assert.deepEqual(visibleCropSelectionForBounds(imageBounds, { width: 1200, height: 800 }), { x: 0, y: 0, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("clamps a manually drawn crop to the currently visible image region", () => {
|
||||
const imageBounds = { left: -200, top: -100, width: 1000, height: 800 };
|
||||
const visibleSelection = { x: 0.2, y: 0.125, width: 0.6, height: 0.75 };
|
||||
|
||||
assert.deepEqual(
|
||||
cropSelectionFromScreenDrag(imageBounds, visibleSelection, { x: 0, y: 0 }, { x: 900, y: 700 }),
|
||||
visibleSelection
|
||||
);
|
||||
assert.deepEqual(
|
||||
cropSelectionFromScreenDrag(imageBounds, visibleSelection, { x: 100, y: 140 }, { x: 500, y: 460 }),
|
||||
{ x: 0.3, y: 0.3, width: 0.4, height: 0.4 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { findColorBoundaryCrop, mapColorBoundaryCropToLimit } from "../src/ui/pages/CanvasWorkspace/canvas/colorBoundaryCrop.ts";
|
||||
|
||||
function imageFixture(width, height, background, region) {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
const inside = x >= region.left && x < region.right && y >= region.top && y < region.bottom;
|
||||
const color = inside ? region.color(x, y) : background;
|
||||
data[offset] = color[0];
|
||||
data[offset + 1] = color[1];
|
||||
data[offset + 2] = color[2];
|
||||
data[offset + 3] = color[3] ?? 255;
|
||||
}
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
test("finds the connected color-bounded region around a crop click", () => {
|
||||
const image = imageFixture(12, 10, [235, 235, 235, 255], {
|
||||
left: 3,
|
||||
top: 2,
|
||||
right: 9,
|
||||
bottom: 7,
|
||||
color: (x, y) => [64 + x, 72 + y, 80, 255]
|
||||
});
|
||||
|
||||
assert.deepEqual(findColorBoundaryCrop(image, { x: 0.5, y: 0.4 }), {
|
||||
x: 0.25,
|
||||
y: 0.2,
|
||||
width: 0.5,
|
||||
height: 0.5
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the full analyzable area when no color boundary encloses the click", () => {
|
||||
const image = imageFixture(8, 6, [250, 250, 250, 255], {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
color: () => [0, 0, 0, 255]
|
||||
});
|
||||
|
||||
assert.deepEqual(findColorBoundaryCrop(image, { x: 0.5, y: 0.5 }), { x: 0, y: 0, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("never expands automatic selection beyond the currently visible crop limit", () => {
|
||||
const visibleLimit = { x: 0.18, y: 0.12, width: 0.64, height: 0.7 };
|
||||
|
||||
assert.deepEqual(mapColorBoundaryCropToLimit({ x: 0, y: 0, width: 1, height: 1 }, visibleLimit), visibleLimit);
|
||||
assert.deepEqual(mapColorBoundaryCropToLimit({ x: 0.25, y: 0.2, width: 0.5, height: 0.4 }, visibleLimit), {
|
||||
x: 0.34,
|
||||
y: 0.26,
|
||||
width: 0.32,
|
||||
height: 0.28
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { leaferViewSize, leaferVisualDescriptor, visualInput } from "../src/ui/pages/CanvasWorkspace/canvas/leaferRenderer.ts";
|
||||
|
||||
const cadNode = {
|
||||
id: "cad-1",
|
||||
type: "image",
|
||||
title: "drawing.dwg",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 640,
|
||||
height: 480,
|
||||
content: "https://assets.example/drawing.svg",
|
||||
renderContent: "https://assets.example/drawing.render.webp",
|
||||
tone: "transparent-image",
|
||||
status: "success",
|
||||
layerRole: "cad-vector",
|
||||
semanticContext: "CAD semantic context: layer ROOMS"
|
||||
};
|
||||
|
||||
test("renders CAD through its WebP texture without changing source or semantics", () => {
|
||||
const descriptor = leaferVisualDescriptor(cadNode);
|
||||
const input = visualInput(descriptor);
|
||||
|
||||
assert.equal(descriptor.kind, "image");
|
||||
assert.match(descriptor.url, /drawing\.render\.webp/);
|
||||
assert.equal(input.x, cadNode.x + cadNode.width / 2);
|
||||
assert.equal(input.y, cadNode.y + cadNode.height / 2);
|
||||
assert.equal(cadNode.content, "https://assets.example/drawing.svg");
|
||||
assert.match(cadNode.semanticContext, /ROOMS/);
|
||||
});
|
||||
|
||||
test("keeps vector nodes on the DOM fallback until a render texture exists", () => {
|
||||
assert.equal(leaferVisualDescriptor({ ...cadNode, renderContent: undefined }), null);
|
||||
});
|
||||
|
||||
test("maps manual frames to retained Leafer rectangles", () => {
|
||||
const descriptor = leaferVisualDescriptor({
|
||||
...cadNode,
|
||||
id: "frame-1",
|
||||
type: "frame",
|
||||
layerRole: "manual-frame",
|
||||
fillColor: "#ffffff",
|
||||
strokeColor: "#101010",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dashed"
|
||||
});
|
||||
|
||||
assert.equal(descriptor.kind, "frame");
|
||||
assert.deepEqual(descriptor.dashPattern, [12, 8]);
|
||||
});
|
||||
|
||||
test("sizes the Leafer surface from the mounted stage instead of stale defaults", () => {
|
||||
assert.deepEqual(leaferViewSize({ clientWidth: 1685, clientHeight: 1670 }, { width: 1100, height: 760 }), {
|
||||
width: 1685,
|
||||
height: 1670
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user