diff --git a/frontend/package.json b/frontend/package.json index df575ed..162a625 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 97584e2..b0ec4bd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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'} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/colorBoundaryCrop.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/colorBoundaryCrop.ts new file mode 100644 index 0000000..ebfcc3e --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/colorBoundaryCrop.ts @@ -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 { + 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((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); +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts index a4d1d68..de45051 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/cropViewport.ts @@ -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)); } diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/leaferRenderer.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/leaferRenderer.ts new file mode 100644 index 0000000..e60e525 --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/leaferRenderer.ts @@ -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, 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(); + private readonly readyIds = new Set(); + 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, 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(`[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).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, "\\$&"); +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/types.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/types.ts index a5303e1..8265cbc 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/types.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/types.ts @@ -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; diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index a80b6f2..d545d3b 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -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; 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(null); + const canvasWorldRef = useRef(null); + const leaferMountRef = useRef(null); + const leaferRendererRef = useRef> | null>(null); + const imperativeAlignmentGuideRef = useRef(null); + const viewportCommitTimerRef = useRef(null); const canvasAssetInputRef = useRef(null); const agentComposerRef = useRef(null); const assistantFeedRef = useRef(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([]); @@ -662,6 +699,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess const [moveRecognitionLoading, setMoveRecognitionLoading] = useState(false); const [cropPrompt, setCropPrompt] = useState(""); const [cropSelection, setCropSelection] = useState(null); + const [workspaceCropTargetId, setWorkspaceCropTargetId] = useState(null); + const [workspaceCropDraftBounds, setWorkspaceCropDraftBounds] = useState(null); + const [workspaceCropDetecting, setWorkspaceCropDetecting] = useState(false); const [adjustOptions, setAdjustOptions] = useState(defaultImageAdjustments); const [expandOptions, setExpandOptions] = useState({ 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(".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) => { 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, 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) => { 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, 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(`[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(); + const uploadedById = new Map(); const failedIds = new Set(); 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
)} {showGrid && } -
+