Add canvas workflow and harden data import
This commit is contained in:
@@ -76,7 +76,9 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
|
"ag-psd": "^30.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-dev-inspector/babel-plugin": "^2.0.1",
|
"@react-dev-inspector/babel-plugin": "^2.0.1",
|
||||||
|
|||||||
166
pnpm-lock.yaml
generated
166
pnpm-lock.yaml
generated
@@ -105,6 +105,12 @@ importers:
|
|||||||
'@supabase/supabase-js':
|
'@supabase/supabase-js':
|
||||||
specifier: 2.95.3
|
specifier: 2.95.3
|
||||||
version: 2.95.3
|
version: 2.95.3
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.10.2
|
||||||
|
version: 12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
ag-psd:
|
||||||
|
specifier: ^30.1.1
|
||||||
|
version: 30.1.1
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1403,24 +1409,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.2.4':
|
'@next/swc-linux-arm64-musl@16.2.4':
|
||||||
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.2.4':
|
'@next/swc-linux-x64-gnu@16.2.4':
|
||||||
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.2.4':
|
'@next/swc-linux-x64-musl@16.2.4':
|
||||||
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.2.4':
|
'@next/swc-win32-arm64-msvc@16.2.4':
|
||||||
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
||||||
@@ -2638,6 +2648,9 @@ packages:
|
|||||||
'@types/d3-color@3.1.3':
|
'@types/d3-color@3.1.3':
|
||||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
'@types/d3-ease@3.0.2':
|
'@types/d3-ease@3.0.2':
|
||||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
@@ -2650,6 +2663,9 @@ packages:
|
|||||||
'@types/d3-scale@4.0.9':
|
'@types/d3-scale@4.0.9':
|
||||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11':
|
||||||
|
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.8':
|
'@types/d3-shape@3.1.8':
|
||||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
@@ -2659,6 +2675,12 @@ packages:
|
|||||||
'@types/d3-timer@3.0.2':
|
'@types/d3-timer@3.0.2':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
@@ -2833,41 +2855,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
@@ -2940,6 +2970,15 @@ packages:
|
|||||||
'@xtuc/long@4.2.2':
|
'@xtuc/long@4.2.2':
|
||||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.2':
|
||||||
|
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.76':
|
||||||
|
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2969,6 +3008,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
ag-psd@30.1.1:
|
||||||
|
resolution: {integrity: sha512-0GbWYR4Rvm1QnWCYeMiVbUJBXnSyTUKvNUK2tIIVDt/wrUVUL9pHTsnwqOTonEC2RRh5I/aUcGydc1LNgXfJWA==}
|
||||||
|
|
||||||
agent-base@7.1.4:
|
agent-base@7.1.4:
|
||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -3252,6 +3294,9 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3388,6 +3433,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-ease@3.0.1:
|
d3-ease@3.0.1:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3408,6 +3461,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3424,6 +3481,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-transition@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8:
|
damerau-levenshtein@1.0.8:
|
||||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||||
|
|
||||||
@@ -5245,6 +5312,9 @@ packages:
|
|||||||
package-manager-detector@1.6.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -6368,6 +6438,21 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
zwitch@2.0.4:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -9034,6 +9119,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/d3-ease@3.0.2': {}
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
'@types/d3-interpolate@3.0.4':
|
'@types/d3-interpolate@3.0.4':
|
||||||
@@ -9046,6 +9135,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-time': 3.0.4
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11': {}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.8':
|
'@types/d3-shape@3.1.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-path': 3.1.1
|
'@types/d3-path': 3.1.1
|
||||||
@@ -9054,6 +9145,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-timer@3.0.2': {}
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -9362,6 +9462,29 @@ snapshots:
|
|||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.76
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
zustand: 4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.76':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
'@types/d3-transition': 3.0.9
|
||||||
|
'@types/d3-zoom': 3.0.8
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
@@ -9381,6 +9504,11 @@ snapshots:
|
|||||||
|
|
||||||
address@1.2.2: {}
|
address@1.2.2: {}
|
||||||
|
|
||||||
|
ag-psd@30.1.1:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
pako: 2.1.0
|
||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
ajv-formats@2.1.1(ajv@8.18.0):
|
ajv-formats@2.1.1(ajv@8.18.0):
|
||||||
@@ -9686,6 +9814,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
@@ -9812,6 +9942,13 @@ snapshots:
|
|||||||
|
|
||||||
d3-color@3.1.0: {}
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
d3-ease@3.0.1: {}
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
d3-format@3.1.2: {}
|
d3-format@3.1.2: {}
|
||||||
@@ -9830,6 +9967,8 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-time-format: 4.1.0
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
@@ -9844,6 +9983,23 @@ snapshots:
|
|||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8: {}
|
damerau-levenshtein@1.0.8: {}
|
||||||
|
|
||||||
data-uri-to-buffer@4.0.1: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
@@ -11947,6 +12103,8 @@ snapshots:
|
|||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -13333,4 +13491,12 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
immer: 9.0.21
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
BIN
public/canvas/workflows/scene01.jpeg
Executable file
BIN
public/canvas/workflows/scene01.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 898 KiB |
BIN
public/canvas/workflows/shot01.jpeg
Executable file
BIN
public/canvas/workflows/shot01.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 936 KiB |
BIN
public/canvas/workflows/workflow01.jpeg
Executable file
BIN
public/canvas/workflows/workflow01.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/canvas/workflows/workflow02.jpeg
Executable file
BIN
public/canvas/workflows/workflow02.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 902 KiB |
@@ -75,6 +75,7 @@ type ImportContext = {
|
|||||||
apiKeyIdMap: Map<string, string>;
|
apiKeyIdMap: Map<string, string>;
|
||||||
apiKeyOwnerIdMap: Map<string, string>;
|
apiKeyOwnerIdMap: Map<string, string>;
|
||||||
columnCache: Map<string, Set<string>>;
|
columnCache: Map<string, Set<string>>;
|
||||||
|
defaultableColumnCache: Map<string, Set<string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -136,11 +137,15 @@ async function importRows(
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const existingColumns = await getExistingColumns(client, table, context);
|
const existingColumns = await getExistingColumns(client, table, context);
|
||||||
|
const defaultableColumns = await getDefaultableColumns(client, table, context);
|
||||||
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
||||||
|
|
||||||
for (const rawRow of rows) {
|
for (const rawRow of rows) {
|
||||||
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
||||||
const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col));
|
const cols = Object.keys(row).filter(col => (
|
||||||
|
effectiveAllowedColumns.includes(col)
|
||||||
|
&& !(row[col] == null && defaultableColumns.has(col))
|
||||||
|
));
|
||||||
if (!cols.includes('id') || cols.length === 0) {
|
if (!cols.includes('id') || cols.length === 0) {
|
||||||
skipped++;
|
skipped++;
|
||||||
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
||||||
@@ -235,7 +240,15 @@ async function buildImportContext(
|
|||||||
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
||||||
}
|
}
|
||||||
const ownerId = findImportedWorkUserId(row);
|
const ownerId = findImportedWorkUserId(row);
|
||||||
const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() });
|
const ownerByEmail = findUserIdByEmail(row, {
|
||||||
|
userIdMap,
|
||||||
|
workIdMap,
|
||||||
|
emailUserIdMap,
|
||||||
|
apiKeyIdMap,
|
||||||
|
apiKeyOwnerIdMap,
|
||||||
|
columnCache: new Map(),
|
||||||
|
defaultableColumnCache: new Map(),
|
||||||
|
});
|
||||||
const mappedOwnerId = ownerId
|
const mappedOwnerId = ownerId
|
||||||
? (userIdMap.get(ownerId) || ownerId)
|
? (userIdMap.get(ownerId) || ownerId)
|
||||||
: ownerByEmail;
|
: ownerByEmail;
|
||||||
@@ -266,7 +279,15 @@ async function buildImportContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() };
|
return {
|
||||||
|
userIdMap,
|
||||||
|
workIdMap,
|
||||||
|
emailUserIdMap,
|
||||||
|
apiKeyIdMap,
|
||||||
|
apiKeyOwnerIdMap,
|
||||||
|
columnCache: new Map(),
|
||||||
|
defaultableColumnCache: new Map(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
||||||
@@ -331,6 +352,12 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (table === 'user_api_keys') {
|
if (table === 'user_api_keys') {
|
||||||
|
if (typeof next.note !== 'string' || next.note.trim() === '') {
|
||||||
|
next.note = '导入的 API Key';
|
||||||
|
}
|
||||||
|
if (typeof next.type !== 'string' || next.type.trim() === '') {
|
||||||
|
next.type = 'image';
|
||||||
|
}
|
||||||
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
||||||
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
||||||
const secret = rawApiKey || rawEncrypted;
|
const secret = rawApiKey || rawEncrypted;
|
||||||
@@ -519,6 +546,29 @@ async function getExistingColumns(
|
|||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getDefaultableColumns(
|
||||||
|
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||||
|
table: string,
|
||||||
|
context: ImportContext,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const cached = context.defaultableColumnCache.get(table);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1
|
||||||
|
AND table_name = $2
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
AND column_default IS NOT NULL`,
|
||||||
|
[schemaName, tableName],
|
||||||
|
);
|
||||||
|
const columns = new Set((result.rows || []).map((row: Record<string, unknown>) => String(row.column_name)));
|
||||||
|
context.defaultableColumnCache.set(table, columns);
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
||||||
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
||||||
map.set(value, crypto.randomUUID());
|
map.set(value, crypto.randomUUID());
|
||||||
|
|||||||
60
src/app/api/canvas/projects/[id]/route.ts
Executable file
60
src/app/api/canvas/projects/[id]/route.ts
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { deleteCanvasProject, getCanvasProject, updateCanvasProject } from '@/lib/canvas-store';
|
||||||
|
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||||
|
import { normalizeCanvasState } from '@/lib/canvas-store';
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const project = await getCanvasProject(userId, id);
|
||||||
|
if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects/:id] GET error:', error);
|
||||||
|
return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const project = await updateCanvasProject(userId, id, {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
state: body.state ? normalizeCanvasState(body.state) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects/:id] PUT error:', error);
|
||||||
|
return NextResponse.json({ error: '保存画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const deleted = await deleteCanvasProject(userId, id);
|
||||||
|
if (!deleted) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects/:id] DELETE error:', error);
|
||||||
|
return NextResponse.json({ error: '删除画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/canvas/projects/route.ts
Executable file
31
src/app/api/canvas/projects/route.ts
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createCanvasProject, listCanvasProjects } from '@/lib/canvas-store';
|
||||||
|
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const projects = await listCanvasProjects(userId);
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects] GET error:', error);
|
||||||
|
return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const title = typeof body.title === 'string' ? body.title : '未命名画布';
|
||||||
|
const project = await createCanvasProject(userId, title);
|
||||||
|
return NextResponse.json({ project }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects] POST error:', error);
|
||||||
|
return NextResponse.json({ error: '创建画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/app/canvas/page.tsx
Executable file
5
src/app/canvas/page.tsx
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InfiniteCanvasWorkspace } from '@/components/canvas/infinite-canvas-workspace';
|
||||||
|
|
||||||
|
export default function CanvasPage() {
|
||||||
|
return <InfiniteCanvasWorkspace />;
|
||||||
|
}
|
||||||
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
File diff suppressed because it is too large
Load Diff
553
src/components/canvas/react-flow-canvas.tsx
Executable file
553
src/components/canvas/react-flow-canvas.tsx
Executable file
@@ -0,0 +1,553 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Background,
|
||||||
|
ConnectionLineType,
|
||||||
|
Controls,
|
||||||
|
Handle,
|
||||||
|
MarkerType,
|
||||||
|
MiniMap,
|
||||||
|
Position,
|
||||||
|
ReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
useReactFlow,
|
||||||
|
type Connection,
|
||||||
|
type Edge,
|
||||||
|
type EdgeChange,
|
||||||
|
type Node as FlowNode,
|
||||||
|
type NodeChange,
|
||||||
|
type NodeProps,
|
||||||
|
type Rect,
|
||||||
|
type ReactFlowInstance,
|
||||||
|
type Viewport as FlowViewport,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { Brush, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Type } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { CanvasConnection, CanvasLayer, CanvasNode, CanvasViewport } from '@/lib/canvas-types';
|
||||||
|
|
||||||
|
type NodePositionCommit = { id: string; x: number; y: number };
|
||||||
|
|
||||||
|
type CanvasFlowNodeData = {
|
||||||
|
node: CanvasNode;
|
||||||
|
selected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
allNodes: CanvasNode[];
|
||||||
|
layerCanvasSize: number;
|
||||||
|
layerColors: Record<CanvasLayer['type'], string>;
|
||||||
|
onSelect: (id: string, additive: boolean) => void;
|
||||||
|
onStartConnect: (id: string) => void;
|
||||||
|
onRemoveConnection: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
|
||||||
|
|
||||||
|
type ReactFlowCanvasProps = {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
selectedNodeIds: string[];
|
||||||
|
connectingFromId: string | null;
|
||||||
|
editable: boolean;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
layerCanvasSize: number;
|
||||||
|
layerColors: Record<CanvasLayer['type'], string>;
|
||||||
|
onSelectNode: (id: string, additive: boolean) => void;
|
||||||
|
onSelectionChange: (ids: string[]) => void;
|
||||||
|
onStartConnect: (id: string) => void;
|
||||||
|
onConnect: (sourceId: string, targetId: string) => void;
|
||||||
|
onRemoveConnection: (id: string) => void;
|
||||||
|
onRemoveConnections: (ids: string[]) => void;
|
||||||
|
onNodesCommit: (positions: NodePositionCommit[], options?: { history?: boolean; dirty?: boolean }) => void;
|
||||||
|
onViewportCommit: (viewport: CanvasViewport) => void;
|
||||||
|
onReady?: (controls: CanvasFlowControls | null) => void;
|
||||||
|
onPaneClick: (point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => void;
|
||||||
|
onPaneDoubleClick: (point: { x: number; y: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasFlowControls = {
|
||||||
|
setViewport: (viewport: CanvasViewport) => Promise<boolean>;
|
||||||
|
zoomTo: (zoom: number) => Promise<boolean>;
|
||||||
|
fitBounds: (bounds: Rect, options?: { padding?: number; duration?: number }) => Promise<boolean>;
|
||||||
|
getViewport: () => CanvasViewport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_COLORS = ['#22c55e', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#14b8a6'];
|
||||||
|
const SNAP_GRID: [number, number] = [20, 20];
|
||||||
|
const MULTI_SELECTION_KEYS = ['Meta', 'Control', 'Shift'];
|
||||||
|
const FLOW_FIT_VIEW_OPTIONS = { padding: 0.2 };
|
||||||
|
const CANVAS_NODE_TYPES = { canvasNode: CanvasNodeCard };
|
||||||
|
|
||||||
|
function getNodeImageUrl(node?: CanvasNode | null) {
|
||||||
|
if (!node) return '';
|
||||||
|
if (node.type === 'image') return node.imageUrl || '';
|
||||||
|
return node.selectedOutput || node.outputImages?.[0] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||||
|
const { node, selected, connecting, connections, allNodes, layerCanvasSize, layerColors, onSelect, onStartConnect, onRemoveConnection } = data;
|
||||||
|
const incomingImage = allNodes.find(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id && !!getNodeImageUrl(item)));
|
||||||
|
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-canvas-node
|
||||||
|
data-node-id={node.id}
|
||||||
|
className={cn(
|
||||||
|
'group h-full overflow-visible rounded-lg border transition-shadow',
|
||||||
|
node.type === 'frame' ? 'bg-background/20 shadow-none' : 'bg-card shadow-sm',
|
||||||
|
selected ? 'border-primary ring-2 ring-primary/20' : connecting ? 'border-emerald-500 ring-2 ring-emerald-500/20' : 'border-border',
|
||||||
|
)}
|
||||||
|
style={{ borderColor: node.type === 'frame' && !selected ? node.color || '#22c55e' : undefined }}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('button,input,textarea,[role="combobox"],.nodrag')) return;
|
||||||
|
onSelect(node.id, event.ctrlKey || event.metaKey || event.shiftKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Left} id="input" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||||
|
<Handle type="source" position={Position.Right} id="output" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'nodrag absolute right-0 top-1/2 z-20 flex h-7 w-7 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
|
||||||
|
connecting ? 'border-emerald-500 bg-emerald-500 text-white opacity-100' : '',
|
||||||
|
)}
|
||||||
|
title="从此模块开始连线"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStartConnect(node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-full overflow-hidden rounded-lg">
|
||||||
|
<div className="flex h-10 items-center justify-between border-b bg-muted/40 px-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||||
|
{node.type === 'text' ? <Type className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'image' ? <ImageIcon className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'text2img' ? <Brush className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'img2img' ? <FileImage className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'layeredImage' ? <Layers className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'frame' ? <Maximize2 className="h-4 w-4" /> : null}
|
||||||
|
<span className="truncate">{node.title}</span>
|
||||||
|
</div>
|
||||||
|
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[calc(100%-2.5rem)] p-3">
|
||||||
|
{node.type === 'text' ? (
|
||||||
|
<div className="h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-sm leading-6">{node.text}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'image' ? (
|
||||||
|
<div className="h-full overflow-hidden rounded-md bg-muted">
|
||||||
|
{node.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={node.imageUrl} alt={node.title} className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">未选择图片</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'text2img' || node.type === 'img2img' ? (
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<div className="line-clamp-3 min-h-14 rounded-md bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||||
|
{node.prompt || (connections.some(connection => connection.targetNodeId === node.id) ? '已连接上游内容,可继续补充描述' : '在右侧输入创作描述')}
|
||||||
|
</div>
|
||||||
|
{node.type === 'img2img' ? (
|
||||||
|
<div className="h-20 overflow-hidden rounded-md border bg-muted">
|
||||||
|
{node.referenceImage || getNodeImageUrl(incomingImage) ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={node.referenceImage || getNodeImageUrl(incomingImage)} alt="参考图" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">从右侧选择参考图或连接图片节点</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
|
||||||
|
{node.selectedOutput ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={node.selectedOutput} alt="生成结果" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待生成'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'layeredImage' ? (
|
||||||
|
<div className="grid h-full grid-cols-[1fr_120px] gap-3 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="relative overflow-hidden rounded-md border bg-background">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted))_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted))_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0px] opacity-35" />
|
||||||
|
{(node.layers || []).filter(layer => layer.visible).map(layer => (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute border border-white/60 shadow-sm',
|
||||||
|
layer.type === 'text' ? 'flex items-center px-2 text-xs font-semibold' : 'rounded-md',
|
||||||
|
layer.locked ? 'outline outline-1 outline-dashed outline-muted-foreground/70' : '',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${(layer.x / layerCanvasSize) * 100}%`,
|
||||||
|
top: `${(layer.y / layerCanvasSize) * 100}%`,
|
||||||
|
width: `${(layer.width / layerCanvasSize) * 100}%`,
|
||||||
|
height: `${(layer.height / layerCanvasSize) * 100}%`,
|
||||||
|
backgroundColor: layer.type === 'text' ? 'transparent' : layer.color || layerColors[layer.type],
|
||||||
|
color: layer.color || layerColors.text,
|
||||||
|
opacity: layer.opacity ?? 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer.type === 'text' ? layer.text || layer.name : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1 overflow-y-auto">
|
||||||
|
{(node.layers || []).slice().reverse().map(layer => (
|
||||||
|
<div key={layer.id} className="truncate rounded-md border bg-background px-2 py-1 text-[11px]">
|
||||||
|
<span className={layer.visible ? '' : 'text-muted-foreground line-through'}>{layer.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'frame' ? (
|
||||||
|
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || '#22c55e' }}>
|
||||||
|
<div className="rounded-md bg-background/80 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && nodeConnections.length > 0 ? (
|
||||||
|
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
|
||||||
|
<div className="mb-1 font-medium text-muted-foreground">相关连线</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nodeConnections.slice(0, 4).map(connection => {
|
||||||
|
const source = allNodes.find(item => item.id === connection.sourceNodeId);
|
||||||
|
const target = allNodes.find(item => item.id === connection.targetNodeId);
|
||||||
|
return (
|
||||||
|
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
|
||||||
|
<span className="min-w-0 truncate">{source?.title || '未知模块'} → {target?.title || '未知模块'}</span>
|
||||||
|
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}>删</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFlowNodePositions(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const bMap = new Map(b.map(node => [node.id, node]));
|
||||||
|
return a.every(node => {
|
||||||
|
const next = bMap.get(node.id);
|
||||||
|
return !!next && node.position.x === next.position.x && node.position.y === next.position.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const bMap = new Map(b.map(node => [node.id, node]));
|
||||||
|
return a.every(node => {
|
||||||
|
const next = bMap.get(node.id);
|
||||||
|
return !!next
|
||||||
|
&& node.position.x === next.position.x
|
||||||
|
&& node.position.y === next.position.y
|
||||||
|
&& node.selected === next.selected
|
||||||
|
&& node.draggable === next.draggable
|
||||||
|
&& node.selectable === next.selectable
|
||||||
|
&& node.width === next.width
|
||||||
|
&& node.height === next.height
|
||||||
|
&& node.data.node === next.data.node
|
||||||
|
&& node.data.selected === next.data.selected
|
||||||
|
&& node.data.connecting === next.data.connecting
|
||||||
|
&& node.data.connections === next.data.connections
|
||||||
|
&& node.data.allNodes === next.data.allNodes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number): CanvasViewport {
|
||||||
|
return {
|
||||||
|
x: viewport.x,
|
||||||
|
y: viewport.y,
|
||||||
|
zoom: Math.min(maxZoom, Math.max(minZoom, viewport.zoom)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasInner({
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
viewport,
|
||||||
|
selectedNodeIds,
|
||||||
|
connectingFromId,
|
||||||
|
editable,
|
||||||
|
minZoom,
|
||||||
|
maxZoom,
|
||||||
|
layerCanvasSize,
|
||||||
|
layerColors,
|
||||||
|
onSelectNode,
|
||||||
|
onSelectionChange,
|
||||||
|
onStartConnect,
|
||||||
|
onConnect,
|
||||||
|
onRemoveConnection,
|
||||||
|
onRemoveConnections,
|
||||||
|
onNodesCommit,
|
||||||
|
onViewportCommit,
|
||||||
|
onReady,
|
||||||
|
onPaneClick,
|
||||||
|
onPaneDoubleClick,
|
||||||
|
}: ReactFlowCanvasProps) {
|
||||||
|
const reactFlow = useReactFlow<CanvasFlowNode, Edge>();
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const movingRef = useRef(false);
|
||||||
|
const paneClickTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const incomingNodes = useMemo<CanvasFlowNode[]>(() => nodes.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
type: 'canvasNode',
|
||||||
|
position: { x: node.x, y: node.y },
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
selected: selectedNodeIds.includes(node.id),
|
||||||
|
draggable: editable,
|
||||||
|
selectable: editable,
|
||||||
|
zIndex: node.type === 'frame' ? Math.min(node.zIndex, 0) : node.zIndex,
|
||||||
|
data: {
|
||||||
|
node,
|
||||||
|
selected: selectedNodeIds.includes(node.id),
|
||||||
|
connecting: connectingFromId === node.id,
|
||||||
|
connections,
|
||||||
|
allNodes: nodes,
|
||||||
|
layerCanvasSize,
|
||||||
|
layerColors,
|
||||||
|
onSelect: onSelectNode,
|
||||||
|
onStartConnect,
|
||||||
|
onRemoveConnection,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
},
|
||||||
|
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, nodes, onRemoveConnection, onSelectNode, onStartConnect, selectedNodeIds]);
|
||||||
|
|
||||||
|
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
|
||||||
|
|
||||||
|
const flowEdges = useMemo<Edge[]>(() => connections.map((connection, index) => ({
|
||||||
|
id: connection.id,
|
||||||
|
source: connection.sourceNodeId,
|
||||||
|
target: connection.targetNodeId,
|
||||||
|
sourceHandle: 'output',
|
||||||
|
targetHandle: 'input',
|
||||||
|
label: connection.label,
|
||||||
|
type: 'smoothstep',
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed },
|
||||||
|
style: {
|
||||||
|
stroke: CONNECTION_COLORS[index % CONNECTION_COLORS.length],
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
},
|
||||||
|
labelBgStyle: { fill: 'hsl(var(--background))', fillOpacity: 0.9 },
|
||||||
|
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 12 },
|
||||||
|
})), [connections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFlowNodes(current => {
|
||||||
|
if (draggingRef.current && sameFlowNodePositions(current, incomingNodes)) return current;
|
||||||
|
if (sameFlowNodes(current, incomingNodes)) return current;
|
||||||
|
return incomingNodes;
|
||||||
|
});
|
||||||
|
}, [incomingNodes]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (paneClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(paneClickTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
onReady?.(null);
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
const commitCurrentNodePositions = useCallback((options?: { history?: boolean; dirty?: boolean }) => {
|
||||||
|
const positions = reactFlow.getNodes().map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
x: node.position.x,
|
||||||
|
y: node.position.y,
|
||||||
|
}));
|
||||||
|
onNodesCommit(positions, options);
|
||||||
|
}, [onNodesCommit, reactFlow]);
|
||||||
|
|
||||||
|
const handleNodesChange = useCallback((changes: NodeChange<CanvasFlowNode>[]) => {
|
||||||
|
const relevantChanges = changes.filter(change => change.type === 'position' || change.type === 'select' || change.type === 'remove');
|
||||||
|
if (relevantChanges.length === 0) return;
|
||||||
|
|
||||||
|
setFlowNodes(current => {
|
||||||
|
const removedIds = new Set(
|
||||||
|
relevantChanges
|
||||||
|
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'remove' }> => change.type === 'remove')
|
||||||
|
.map(change => change.id),
|
||||||
|
);
|
||||||
|
let changed = removedIds.size > 0;
|
||||||
|
const nextNodes = current.map(node => {
|
||||||
|
if (removedIds.has(node.id)) return node;
|
||||||
|
const positionChange = relevantChanges.find(
|
||||||
|
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'position' }> => change.type === 'position' && change.id === node.id && !!change.position,
|
||||||
|
);
|
||||||
|
const selectChange = relevantChanges.find(
|
||||||
|
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.id === node.id,
|
||||||
|
);
|
||||||
|
const nextPosition = positionChange?.position || node.position;
|
||||||
|
const nextSelected = typeof selectChange?.selected === 'boolean' ? selectChange.selected : node.selected;
|
||||||
|
if (nextPosition === node.position && nextSelected === node.selected) return node;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: nextPosition,
|
||||||
|
selected: nextSelected,
|
||||||
|
};
|
||||||
|
}).filter(node => !removedIds.has(node.id));
|
||||||
|
return changed ? nextNodes : current;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = relevantChanges
|
||||||
|
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.selected)
|
||||||
|
.map(change => change.id);
|
||||||
|
if (selected.length > 0) onSelectionChange(selected);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
const handleEdgesChange = useCallback((changes: EdgeChange<Edge>[]) => {
|
||||||
|
const removedIds = changes.filter(change => change.type === 'remove').map(change => change.id);
|
||||||
|
if (removedIds.length > 0) onRemoveConnections(removedIds);
|
||||||
|
}, [onRemoveConnections]);
|
||||||
|
|
||||||
|
const handleConnect = useCallback((connection: Connection) => {
|
||||||
|
if (!connection.source || !connection.target) return;
|
||||||
|
onConnect(connection.source, connection.target);
|
||||||
|
}, [onConnect]);
|
||||||
|
|
||||||
|
const handleMoveEnd = useCallback((_event: MouseEvent | TouchEvent | null, nextViewport: FlowViewport) => {
|
||||||
|
if (!movingRef.current) return;
|
||||||
|
movingRef.current = false;
|
||||||
|
const committedViewport = clampViewport(nextViewport, minZoom, maxZoom);
|
||||||
|
onViewportCommit(committedViewport);
|
||||||
|
}, [maxZoom, minZoom, onViewportCommit]);
|
||||||
|
|
||||||
|
const handleNodeDragStart = useCallback(() => {
|
||||||
|
draggingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeDragStop = useCallback(() => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
commitCurrentNodePositions({ history: true, dirty: true });
|
||||||
|
}, [commitCurrentNodePositions]);
|
||||||
|
|
||||||
|
const handleMoveStart = useCallback(() => {
|
||||||
|
movingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaneReactFlowClick = useCallback((event: React.MouseEvent) => {
|
||||||
|
const point = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
if (event.detail >= 2) {
|
||||||
|
if (paneClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(paneClickTimerRef.current);
|
||||||
|
paneClickTimerRef.current = null;
|
||||||
|
}
|
||||||
|
onPaneDoubleClick(reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY }, { snapToGrid: true, snapGrid: SNAP_GRID }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (paneClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(paneClickTimerRef.current);
|
||||||
|
}
|
||||||
|
paneClickTimerRef.current = window.setTimeout(() => {
|
||||||
|
paneClickTimerRef.current = null;
|
||||||
|
onPaneClick(point, event);
|
||||||
|
}, 180);
|
||||||
|
}, [onPaneClick, onPaneDoubleClick, reactFlow]);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: CanvasFlowNode[] }) => {
|
||||||
|
onSelectionChange(selectedNodes.map(node => node.id));
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
const getMiniMapNodeColor = useCallback((node: FlowNode) => (
|
||||||
|
selectedNodeIds.includes(node.id) ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'
|
||||||
|
), [selectedNodeIds]);
|
||||||
|
|
||||||
|
const handleInit = useCallback((instance: ReactFlowInstance<CanvasFlowNode, Edge>) => {
|
||||||
|
onReady?.({
|
||||||
|
setViewport: (nextViewport) => instance.setViewport(nextViewport, { duration: 120 }),
|
||||||
|
zoomTo: (zoom) => instance.zoomTo(Math.min(maxZoom, Math.max(minZoom, zoom)), { duration: 120 }),
|
||||||
|
fitBounds: (bounds, options) => instance.fitBounds(bounds, options),
|
||||||
|
getViewport: () => clampViewport(instance.getViewport(), minZoom, maxZoom),
|
||||||
|
});
|
||||||
|
}, [maxZoom, minZoom, onReady]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
className="canvas-flow"
|
||||||
|
nodes={flowNodes}
|
||||||
|
edges={flowEdges}
|
||||||
|
nodeTypes={CANVAS_NODE_TYPES}
|
||||||
|
minZoom={minZoom}
|
||||||
|
maxZoom={maxZoom}
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={SNAP_GRID}
|
||||||
|
defaultViewport={viewport}
|
||||||
|
zoomOnScroll
|
||||||
|
zoomOnPinch
|
||||||
|
panOnScroll={false}
|
||||||
|
panOnDrag
|
||||||
|
selectionOnDrag={false}
|
||||||
|
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
|
||||||
|
deleteKeyCode={null}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
nodesDraggable={editable}
|
||||||
|
nodesConnectable={editable}
|
||||||
|
nodesFocusable={false}
|
||||||
|
edgesFocusable={false}
|
||||||
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
|
fitViewOptions={FLOW_FIT_VIEW_OPTIONS}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onConnect={handleConnect}
|
||||||
|
onNodeDragStart={handleNodeDragStart}
|
||||||
|
onNodeDragStop={handleNodeDragStop}
|
||||||
|
onMoveStart={handleMoveStart}
|
||||||
|
onMoveEnd={handleMoveEnd}
|
||||||
|
onPaneClick={handlePaneReactFlowClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
onInit={handleInit}
|
||||||
|
>
|
||||||
|
<Background gap={20} size={1} color="hsl(var(--border))" />
|
||||||
|
<Background gap={100} size={1.2} color="hsl(var(--muted-foreground))" />
|
||||||
|
<Controls showInteractive={false} position="bottom-left" />
|
||||||
|
<MiniMap
|
||||||
|
position="bottom-right"
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
nodeColor={getMiniMapNodeColor}
|
||||||
|
maskColor="hsl(var(--background) / 0.58)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactFlowCanvas(props: ReactFlowCanvasProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<FlowCanvasInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
LogOut,
|
LogOut,
|
||||||
Shield,
|
Shield,
|
||||||
|
PanelsTopLeft,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -26,6 +27,7 @@ const navItems = [
|
|||||||
{ href: '/', label: '首页', icon: Sparkles },
|
{ href: '/', label: '首页', icon: Sparkles },
|
||||||
{ href: '/create', label: '创作', icon: Brush },
|
{ href: '/create', label: '创作', icon: Brush },
|
||||||
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
|
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
|
||||||
|
{ href: '/canvas', label: '画布', icon: PanelsTopLeft },
|
||||||
{ href: '/profile', label: '我的', icon: User },
|
{ href: '/profile', label: '我的', icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
150
src/lib/canvas-store.ts
Executable file
150
src/lib/canvas-store.ts
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
import { getDbClient } from '@/storage/database/local-db';
|
||||||
|
import { createEmptyCanvasState, type CanvasProject, type CanvasProjectState } from '@/lib/canvas-types';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCanvasState(value: unknown): CanvasProjectState {
|
||||||
|
if (!isRecord(value)) return createEmptyCanvasState();
|
||||||
|
const fallback = createEmptyCanvasState();
|
||||||
|
const viewport = isRecord(value.viewport) ? value.viewport : fallback.viewport;
|
||||||
|
return {
|
||||||
|
nodes: Array.isArray(value.nodes) ? value.nodes as CanvasProjectState['nodes'] : [],
|
||||||
|
connections: Array.isArray(value.connections) ? value.connections as CanvasProjectState['connections'] : [],
|
||||||
|
assets: Array.isArray(value.assets) ? value.assets as CanvasProjectState['assets'] : [],
|
||||||
|
viewport: {
|
||||||
|
x: typeof viewport.x === 'number' ? viewport.x : 0,
|
||||||
|
y: typeof viewport.y === 'number' ? viewport.y : 0,
|
||||||
|
zoom: typeof viewport.zoom === 'number' ? Math.min(3, Math.max(0.2, viewport.zoom)) : 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCanvasSchema() {
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL DEFAULT '未命名画布',
|
||||||
|
state jsonb NOT NULL DEFAULT '{"nodes":[],"assets":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await client.query('CREATE INDEX IF NOT EXISTS idx_canvas_projects_user_updated ON canvas_projects(user_id, updated_at DESC)');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProject(row: Record<string, unknown>): CanvasProject {
|
||||||
|
return {
|
||||||
|
id: String(row.id || ''),
|
||||||
|
userId: String(row.user_id || ''),
|
||||||
|
title: String(row.title || '未命名画布'),
|
||||||
|
state: normalizeCanvasState(row.state),
|
||||||
|
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at || ''),
|
||||||
|
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCanvasProjects(userId: string): Promise<CanvasProject[]> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT id, user_id, title, state, created_at, updated_at
|
||||||
|
FROM canvas_projects
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows.map(mapProject);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCanvasProject(userId: string, title = '未命名画布'): Promise<CanvasProject> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const result = await client.query(
|
||||||
|
`INSERT INTO canvas_projects (id, user_id, title, state)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb)
|
||||||
|
RETURNING id, user_id, title, state, created_at, updated_at`,
|
||||||
|
[id, userId, title.trim() || '未命名画布', JSON.stringify(createEmptyCanvasState())],
|
||||||
|
);
|
||||||
|
return mapProject(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCanvasProject(userId: string, id: string): Promise<CanvasProject | null> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT id, user_id, title, state, created_at, updated_at
|
||||||
|
FROM canvas_projects
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCanvasProject(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
values: { title?: string; state?: CanvasProjectState },
|
||||||
|
): Promise<CanvasProject | null> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const current = await client.query(
|
||||||
|
'SELECT title, state FROM canvas_projects WHERE id = $1 AND user_id = $2 LIMIT 1',
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
if (current.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const title = typeof values.title === 'string' && values.title.trim()
|
||||||
|
? values.title.trim()
|
||||||
|
: current.rows[0].title;
|
||||||
|
const state = values.state ? normalizeCanvasState(values.state) : normalizeCanvasState(current.rows[0].state);
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE canvas_projects
|
||||||
|
SET title = $3, state = $4::jsonb, updated_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING id, user_id, title, state, created_at, updated_at`,
|
||||||
|
[id, userId, title, JSON.stringify(state)],
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCanvasProject(userId: string, id: string): Promise<boolean> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
'DELETE FROM canvas_projects WHERE id = $1 AND user_id = $2',
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
return (result.rowCount || 0) > 0;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/lib/canvas-types.ts
Executable file
97
src/lib/canvas-types.ts
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
export type CanvasNodeType = 'text' | 'image' | 'text2img' | 'img2img' | 'layeredImage' | 'frame';
|
||||||
|
|
||||||
|
export type CanvasViewport = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
zoom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasAsset = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: 'image';
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasConnection = {
|
||||||
|
id: string;
|
||||||
|
sourceNodeId: string;
|
||||||
|
targetNodeId: string;
|
||||||
|
label?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasLayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'background' | 'element' | 'icon' | 'text' | 'effect';
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
assetUrl?: string;
|
||||||
|
text?: string;
|
||||||
|
color?: string;
|
||||||
|
opacity?: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNode = {
|
||||||
|
id: string;
|
||||||
|
type: CanvasNodeType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
zIndex: number;
|
||||||
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
prompt?: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
text?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
referenceImage?: string;
|
||||||
|
outputImages?: string[];
|
||||||
|
selectedOutput?: string;
|
||||||
|
status?: 'idle' | 'generating' | 'failed' | 'succeeded';
|
||||||
|
error?: string;
|
||||||
|
params?: {
|
||||||
|
aspectRatio?: string;
|
||||||
|
resolution?: string;
|
||||||
|
count?: number;
|
||||||
|
strength?: number;
|
||||||
|
model?: string;
|
||||||
|
modelLabel?: string;
|
||||||
|
apiType?: 'stream' | 'sync';
|
||||||
|
};
|
||||||
|
layers?: CanvasLayer[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasProjectState = {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
assets: CanvasAsset[];
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasProject = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
state: CanvasProjectState;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEmptyCanvasState(): CanvasProjectState {
|
||||||
|
return {
|
||||||
|
nodes: [],
|
||||||
|
connections: [],
|
||||||
|
assets: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user