feat: add CanvasPathEditor and editable path functionality
- Implemented CanvasPathEditor component for editing paths on the canvas. - Created editablePath module to handle path data manipulation and conversion to SVG. - Introduced pathPen module for managing path drawing with a pen tool, including anchor management and path data generation. - Added tests for editablePath and pathPen functionalities to ensure correctness of path editing and drawing behavior.
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@leafer-in/editor": "2.2.2",
|
||||
"@leafer-ui/core": "2.2.2",
|
||||
"@leafer-ui/interface": "2.2.2",
|
||||
"@node-projects/acad-ts": "2.3.0",
|
||||
"@radix-ui/react-context-menu": "^2.3.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||
@@ -21,6 +24,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"ag-psd": "31.0.2",
|
||||
"leafer-ui": "2.2.2",
|
||||
"leafer-x-path-editor": "^1.1.3",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.7",
|
||||
@@ -34,5 +38,13 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"vite-plugin-compression2": "^2.5.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"leafer-x-path-editor>@leafer-in/state": "2.2.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"leafer-x-path-editor@1.1.3": "patches/leafer-x-path-editor@1.1.3.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Generated
+77
@@ -4,7 +4,24 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
leafer-x-path-editor>@leafer-in/state: 2.2.2
|
||||
|
||||
patchedDependencies:
|
||||
leafer-x-path-editor@1.1.3:
|
||||
hash: ypuxjyaot5mkx7bppr2oqh3qu4
|
||||
path: patches/leafer-x-path-editor@1.1.3.patch
|
||||
|
||||
dependencies:
|
||||
'@leafer-in/editor':
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2(@leafer-in/interface@2.2.2)(@leafer-in/resize@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/draw@2.2.2)(@leafer-ui/interface@2.2.2)
|
||||
'@leafer-ui/core':
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2
|
||||
'@leafer-ui/interface':
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2
|
||||
'@node-projects/acad-ts':
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -32,6 +49,9 @@ dependencies:
|
||||
leafer-ui:
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2
|
||||
leafer-x-path-editor:
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(patch_hash=ypuxjyaot5mkx7bppr2oqh3qu4)(@leafer-in/editor@2.2.2)(@leafer-in/interface@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/interface@2.2.2)
|
||||
lucide-react:
|
||||
specifier: ^1.23.0
|
||||
version: 1.23.0(react@19.2.7)
|
||||
@@ -356,6 +376,22 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@leafer-in/editor@2.2.2(@leafer-in/interface@2.2.2)(@leafer-in/resize@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/draw@2.2.2)(@leafer-ui/interface@2.2.2):
|
||||
resolution: {integrity: sha512-HSYZ/WUr3c4XvUySJtSKBlwoZCBrqFtTb9i9dguZuhknKE54KXEkx1mizTjLyN0NN67PHPhpIxh+3U4G9o8ZmA==}
|
||||
peerDependencies:
|
||||
'@leafer-in/interface': ^2.2.2
|
||||
'@leafer-in/resize': ^2.2.2
|
||||
'@leafer-ui/core': ^2.2.2
|
||||
'@leafer-ui/draw': ^2.2.2
|
||||
'@leafer-ui/interface': ^2.2.2
|
||||
dependencies:
|
||||
'@leafer-in/interface': 2.2.2(@leafer-ui/interface@2.2.2)(@leafer/interface@2.2.2)
|
||||
'@leafer-in/resize': 2.2.2(@leafer-in/interface@2.2.2)(@leafer-ui/draw@2.2.2)(@leafer-ui/interface@2.2.2)
|
||||
'@leafer-ui/core': 2.2.2
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/interface': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@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:
|
||||
@@ -366,6 +402,30 @@ packages:
|
||||
'@leafer/interface': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-in/resize@2.2.2(@leafer-in/interface@2.2.2)(@leafer-ui/draw@2.2.2)(@leafer-ui/interface@2.2.2):
|
||||
resolution: {integrity: sha512-h1u3TIgODBb+RAXwvhBcTqW3Yprbb6sJm7sCDtvXCewxcGSJ2jz6Gb9TaZ4SY1Nj/6aRKK+9Eplw5AWWeTwCNQ==}
|
||||
peerDependencies:
|
||||
'@leafer-in/interface': ^2.2.2
|
||||
'@leafer-ui/draw': ^2.2.2
|
||||
'@leafer-ui/interface': ^2.2.2
|
||||
dependencies:
|
||||
'@leafer-in/interface': 2.2.2(@leafer-ui/interface@2.2.2)(@leafer/interface@2.2.2)
|
||||
'@leafer-ui/draw': 2.2.2
|
||||
'@leafer-ui/interface': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-in/state@2.2.2(@leafer-in/interface@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/interface@2.2.2):
|
||||
resolution: {integrity: sha512-s8VjDuTdYA5yUVLfY207RZ1x9879e0ogk8YZOFVKMf5hT5MNDcOqdbrhs82uVo7v4MpetyAR6VyJOMcdvjmEmw==}
|
||||
peerDependencies:
|
||||
'@leafer-in/interface': ^2.2.2
|
||||
'@leafer-ui/core': ^2.2.2
|
||||
'@leafer-ui/interface': ^2.2.2
|
||||
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/interface': 2.2.2
|
||||
dev: false
|
||||
|
||||
/@leafer-ui/app@2.2.2:
|
||||
resolution: {integrity: sha512-mN0ItJqbtJPwWiqo7rvl8GpVGMNUd8/VgZ0MUZxad8kxkKkjnnCoawczj+URF61ebDgO2U6OUSnhi4bPSmh5QQ==}
|
||||
dependencies:
|
||||
@@ -1732,6 +1792,23 @@ packages:
|
||||
'@leafer/partner': 2.2.2
|
||||
dev: false
|
||||
|
||||
/leafer-x-path-editor@1.1.3(patch_hash=ypuxjyaot5mkx7bppr2oqh3qu4)(@leafer-in/editor@2.2.2)(@leafer-in/interface@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/interface@2.2.2):
|
||||
resolution: {integrity: sha512-blqVMWuatQjEO+FAsUNCXX+k1ynV4dFX4M0iwzVPQpUQICgMs/j5xj9FLokyPat6eCJ41wtl9RJkID5F3NMiUw==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@leafer-in/editor': 1.1.0
|
||||
'@leafer-ui/core': 1.1.0
|
||||
'@leafer-ui/interface': 1.1.0
|
||||
dependencies:
|
||||
'@leafer-in/editor': 2.2.2(@leafer-in/interface@2.2.2)(@leafer-in/resize@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/draw@2.2.2)(@leafer-ui/interface@2.2.2)
|
||||
'@leafer-in/state': 2.2.2(@leafer-in/interface@2.2.2)(@leafer-ui/core@2.2.2)(@leafer-ui/interface@2.2.2)
|
||||
'@leafer-ui/core': 2.2.2
|
||||
'@leafer-ui/interface': 2.2.2
|
||||
transitivePeerDependencies:
|
||||
- '@leafer-in/interface'
|
||||
dev: false
|
||||
patched: true
|
||||
|
||||
/lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<g transform="translate(2 2) scale(.78)" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g stroke="#fff" stroke-width="5">
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
</g>
|
||||
<g stroke="#111827" stroke-width="2">
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1004 B |
@@ -372,9 +372,12 @@
|
||||
"select": "Select",
|
||||
"pan": "Move",
|
||||
"pin": "Pin",
|
||||
"pen": "Pen",
|
||||
"penColor": "Pen color",
|
||||
"penWidth": "Pen width",
|
||||
"pen": "Brush",
|
||||
"penColor": "Brush color",
|
||||
"penWidth": "Brush width",
|
||||
"pathPen": "Pen",
|
||||
"pathPenColor": "Pen color",
|
||||
"pathPenWidth": "Pen width",
|
||||
"grid": "Grid",
|
||||
"smartArtboard": "Smart artboard",
|
||||
"shapeTool": "Shapes",
|
||||
@@ -451,7 +454,7 @@
|
||||
"imageGeneratorNode": "Image Generator",
|
||||
"newImageNode": "Image direction",
|
||||
"newFrameNode": "Website artboard",
|
||||
"newPenStrokeNode": "Pen stroke",
|
||||
"newPenStrokeNode": "Brush stroke",
|
||||
"newTextNode": "Text note",
|
||||
"newTextNodeContent": "Capture a design decision, revision, or next task.",
|
||||
"layers": "Layers",
|
||||
|
||||
@@ -375,6 +375,9 @@
|
||||
"pen": "画笔",
|
||||
"penColor": "画笔颜色",
|
||||
"penWidth": "画笔粗细",
|
||||
"pathPen": "钢笔",
|
||||
"pathPenColor": "钢笔颜色",
|
||||
"pathPenWidth": "钢笔粗细",
|
||||
"grid": "网格",
|
||||
"smartArtboard": "智能画板",
|
||||
"shapeTool": "形状",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ResolveShareResponse } from "@/domain/design";
|
||||
import { readRoute, type AppRoute } from "@/application/route";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { designGateway } from "@/infrastructure/designGateway";
|
||||
import { httpStatusOf } from "@/infrastructure/userMessages";
|
||||
import { BrandMark } from "@/ui/components/BrandMark";
|
||||
import { CanvasLoading } from "@/ui/components/CanvasLoading";
|
||||
import { BrandKitRoutePage } from "@/ui/pages/BrandKitRoutePage";
|
||||
import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace";
|
||||
import { HomePage } from "@/ui/pages/HomePage";
|
||||
@@ -15,6 +16,7 @@ import { ProjectsRoutePage } from "@/ui/pages/ProjectsRoutePage";
|
||||
import { useAuth } from "@/ui/auth/AuthProvider";
|
||||
|
||||
export function App() {
|
||||
const { t } = useI18n();
|
||||
const { isAuthenticated, openLogin } = useAuth();
|
||||
const [route, setRoute] = useState<AppRoute | null>(null);
|
||||
|
||||
@@ -36,11 +38,7 @@ export function App() {
|
||||
}, [isAuthenticated, openLogin, route]);
|
||||
|
||||
if (!route) {
|
||||
return (
|
||||
<div className="workspace-loading">
|
||||
<Loader2 className="spin" size={28} />
|
||||
</div>
|
||||
);
|
||||
return <CanvasLoading label={t("loadingCanvas")} />;
|
||||
}
|
||||
|
||||
if (route.name === "project" && !isAuthenticated) return <ProjectAuthPreview />;
|
||||
|
||||
@@ -3,13 +3,26 @@ const leftPath =
|
||||
const rightPath =
|
||||
"M 171.40 409.52 Q 173.04 407.96 173.02 405.28 Q 172.88 380.57 173.02 257.61 C 173.03 254.95 173.42 249.20 171.72 247.31 A 1.01 1.01 0.0 0 0 170.08 247.49 Q 157.08 270.82 136.42 308.16 Q 134.66 311.34 133.26 313.46 A 2.57 2.55 -66.0 0 1 130.48 314.54 Q 129.04 314.16 128.36 312.36 Q 125.76 305.44 124.12 301.86 C 121.68 296.53 120.76 290.88 123.21 286.47 Q 140.14 255.98 175.72 191.77 C 178.52 186.73 184.70 186.37 187.87 191.19 C 192.18 197.72 191.27 205.58 191.28 215.25 Q 191.33 274.55 191.27 424.25 C 191.27 433.88 190.40 450.35 178.83 453.37 C 173.85 454.68 169.40 451.23 166.85 446.89 Q 158.36 432.43 124.69 375.24 Q 122.02 370.72 121.70 368.33 Q 121.21 364.73 122.32 360.78 Q 122.58 359.85 128.49 345.57 Q 129.14 344.02 130.77 344.06 C 132.27 344.10 133.03 345.35 133.75 346.59 Q 143.27 363.04 169.58 408.65 Q 169.89 409.19 170.38 409.57 A 0.78 0.78 0.0 0 0 171.40 409.52 Z";
|
||||
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
export function BrandMark({ className, gradient = false }: { className?: string; gradient?: boolean }) {
|
||||
const fillValue = gradient ? "url(#brand-mark-gradient-id)" : "currentColor";
|
||||
const strokeValue = gradient ? "url(#brand-mark-gradient-id)" : "currentColor";
|
||||
return (
|
||||
<svg className={className} viewBox="8 184 198 276" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(107 322) scale(1.16 1) translate(-107 -322)" stroke="currentColor" strokeWidth={9} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path fill="currentColor" d={leftPath} />
|
||||
<path fill="currentColor" d={rightPath} />
|
||||
<ellipse fill="currentColor" cx="0" cy="0" transform="translate(106.95 328.63) rotate(-90)" rx="23.87" ry="13.32" />
|
||||
{gradient && (
|
||||
<defs>
|
||||
<linearGradient id="brand-mark-gradient-id" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#4F46E5" />
|
||||
<stop offset="50%" stopColor="#7C3AED" />
|
||||
<stop offset="100%" stopColor="#EC4899" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<g transform="translate(107 322) scale(1.16 1) translate(-107 -322)" stroke={strokeValue} strokeWidth={9} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path className="brand-mark-left" fill={fillValue} d={leftPath} />
|
||||
<path className="brand-mark-right" fill={fillValue} d={rightPath} />
|
||||
<g transform="translate(106.95 328.63) rotate(-90)">
|
||||
<ellipse className="brand-mark-center" fill={fillValue} cx="0" cy="0" rx="23.87" ry="13.32" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
.canvas-loading {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: #faf9f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Infinite canvas dot grid overlay */
|
||||
.canvas-loading-grid-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(17, 24, 39, 0.035) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Glassmorphism content card */
|
||||
.canvas-loading-card {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 28px;
|
||||
padding: 48px 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow:
|
||||
0 32px 64px -12px rgba(17, 24, 39, 0.05),
|
||||
0 16px 32px -8px rgba(17, 24, 39, 0.03),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.canvas-loading-brand-wrapper {
|
||||
margin-bottom: 28px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas-loading-brand-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-loading-spinner-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: spinner-rotate 1.6s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.canvas-loading-brand {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 4px 8px rgba(17, 24, 39, 0.06));
|
||||
animation: logo-breath 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Progress bar container */
|
||||
.canvas-loading-progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.canvas-loading-progress-track {
|
||||
width: 240px;
|
||||
height: 6px;
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-loading-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: #111827;
|
||||
transition: width 0.3s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-loading-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
|
||||
animation: progress-shimmer 1.8s infinite;
|
||||
}
|
||||
|
||||
.canvas-loading-percentage {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Status steps text */
|
||||
.canvas-loading-status-container {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-loading-step-text {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.01em;
|
||||
animation: text-fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spinner-rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-breath {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 4px 8px rgba(17, 24, 39, 0.06));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
filter: drop-shadow(0 8px 16px rgba(17, 24, 39, 0.12));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes text-fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { BrandMark } from "@/ui/components/BrandMark";
|
||||
|
||||
export function CanvasLoading({ label }: { label?: string }) {
|
||||
const { locale } = useI18n();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
|
||||
const stepsZh = [
|
||||
"正在初始化工作区...",
|
||||
"正在连接画布服务...",
|
||||
"正在加载画布模块...",
|
||||
"正在绘制图层与元素...",
|
||||
label || "正在打开画布..."
|
||||
];
|
||||
|
||||
const stepsEn = [
|
||||
"Initializing workspace...",
|
||||
"Connecting to canvas gateway...",
|
||||
"Loading canvas modules...",
|
||||
"Rendering layers and elements...",
|
||||
label || "Opening canvas..."
|
||||
];
|
||||
|
||||
const steps = locale === "zh-CN" ? stepsZh : stepsEn;
|
||||
const currentStep = steps[stepIndex] || label || "";
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const startTime = Date.now();
|
||||
const duration = 2400; // Simulated duration in ms
|
||||
|
||||
const tick = () => {
|
||||
if (!active) return;
|
||||
const elapsed = Date.now() - startTime;
|
||||
const ratio = Math.min(elapsed / duration, 1);
|
||||
// Ease-out cubic
|
||||
const easeOutRatio = 1 - Math.pow(1 - ratio, 3);
|
||||
const currentProgress = Math.floor(easeOutRatio * 98);
|
||||
|
||||
setProgress(currentProgress);
|
||||
|
||||
if (currentProgress < 20) {
|
||||
setStepIndex(0);
|
||||
} else if (currentProgress < 45) {
|
||||
setStepIndex(1);
|
||||
} else if (currentProgress < 70) {
|
||||
setStepIndex(2);
|
||||
} else if (currentProgress < 90) {
|
||||
setStepIndex(3);
|
||||
} else {
|
||||
setStepIndex(4);
|
||||
}
|
||||
|
||||
if (ratio < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="canvas-loading" role="status" aria-live="polite" aria-busy="true">
|
||||
<div className="canvas-loading-grid-overlay" />
|
||||
|
||||
<div className="canvas-loading-card">
|
||||
<div className="canvas-loading-brand-wrapper">
|
||||
<div className="canvas-loading-brand-container">
|
||||
<svg className="canvas-loading-spinner-ring" viewBox="0 0 100 100">
|
||||
<circle className="spinner-track" cx="50" cy="50" r="45" fill="none" stroke="rgba(17, 24, 39, 0.05)" strokeWidth="3.5" />
|
||||
<circle className="spinner-head" cx="50" cy="50" r="45" fill="none" stroke="#111827" strokeWidth="3.5" strokeDasharray="282" strokeDashoffset="210" strokeLinecap="round" />
|
||||
</svg>
|
||||
<BrandMark className="canvas-loading-brand" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="canvas-loading-progress-container">
|
||||
<div className="canvas-loading-progress-track">
|
||||
<div
|
||||
className="canvas-loading-progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="canvas-loading-percentage">{progress}%</span>
|
||||
</div>
|
||||
|
||||
<div className="canvas-loading-status-container">
|
||||
<span className="canvas-loading-step-text" key={stepIndex}>{currentStep}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type MutableRefObject, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { CanvasNode, CanvasViewport } from "@/domain/design";
|
||||
import { editablePathForNode, type EditablePathData } from "./editablePath";
|
||||
|
||||
type LeaferEditorApp = import("leafer-ui").App & { editor: import("@leafer-in/editor").Editor };
|
||||
|
||||
export type CanvasPathEditorControl = {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export function CanvasPathEditor({
|
||||
node,
|
||||
viewport,
|
||||
stageSize,
|
||||
controlRef,
|
||||
onCommit,
|
||||
onClose
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
viewport: CanvasViewport;
|
||||
stageSize: { width: number; height: number };
|
||||
controlRef: MutableRefObject<CanvasPathEditorControl | null>;
|
||||
onCommit: (pathData: EditablePathData) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<import("leafer-ui").App | null>(null);
|
||||
const viewportRef = useRef(viewport);
|
||||
const latestPathRef = useRef<EditablePathData | null>(null);
|
||||
const commitRef = useRef(onCommit);
|
||||
const closeRef = useRef(onClose);
|
||||
|
||||
viewportRef.current = viewport;
|
||||
commitRef.current = onCommit;
|
||||
closeRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
const initialPath = editablePathForNode(node);
|
||||
if (!mount || !initialPath) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let closeRequested = false;
|
||||
let app: LeaferEditorApp | null = null;
|
||||
const closeEditor = () => {
|
||||
closeRequested = true;
|
||||
app?.editor.closeInnerEditor();
|
||||
};
|
||||
controlRef.current = { close: closeEditor };
|
||||
|
||||
const dispatchLegacyKey = (type: "keydown" | "keyup", keyCode: number) => {
|
||||
const synthetic = new KeyboardEvent(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(synthetic, {
|
||||
keyCode: { value: keyCode },
|
||||
which: { value: keyCode }
|
||||
});
|
||||
document.dispatchEvent(synthetic);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeEditor();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Enter" || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
dispatchLegacyKey("keydown", 8);
|
||||
dispatchLegacyKey("keyup", 8);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
|
||||
void (async () => {
|
||||
const editorModule = await import("@leafer-in/editor");
|
||||
const pathEditorModule = await import("leafer-x-path-editor");
|
||||
const leaferModule = await import("leafer-ui");
|
||||
if (disposed) return;
|
||||
|
||||
const nextApp = new leaferModule.App({
|
||||
view: mount,
|
||||
fill: "transparent",
|
||||
editor: { openInner: "double", lockRatio: false },
|
||||
wheel: { disabled: true },
|
||||
touch: { preventDefault: true }
|
||||
}) as LeaferEditorApp;
|
||||
app = nextApp;
|
||||
appRef.current = nextApp;
|
||||
const path = new leaferModule.Path({
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
path: initialPath,
|
||||
fill: node.fillColor || "transparent",
|
||||
stroke: node.strokeColor || "#111827",
|
||||
strokeWidth: Math.max(node.strokeWidth ?? 2, 0),
|
||||
dashPattern: strokeDashPattern(node.strokeStyle, node.strokeWidth),
|
||||
opacity: typeof node.opacity === "number" ? node.opacity : 1,
|
||||
editable: true
|
||||
});
|
||||
applyViewport(nextApp, viewportRef.current);
|
||||
nextApp.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 });
|
||||
nextApp.tree.add(path);
|
||||
|
||||
nextApp.editor.on(pathEditorModule.PathEditorEvent.CHANGE, (event: import("leafer-x-path-editor").PathEditorEvent) => {
|
||||
const value = event.value as import("leafer-ui").Path;
|
||||
latestPathRef.current = [...value.getPath()];
|
||||
});
|
||||
nextApp.editor.on(editorModule.InnerEditorEvent.CLOSE, () => {
|
||||
if (disposed) return;
|
||||
const pathData = latestPathRef.current ?? [...path.getPath()];
|
||||
commitRef.current(pathData);
|
||||
closeRef.current();
|
||||
});
|
||||
nextApp.editor.target = path;
|
||||
nextApp.editor.openInnerEditor(path);
|
||||
if (closeRequested) nextApp.editor.closeInnerEditor();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
controlRef.current = null;
|
||||
appRef.current?.destroy();
|
||||
appRef.current = null;
|
||||
};
|
||||
}, [controlRef, node.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const app = appRef.current;
|
||||
if (!app) return;
|
||||
applyViewport(app, viewport);
|
||||
}, [viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
appRef.current?.resize({ width: stageSize.width, height: stageSize.height, pixelRatio: window.devicePixelRatio || 1 });
|
||||
}, [stageSize.height, stageSize.width]);
|
||||
|
||||
const stopPropagation = (event: ReactPointerEvent<HTMLDivElement>) => event.stopPropagation();
|
||||
return <div ref={mountRef} className="canvas-path-editor-overlay" onPointerDown={stopPropagation} />;
|
||||
}
|
||||
|
||||
function applyViewport(app: import("leafer-ui").App, viewport: CanvasViewport) {
|
||||
app.tree.set({ x: viewport.x, y: viewport.y, scaleX: viewport.k, scaleY: viewport.k });
|
||||
}
|
||||
|
||||
function strokeDashPattern(style: string | undefined, width = 1) {
|
||||
if (style === "dashed") return [Math.max(width * 3, 8), Math.max(width * 2, 6)];
|
||||
if (style === "dotted") return [1, Math.max(width * 2, 6)];
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { PenLine } from "lucide-react";
|
||||
import { PenLine, PenTool as PenToolIcon } from "lucide-react";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import type { CanvasNode, CanvasViewport } from "@/domain/design";
|
||||
import { CanvasColorPopover } from "./CanvasColorPopover";
|
||||
import { isPathPenNearStart, pathPenDraftToScreenPath, type PathPenDraft } from "./pathPen";
|
||||
|
||||
export type PenSettings = {
|
||||
color: string;
|
||||
@@ -129,22 +130,46 @@ export function PenStrokeDraftOverlay({ draft }: { draft: PenDraft | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PathPenDraftOverlay({ draft }: { draft: PathPenDraft | null }) {
|
||||
if (!draft || draft.anchors.length === 0) return null;
|
||||
const closeReady = Boolean(draft.hover && isPathPenNearStart(draft, draft.hover.screen));
|
||||
|
||||
return (
|
||||
<svg className="path-pen-draft-overlay" aria-hidden="true">
|
||||
<path className="path-pen-draft-path" d={pathPenDraftToScreenPath(draft)} fill="none" stroke={draft.color} strokeWidth={draft.width} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{draft.anchors.map((anchor, index) => (
|
||||
<g key={index}>
|
||||
{anchor.handleIn && <line className="path-pen-handle-line" x1={anchor.handleIn.screen.x} y1={anchor.handleIn.screen.y} x2={anchor.screen.x} y2={anchor.screen.y} />}
|
||||
{anchor.handleOut && <line className="path-pen-handle-line" x1={anchor.screen.x} y1={anchor.screen.y} x2={anchor.handleOut.screen.x} y2={anchor.handleOut.screen.y} />}
|
||||
{anchor.handleIn && <circle className="path-pen-handle" cx={anchor.handleIn.screen.x} cy={anchor.handleIn.screen.y} r={3} />}
|
||||
{anchor.handleOut && <circle className="path-pen-handle" cx={anchor.handleOut.screen.x} cy={anchor.handleOut.screen.y} r={3} />}
|
||||
<circle className={`path-pen-anchor ${index === 0 ? "is-first" : ""} ${index === 0 && closeReady ? "is-close-target" : ""}`} cx={anchor.screen.x} cy={anchor.screen.y} r={index === 0 && closeReady ? 5 : 4} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PenToolControls({
|
||||
settings,
|
||||
variant = "brush",
|
||||
offsetBy,
|
||||
onChange
|
||||
}: {
|
||||
settings: PenSettings;
|
||||
variant?: "brush" | "path";
|
||||
offsetBy: "files" | "layers" | null;
|
||||
onChange: (settings: PenSettings) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const colorLabel = t(variant === "path" ? "pathPenColor" : "penColor");
|
||||
const widthLabel = t(variant === "path" ? "pathPenWidth" : "penWidth");
|
||||
const offsetClass = offsetBy === "files" ? "is-offset-by-files-panel" : offsetBy === "layers" ? "is-offset-by-layers-panel" : "";
|
||||
|
||||
return (
|
||||
<div className={`pen-tool-controls ${offsetClass}`} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<CanvasColorPopover
|
||||
title={t("penColor")}
|
||||
title={colorLabel}
|
||||
value={settings.color}
|
||||
opacity={100}
|
||||
showOpacity={false}
|
||||
@@ -152,16 +177,16 @@ export function PenToolControls({
|
||||
onColorChange={(color) => onChange({ ...settings, color })}
|
||||
onOpacityChange={() => undefined}
|
||||
>
|
||||
<button type="button" className="pen-color-trigger" aria-label={t("penColor")} title={t("penColor")}>
|
||||
<button type="button" className="pen-color-trigger" aria-label={colorLabel} title={colorLabel}>
|
||||
<span style={{ background: settings.color }} />
|
||||
</button>
|
||||
</CanvasColorPopover>
|
||||
<span className="pen-tool-separator" />
|
||||
<PenLine size={18} />
|
||||
{variant === "path" ? <PenToolIcon size={18} /> : <PenLine size={18} />}
|
||||
<input
|
||||
value={settings.width}
|
||||
inputMode="numeric"
|
||||
aria-label={t("penWidth")}
|
||||
aria-label={widthLabel}
|
||||
onChange={(event) => onChange({ ...settings, width: clamp(Number(event.target.value), 1, 80) })}
|
||||
/>
|
||||
<span>Px</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CanvasNode } from "@/domain/design";
|
||||
import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
|
||||
import { isBooleanShapeGroupNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, type BooleanShapeOperand } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
||||
import { normalizeStrokeStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
|
||||
import "./index.css";
|
||||
@@ -23,6 +24,14 @@ export function ShapeNodePreview({ node }: { node: CanvasNode }) {
|
||||
const dashArray = strokeDashArray(normalizeStrokeStyle(node.strokeStyle), strokeWidth);
|
||||
const markerId = `shape-arrow-${node.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
|
||||
if (options.kind === "path") {
|
||||
return (
|
||||
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
||||
<path d={normalizedPathDataToSvg(options.pathData, width, height)} fill={fill} fillOpacity={opacity} stroke={stroke} strokeWidth={strokeWidth} strokeDasharray={dashArray} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.kind === "line" || options.kind === "arrow") {
|
||||
const startWorld = shapeEndpointToWorldPoint(node, options.start);
|
||||
const endWorld = shapeEndpointToWorldPoint(node, options.end);
|
||||
@@ -239,6 +248,10 @@ function operandPath(operand: BooleanShapeOperand) {
|
||||
const width = Math.max(operand.width, 1);
|
||||
const height = Math.max(operand.height, 1);
|
||||
|
||||
if (options.kind === "path") {
|
||||
return normalizedPathDataToSvg(options.pathData, width, height, x, y);
|
||||
}
|
||||
|
||||
if (options.kind === "ellipse") {
|
||||
return ellipsePath(x + width / 2, y + height / 2, Math.max(width / 2 - inset, 0.5), Math.max(height / 2 - inset, 0.5));
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ShapeStyleToolbar({
|
||||
<span className="shape-style-stroke-swatch" style={{ borderColor: node.strokeColor || defaultShapeStrokeColor }} />
|
||||
</button>
|
||||
</StrokePopover>
|
||||
{options.kind !== "ellipse" && <RadiusPopover node={node} options={options} onPatch={onPatch} />}
|
||||
{options.kind !== "ellipse" && options.kind !== "path" && <RadiusPopover node={node} options={options} onPatch={onPatch} />}
|
||||
<span className="shape-style-separator" />
|
||||
<label className="shape-size-field">
|
||||
<span>{t("widthShort")}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Crop, FileUp, Grid3X3, Hand, Hash, Image as ImageIcon, MapPin, MousePointer2, PenLine, Sparkles, Type } from "lucide-react";
|
||||
import { Crop, FileUp, Grid3X3, Hand, Hash, Image as ImageIcon, MapPin, MousePointer2, PenLine, PenTool, Sparkles, Type } from "lucide-react";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { ShapeToolMenu } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolMenu";
|
||||
@@ -103,7 +103,10 @@ export function WorkspaceToolbar({
|
||||
onChooseTool={onChooseTool}
|
||||
onChooseShapeKind={onChooseShapeKind}
|
||||
/>
|
||||
<button disabled={disabled} className={activeTool === "pen" ? "active" : ""} aria-label={t("pen")} title={t("pen")} onClick={() => onChooseTool("pen")}>
|
||||
<button disabled={disabled} className={activeTool === "path" ? "active" : ""} aria-label={t("pathPen")} title={`${t("pathPen")} P`} onClick={() => onChooseTool("path")}>
|
||||
<PenTool size={18} />
|
||||
</button>
|
||||
<button disabled={disabled} className={activeTool === "pen" ? "active" : ""} aria-label={t("pen")} title={`${t("pen")} B`} onClick={() => onChooseTool("pen")}>
|
||||
<PenLine size={18} />
|
||||
</button>
|
||||
<button disabled={disabled} className={activeTool === "text" ? "active" : ""} aria-label={t("textNode")} title={`${t("textNode")} T`} onClick={() => onChooseTool("text")}>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { CanvasNode } from "@/domain/design";
|
||||
import { isShapeNode, parseShapeOptions, shapeEndpointToWorldPoint, shapeOptionsToContent } from "./shapeNode.ts";
|
||||
|
||||
export type EditablePathData = number[];
|
||||
|
||||
const command = {
|
||||
move: 1,
|
||||
line: 2,
|
||||
cubic: 5,
|
||||
quadratic: 7,
|
||||
close: 11
|
||||
} as const;
|
||||
|
||||
const ellipseControlRatio = 0.5522847498;
|
||||
|
||||
export function isEditablePathNode(node: CanvasNode) {
|
||||
return isShapeNode(node);
|
||||
}
|
||||
|
||||
export function editablePathForNode(node: CanvasNode) {
|
||||
if (isShapeNode(node)) return shapePathForEditing(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyEditedPathToNode(node: CanvasNode, pathData: EditablePathData): CanvasNode {
|
||||
if (!isEditablePathNode(node) || !isEditablePathData(pathData)) return node;
|
||||
const normalized = normalizeEditedPath(node, pathData);
|
||||
|
||||
if (isShapeNode(node)) {
|
||||
const options = parseShapeOptions(node.content);
|
||||
return {
|
||||
...node,
|
||||
x: normalized.x,
|
||||
y: normalized.y,
|
||||
width: normalized.width,
|
||||
height: normalized.height,
|
||||
content: shapeOptionsToContent({ ...options, kind: "path", pathData: normalized.pathData })
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function normalizedPathDataToSvg(pathData: EditablePathData | undefined, width: number, height: number, offsetX = 0, offsetY = 0) {
|
||||
if (!pathData || !isEditablePathData(pathData)) return "";
|
||||
return pathDataToSvg(transformPathData(pathData, (value, axis) => value * (axis === "x" ? width : height) + (axis === "x" ? offsetX : offsetY)));
|
||||
}
|
||||
|
||||
export function pathDataToSvg(pathData: EditablePathData) {
|
||||
const output: string[] = [];
|
||||
for (let index = 0; index < pathData.length; ) {
|
||||
const name = pathData[index++];
|
||||
if (name === command.move || name === command.line) {
|
||||
output.push(`${name === command.move ? "M" : "L"} ${round(pathData[index++])} ${round(pathData[index++])}`);
|
||||
continue;
|
||||
}
|
||||
if (name === command.cubic) {
|
||||
output.push(`C ${round(pathData[index++])} ${round(pathData[index++])} ${round(pathData[index++])} ${round(pathData[index++])} ${round(pathData[index++])} ${round(pathData[index++])}`);
|
||||
continue;
|
||||
}
|
||||
if (name === command.quadratic) {
|
||||
output.push(`Q ${round(pathData[index++])} ${round(pathData[index++])} ${round(pathData[index++])} ${round(pathData[index++])}`);
|
||||
continue;
|
||||
}
|
||||
if (name === command.close) {
|
||||
output.push("Z");
|
||||
continue;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return output.join(" ");
|
||||
}
|
||||
|
||||
function shapePathForEditing(node: CanvasNode) {
|
||||
const options = parseShapeOptions(node.content);
|
||||
const strokeWidth = Math.max(node.strokeWidth ?? 2, 0);
|
||||
const inset = strokeWidth / 2;
|
||||
const width = Math.max(node.width, 1);
|
||||
const height = Math.max(node.height, 1);
|
||||
|
||||
if (options.kind === "path") return normalizedPathDataToSvg(options.pathData, width, height);
|
||||
|
||||
if (options.kind === "line" || options.kind === "arrow") {
|
||||
const startWorld = shapeEndpointToWorldPoint(node, options.start);
|
||||
const endWorld = shapeEndpointToWorldPoint(node, options.end);
|
||||
const start = { x: startWorld.x - node.x, y: startWorld.y - node.y };
|
||||
const end = { x: endWorld.x - node.x, y: endWorld.y - node.y };
|
||||
if (Math.abs(options.curve) < 0.5) return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.max(Math.hypot(dx, dy), 1);
|
||||
const controlX = (start.x + end.x) / 2 + (-dy / length) * options.curve;
|
||||
const controlY = (start.y + end.y) / 2 + (dx / length) * options.curve;
|
||||
return `M ${start.x} ${start.y} Q ${controlX} ${controlY} ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
if (options.kind === "ellipse") return ellipsePath(width, height, inset);
|
||||
|
||||
if (options.kind === "polygon" || options.kind === "star") {
|
||||
const points = options.kind === "star" ? regularStarPoints(width, height, options.vertices, options.innerRadius, inset + 2) : regularPolygonPoints(width, height, options.vertices, inset + 2);
|
||||
return roundedPath(points, options.cornerRadius);
|
||||
}
|
||||
|
||||
const radius = Math.min(options.cornerRadius, width / 2, height / 2);
|
||||
const left = inset;
|
||||
const top = inset;
|
||||
const right = Math.max(left + 0.5, width - inset);
|
||||
const bottom = Math.max(top + 0.5, height - inset);
|
||||
if (radius < 0.5) return `M ${left} ${top} L ${right} ${top} L ${right} ${bottom} L ${left} ${bottom} Z`;
|
||||
return `M ${left + radius} ${top} L ${right - radius} ${top} Q ${right} ${top} ${right} ${top + radius} L ${right} ${bottom - radius} Q ${right} ${bottom} ${right - radius} ${bottom} L ${left + radius} ${bottom} Q ${left} ${bottom} ${left} ${bottom - radius} L ${left} ${top + radius} Q ${left} ${top} ${left + radius} ${top} Z`;
|
||||
}
|
||||
|
||||
function normalizeEditedPath(node: CanvasNode, pathData: EditablePathData) {
|
||||
const bounds = pathDataBounds(pathData);
|
||||
const padding = Math.max((node.strokeWidth ?? 0) / 2 + 2, 2);
|
||||
const left = Math.min(0, bounds.left - padding);
|
||||
const top = Math.min(0, bounds.top - padding);
|
||||
const right = Math.max(node.width, bounds.right + padding);
|
||||
const bottom = Math.max(node.height, bounds.bottom + padding);
|
||||
const width = Math.max(right - left, 1);
|
||||
const height = Math.max(bottom - top, 1);
|
||||
const translated = transformPathData(pathData, (value, axis) => value - (axis === "x" ? left : top));
|
||||
const normalized = transformPathData(translated, (value, axis) => value / (axis === "x" ? width : height));
|
||||
return {
|
||||
x: node.x + left,
|
||||
y: node.y + top,
|
||||
width,
|
||||
height,
|
||||
pathData: normalized
|
||||
};
|
||||
}
|
||||
|
||||
function pathDataBounds(pathData: EditablePathData) {
|
||||
const points: Array<{ x: number; y: number }> = [];
|
||||
walkPathCoordinates(pathData, (x, y) => points.push({ x, y }));
|
||||
return {
|
||||
left: Math.min(...points.map((point) => point.x)),
|
||||
top: Math.min(...points.map((point) => point.y)),
|
||||
right: Math.max(...points.map((point) => point.x)),
|
||||
bottom: Math.max(...points.map((point) => point.y))
|
||||
};
|
||||
}
|
||||
|
||||
function transformPathData(pathData: EditablePathData, transform: (value: number, axis: "x" | "y") => number) {
|
||||
const output = [...pathData];
|
||||
let axis: "x" | "y" = "x";
|
||||
for (let index = 0; index < output.length; ) {
|
||||
const name = output[index++];
|
||||
const coordinateCount = name === command.move || name === command.line ? 2 : name === command.cubic ? 6 : name === command.quadratic ? 4 : name === command.close ? 0 : -1;
|
||||
if (coordinateCount < 0) return [];
|
||||
for (let coordinate = 0; coordinate < coordinateCount; coordinate += 1) {
|
||||
axis = coordinate % 2 === 0 ? "x" : "y";
|
||||
output[index] = transform(output[index], axis);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function walkPathCoordinates(pathData: EditablePathData, visit: (x: number, y: number) => void) {
|
||||
for (let index = 0; index < pathData.length; ) {
|
||||
const name = pathData[index++];
|
||||
const coordinateCount = name === command.move || name === command.line ? 2 : name === command.cubic ? 6 : name === command.quadratic ? 4 : name === command.close ? 0 : -1;
|
||||
if (coordinateCount < 0) return;
|
||||
for (let coordinate = 0; coordinate < coordinateCount; coordinate += 2) {
|
||||
visit(pathData[index + coordinate], pathData[index + coordinate + 1]);
|
||||
}
|
||||
index += coordinateCount;
|
||||
}
|
||||
}
|
||||
|
||||
function isEditablePathData(value: unknown): value is EditablePathData {
|
||||
if (!Array.isArray(value) || value.length < 3 || value[0] !== command.move || !value.every(Number.isFinite)) return false;
|
||||
return Boolean(pathDataToSvg(value));
|
||||
}
|
||||
|
||||
function ellipsePath(width: number, height: number, inset: number) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const rx = Math.max(width / 2 - inset, 0.5);
|
||||
const ry = Math.max(height / 2 - inset, 0.5);
|
||||
const ox = rx * ellipseControlRatio;
|
||||
const oy = ry * ellipseControlRatio;
|
||||
return `M ${cx - rx} ${cy} C ${cx - rx} ${cy - oy} ${cx - ox} ${cy - ry} ${cx} ${cy - ry} C ${cx + ox} ${cy - ry} ${cx + rx} ${cy - oy} ${cx + rx} ${cy} C ${cx + rx} ${cy + oy} ${cx + ox} ${cy + ry} ${cx} ${cy + ry} C ${cx - ox} ${cy + ry} ${cx - rx} ${cy + oy} ${cx - rx} ${cy} Z`;
|
||||
}
|
||||
|
||||
function regularPolygonPoints(width: number, height: number, vertices: number, inset: number) {
|
||||
const count = Math.max(3, Math.round(vertices));
|
||||
const radius = Math.max(Math.min(width, height) / 2 - inset, 2);
|
||||
return Array.from({ length: count }, (_, index) => pointOnCircle(width, height, radius, -Math.PI / 2 + (Math.PI * 2 * index) / count));
|
||||
}
|
||||
|
||||
function regularStarPoints(width: number, height: number, vertices: number, innerRatio: number, inset: number) {
|
||||
const count = Math.max(3, Math.round(vertices));
|
||||
const outerRadius = Math.max(Math.min(width, height) / 2 - inset, 2);
|
||||
return Array.from({ length: count * 2 }, (_, index) => pointOnCircle(width, height, index % 2 === 0 ? outerRadius : outerRadius * innerRatio, -Math.PI / 2 + (Math.PI * index) / count));
|
||||
}
|
||||
|
||||
function pointOnCircle(width: number, height: number, radius: number, angle: number) {
|
||||
return { x: width / 2 + Math.cos(angle) * radius, y: height / 2 + Math.sin(angle) * radius };
|
||||
}
|
||||
|
||||
function roundedPath(points: Array<{ x: number; y: number }>, radius: number) {
|
||||
if (points.length < 3) return "";
|
||||
if (radius < 0.5) return `M ${points.map((point) => `${point.x} ${point.y}`).join(" L ")} Z`;
|
||||
const segments = points.map((point, index) => ({
|
||||
point,
|
||||
before: pointAlong(point, points[(index - 1 + points.length) % points.length], radius),
|
||||
after: pointAlong(point, points[(index + 1) % points.length], radius)
|
||||
}));
|
||||
return [`M ${segments[0].after.x} ${segments[0].after.y}`, ...segments.slice(1).map((segment) => `L ${segment.before.x} ${segment.before.y} Q ${segment.point.x} ${segment.point.y} ${segment.after.x} ${segment.after.y}`), `L ${segments[0].before.x} ${segments[0].before.y} Q ${segments[0].point.x} ${segments[0].point.y} ${segments[0].after.x} ${segments[0].after.y}`, "Z"].join(" ");
|
||||
}
|
||||
|
||||
function pointAlong(from: { x: number; y: number }, to: { x: number; y: number }, radius: number) {
|
||||
const length = Math.max(Math.hypot(to.x - from.x, to.y - from.y), 1);
|
||||
const distance = Math.min(radius, length * 0.42);
|
||||
return { x: from.x + ((to.x - from.x) / length) * distance, y: from.y + ((to.y - from.y) / length) * distance };
|
||||
}
|
||||
|
||||
function round(value: number) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { CanvasNode } from "@/domain/design";
|
||||
import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "ag-psd";
|
||||
import { imageAdjustmentImageStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||
import { isShapeNode, parseShapeOptions, shapeEndpointToWorldPoint } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
||||
import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
|
||||
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor, normalizeStrokeStyle } from "./frameStyle";
|
||||
|
||||
export type ImageExportFormat = "png" | "jpg" | "svg" | "psd";
|
||||
@@ -506,6 +507,10 @@ function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number,
|
||||
: `stroke="none"`;
|
||||
const inset = strokeWidth / 2;
|
||||
|
||||
if (options.kind === "path") {
|
||||
return `<path d="${normalizedPathDataToSvg(options.pathData, width, height, x, y)}" ${fill} ${stroke} stroke-linecap="round" stroke-linejoin="round"${transform}/>`;
|
||||
}
|
||||
|
||||
if (options.kind === "line" || options.kind === "arrow") {
|
||||
const startWorld = shapeEndpointToWorldPoint(node, options.start);
|
||||
const endWorld = shapeEndpointToWorldPoint(node, options.end);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { CanvasNode } from "@/domain/design";
|
||||
import { shapeOptionsToContent } from "./shapeNode.ts";
|
||||
|
||||
export type PathPenPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type PathPenPosition = {
|
||||
screen: PathPenPoint;
|
||||
world: PathPenPoint;
|
||||
};
|
||||
|
||||
export type PathPenAnchor = PathPenPosition & {
|
||||
handleIn?: PathPenPosition;
|
||||
handleOut?: PathPenPosition;
|
||||
};
|
||||
|
||||
export type PathPenDraft = {
|
||||
anchors: PathPenAnchor[];
|
||||
hover: PathPenPosition | null;
|
||||
closed: boolean;
|
||||
color: string;
|
||||
width: number;
|
||||
viewportScale: number;
|
||||
};
|
||||
|
||||
type PathPenSettings = {
|
||||
color: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const moveCommand = 1;
|
||||
const lineCommand = 2;
|
||||
const cubicCommand = 5;
|
||||
const closeCommand = 11;
|
||||
|
||||
export function startPathPenDraft(settings: PathPenSettings, viewportScale: number, position: PathPenPosition): PathPenDraft {
|
||||
return {
|
||||
anchors: [toAnchor(position)],
|
||||
hover: null,
|
||||
closed: false,
|
||||
color: settings.color,
|
||||
width: settings.width,
|
||||
viewportScale
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPathPenAnchor(draft: PathPenDraft, position: PathPenPosition): PathPenDraft {
|
||||
if (draft.closed) return draft;
|
||||
return {
|
||||
...draft,
|
||||
anchors: [...draft.anchors, toAnchor(position)],
|
||||
hover: null
|
||||
};
|
||||
}
|
||||
|
||||
export function setLastPathPenAnchorHandles(draft: PathPenDraft, handleOut: PathPenPosition): PathPenDraft {
|
||||
const anchor = draft.anchors[draft.anchors.length - 1];
|
||||
if (!anchor || draft.closed) return draft;
|
||||
const handleIn = reflectPosition(anchor, handleOut);
|
||||
return {
|
||||
...draft,
|
||||
anchors: [...draft.anchors.slice(0, -1), { ...anchor, handleIn, handleOut }]
|
||||
};
|
||||
}
|
||||
|
||||
export function setPathPenHover(draft: PathPenDraft, hover: PathPenPosition | null): PathPenDraft {
|
||||
if (draft.closed) return draft;
|
||||
return { ...draft, hover };
|
||||
}
|
||||
|
||||
export function removeLastPathPenAnchor(draft: PathPenDraft) {
|
||||
if (draft.closed) return draft;
|
||||
return {
|
||||
...draft,
|
||||
anchors: draft.anchors.slice(0, -1),
|
||||
hover: null
|
||||
};
|
||||
}
|
||||
|
||||
export function closePathPenDraft(draft: PathPenDraft): PathPenDraft {
|
||||
if (draft.anchors.length < 3) return draft;
|
||||
return { ...draft, closed: true, hover: null };
|
||||
}
|
||||
|
||||
export function canCommitPathPenDraft(draft: PathPenDraft | null): draft is PathPenDraft {
|
||||
return Boolean(draft && draft.anchors.length >= 2);
|
||||
}
|
||||
|
||||
export function isPathPenNearStart(draft: PathPenDraft | null, point: PathPenPoint, radius = 10) {
|
||||
if (!draft || draft.anchors.length < 3) return false;
|
||||
return distance(draft.anchors[0].screen, point) <= radius;
|
||||
}
|
||||
|
||||
export function constrainPathPenPoint(origin: PathPenPoint, point: PathPenPoint): PathPenPoint {
|
||||
const dx = point.x - origin.x;
|
||||
const dy = point.y - origin.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length === 0) return point;
|
||||
const angleStep = Math.PI / 4;
|
||||
const angle = Math.round(Math.atan2(dy, dx) / angleStep) * angleStep;
|
||||
return {
|
||||
x: origin.x + Math.cos(angle) * length,
|
||||
y: origin.y + Math.sin(angle) * length
|
||||
};
|
||||
}
|
||||
|
||||
export function pathPenDraftToPathData(draft: PathPenDraft, includeHover = false) {
|
||||
const anchors = includeHover && draft.hover && !draft.closed ? [...draft.anchors, toAnchor(draft.hover)] : draft.anchors;
|
||||
const first = anchors[0];
|
||||
if (!first) return [];
|
||||
const pathData = [moveCommand, first.world.x, first.world.y];
|
||||
|
||||
for (let index = 1; index < anchors.length; index += 1) {
|
||||
appendSegment(pathData, anchors[index - 1], anchors[index]);
|
||||
}
|
||||
|
||||
if (draft.closed && anchors.length >= 3) {
|
||||
const last = anchors[anchors.length - 1];
|
||||
if (last.handleOut || first.handleIn) appendSegment(pathData, last, first);
|
||||
pathData.push(closeCommand);
|
||||
}
|
||||
return pathData;
|
||||
}
|
||||
|
||||
export function pathPenDraftToScreenPath(draft: PathPenDraft) {
|
||||
const anchors = draft.hover && !draft.closed ? [...draft.anchors, toAnchor(draft.hover)] : draft.anchors;
|
||||
const first = anchors[0];
|
||||
if (!first) return "";
|
||||
const commands = [`M ${round(first.screen.x)} ${round(first.screen.y)}`];
|
||||
|
||||
for (let index = 1; index < anchors.length; index += 1) {
|
||||
commands.push(screenSegment(anchors[index - 1], anchors[index]));
|
||||
}
|
||||
|
||||
if (draft.closed && anchors.length >= 3) {
|
||||
const last = anchors[anchors.length - 1];
|
||||
if (last.handleOut || first.handleIn) commands.push(screenSegment(last, first));
|
||||
commands.push("Z");
|
||||
}
|
||||
return commands.join(" ");
|
||||
}
|
||||
|
||||
export function createPathPenNode(draft: PathPenDraft, title: string): CanvasNode {
|
||||
const pathData = pathPenDraftToPathData(draft);
|
||||
const strokeWidth = Math.max(1, draft.width / Math.max(draft.viewportScale, 0.01));
|
||||
const padding = strokeWidth / 2 + 4 / Math.max(draft.viewportScale, 0.01);
|
||||
const points = draft.anchors.flatMap((anchor) => [anchor.world, anchor.handleIn?.world, anchor.handleOut?.world].filter((point): point is PathPenPoint => Boolean(point)));
|
||||
const bounds = pointBounds(points);
|
||||
const x = bounds.left - padding;
|
||||
const y = bounds.top - padding;
|
||||
const width = Math.max(1, bounds.right - bounds.left + padding * 2);
|
||||
const height = Math.max(1, bounds.bottom - bounds.top + padding * 2);
|
||||
const normalizedPathData = transformPathData(pathData, (value, axis) => (value - (axis === "x" ? x : y)) / (axis === "x" ? width : height));
|
||||
|
||||
return {
|
||||
id: cryptoFallbackId(),
|
||||
type: "frame",
|
||||
title,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
content: shapeOptionsToContent({
|
||||
kind: "path",
|
||||
cornerRadius: 0,
|
||||
vertices: 5,
|
||||
innerRadius: 0.46,
|
||||
curve: 0,
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 1, y: 1 },
|
||||
pathData: normalizedPathData
|
||||
}),
|
||||
tone: "shape",
|
||||
status: "draft",
|
||||
layerRole: "shape",
|
||||
opacity: 1,
|
||||
fillColor: "transparent",
|
||||
strokeColor: draft.color,
|
||||
strokeWidth,
|
||||
strokeStyle: "solid"
|
||||
};
|
||||
}
|
||||
|
||||
function toAnchor(position: PathPenPosition): PathPenAnchor {
|
||||
return {
|
||||
screen: { ...position.screen },
|
||||
world: { ...position.world }
|
||||
};
|
||||
}
|
||||
|
||||
function reflectPosition(anchor: PathPenPosition, point: PathPenPosition): PathPenPosition {
|
||||
return {
|
||||
screen: {
|
||||
x: anchor.screen.x * 2 - point.screen.x,
|
||||
y: anchor.screen.y * 2 - point.screen.y
|
||||
},
|
||||
world: {
|
||||
x: anchor.world.x * 2 - point.world.x,
|
||||
y: anchor.world.y * 2 - point.world.y
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function appendSegment(pathData: number[], from: PathPenAnchor, to: PathPenAnchor) {
|
||||
if (from.handleOut || to.handleIn) {
|
||||
const controlOut = from.handleOut?.world ?? from.world;
|
||||
const controlIn = to.handleIn?.world ?? to.world;
|
||||
pathData.push(cubicCommand, controlOut.x, controlOut.y, controlIn.x, controlIn.y, to.world.x, to.world.y);
|
||||
return;
|
||||
}
|
||||
pathData.push(lineCommand, to.world.x, to.world.y);
|
||||
}
|
||||
|
||||
function screenSegment(from: PathPenAnchor, to: PathPenAnchor) {
|
||||
if (from.handleOut || to.handleIn) {
|
||||
const controlOut = from.handleOut?.screen ?? from.screen;
|
||||
const controlIn = to.handleIn?.screen ?? to.screen;
|
||||
return `C ${round(controlOut.x)} ${round(controlOut.y)} ${round(controlIn.x)} ${round(controlIn.y)} ${round(to.screen.x)} ${round(to.screen.y)}`;
|
||||
}
|
||||
return `L ${round(to.screen.x)} ${round(to.screen.y)}`;
|
||||
}
|
||||
|
||||
function transformPathData(pathData: number[], transform: (value: number, axis: "x" | "y") => number) {
|
||||
const output = [...pathData];
|
||||
for (let index = 0; index < output.length; ) {
|
||||
const name = output[index++];
|
||||
const coordinateCount = name === moveCommand || name === lineCommand ? 2 : name === cubicCommand ? 6 : name === closeCommand ? 0 : -1;
|
||||
if (coordinateCount < 0) return [];
|
||||
for (let coordinate = 0; coordinate < coordinateCount; coordinate += 1) {
|
||||
output[index] = transform(output[index], coordinate % 2 === 0 ? "x" : "y");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function pointBounds(points: PathPenPoint[]) {
|
||||
return {
|
||||
left: Math.min(...points.map((point) => point.x)),
|
||||
top: Math.min(...points.map((point) => point.y)),
|
||||
right: Math.max(...points.map((point) => point.x)),
|
||||
bottom: Math.max(...points.map((point) => point.y))
|
||||
};
|
||||
}
|
||||
|
||||
function distance(a: PathPenPoint, b: PathPenPoint) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
function round(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function cryptoFallbackId() {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-pen-path`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { CanvasNode } from "@/domain/design";
|
||||
import type { FrameStylePatch } from "@/ui/pages/CanvasWorkspace/canvas/types";
|
||||
|
||||
export type ShapeKind = "rectangle" | "line" | "arrow" | "ellipse" | "polygon" | "star";
|
||||
export type ShapeNodeKind = ShapeKind | "path";
|
||||
export type ShapeEndpointKey = "start" | "end";
|
||||
export type BooleanShapeOperation = "union" | "subtract" | "intersect" | "exclude" | "flatten";
|
||||
|
||||
@@ -11,13 +12,14 @@ export type ShapeEndpoint = {
|
||||
};
|
||||
|
||||
export type ShapeOptions = {
|
||||
kind: ShapeKind;
|
||||
kind: ShapeNodeKind;
|
||||
cornerRadius: number;
|
||||
vertices: number;
|
||||
innerRadius: number;
|
||||
curve: number;
|
||||
start: ShapeEndpoint;
|
||||
end: ShapeEndpoint;
|
||||
pathData?: number[];
|
||||
};
|
||||
|
||||
export type ShapeWorldDraft = {
|
||||
@@ -81,7 +83,7 @@ export function isLineShapeNode(node: CanvasNode) {
|
||||
export function isBooleanCapableShapeNode(node: CanvasNode) {
|
||||
if (!isShapeNode(node)) return false;
|
||||
const kind = parseShapeOptions(node.content).kind;
|
||||
return kind === "rectangle" || kind === "ellipse" || kind === "polygon" || kind === "star";
|
||||
return kind === "rectangle" || kind === "ellipse" || kind === "polygon" || kind === "star" || kind === "path";
|
||||
}
|
||||
|
||||
export function shapeTitle(kind: ShapeKind, t: (key: string) => string) {
|
||||
@@ -98,7 +100,7 @@ export function shapeTitle(kind: ShapeKind, t: (key: string) => string) {
|
||||
|
||||
export function parseShapeOptions(content: string | undefined): ShapeOptions {
|
||||
const parsed = safeJson(content);
|
||||
const kind = shapeKinds.includes(parsed.kind) ? parsed.kind : "rectangle";
|
||||
const kind: ShapeNodeKind = parsed.kind === "path" ? "path" : shapeKinds.includes(parsed.kind) ? parsed.kind : "rectangle";
|
||||
return {
|
||||
kind,
|
||||
cornerRadius: clampNumber(parsed.cornerRadius, 0, 300, kind === "rectangle" ? 0 : 12),
|
||||
@@ -106,7 +108,8 @@ export function parseShapeOptions(content: string | undefined): ShapeOptions {
|
||||
innerRadius: clampNumber(parsed.innerRadius, 0.12, 0.86, 0.46),
|
||||
curve: clampNumber(parsed.curve, -260, 260, 0),
|
||||
start: normalizeEndpoint(parsed.start, { x: 0, y: 0 }),
|
||||
end: normalizeEndpoint(parsed.end, { x: 1, y: 1 })
|
||||
end: normalizeEndpoint(parsed.end, { x: 1, y: 1 }),
|
||||
pathData: kind === "path" ? normalizePathData(parsed.pathData) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,7 +121,8 @@ export function shapeOptionsToContent(options: ShapeOptions) {
|
||||
innerRadius: Math.round(options.innerRadius * 1000) / 1000,
|
||||
curve: Math.round(options.curve * 10) / 10,
|
||||
start: normalizeEndpoint(options.start, { x: 0, y: 0 }),
|
||||
end: normalizeEndpoint(options.end, { x: 1, y: 1 })
|
||||
end: normalizeEndpoint(options.end, { x: 1, y: 1 }),
|
||||
...(options.kind === "path" && options.pathData ? { pathData: options.pathData.map((value) => Math.round(value * 100_000) / 100_000) } : {})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,6 +355,12 @@ function normalizeEndpoint(value: unknown, fallback: ShapeEndpoint) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathData(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const data = value.map(Number);
|
||||
return data.length >= 4 && data.every(Number.isFinite) ? data : undefined;
|
||||
}
|
||||
|
||||
function safeJson(content: string | undefined): Record<string, any> {
|
||||
if (!content) return {};
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CanvasTool = "select" | "pan" | "pin" | "image" | "frame" | "shape" | "pen" | "text" | "crop";
|
||||
export type CanvasTool = "select" | "pan" | "pin" | "image" | "frame" | "shape" | "path" | "pen" | "text" | "crop";
|
||||
|
||||
export type NodeScreenBounds = {
|
||||
left: number;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuth } from "@/ui/auth/AuthProvider";
|
||||
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
|
||||
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
|
||||
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
|
||||
import { CanvasLoading } from "@/ui/components/CanvasLoading";
|
||||
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
|
||||
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
|
||||
import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer";
|
||||
@@ -50,6 +51,7 @@ import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
|
||||
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
|
||||
import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isFrameChildNode, isFrameContainerNode, isRealCanvasImageNode, moveNodesByDelta, nodeIdsWithFrameChildren, orderedCanvasNodesForRender, patchFrameStyleNode, removeNodesById, reorderLayer as reorderLayerOp, reorderNode as reorderNodeOp, resizeFrameWithChildren, resizeVisualNodeByHandle, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
|
||||
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
|
||||
import { CanvasPathEditor, type CanvasPathEditorControl } from "@/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor";
|
||||
import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop";
|
||||
import { installCanvasWheelListener } from "./canvas/canvasWheel";
|
||||
import { detectColorBoundaryCrop } from "./canvas/colorBoundaryCrop";
|
||||
@@ -59,6 +61,7 @@ import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldR
|
||||
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
|
||||
import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
|
||||
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling";
|
||||
import { applyEditedPathToNode, editablePathForNode, isEditablePathNode, type EditablePathData } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
|
||||
import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
|
||||
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
|
||||
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
|
||||
@@ -66,7 +69,8 @@ import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useG
|
||||
import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageGeneratorTargetSizeFromSettings, serializeImageGeneratorTargetSize, type ImageGeneratorTargetSize } from "./canvas/imageGeneratorSizing";
|
||||
import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage, withLastCanvasReferenceAnchor } from "./canvas/nodeReferenceImages";
|
||||
import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport";
|
||||
import { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool";
|
||||
import { beginPenStroke, createPenStrokeNode, PathPenDraftOverlay, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool";
|
||||
import { appendPathPenAnchor, canCommitPathPenDraft, closePathPenDraft, constrainPathPenPoint, createPathPenNode, isPathPenNearStart, removeLastPathPenAnchor, setLastPathPenAnchorHandles, setPathPenHover, startPathPenDraft, type PathPenDraft, type PathPenPosition } from "@/ui/pages/CanvasWorkspace/canvas/pathPen";
|
||||
import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar";
|
||||
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
|
||||
import { ShapeStyleToolbar } from "@/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar";
|
||||
@@ -619,6 +623,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const webSearchHydratedRef = useRef(false);
|
||||
const pendingWorkspaceCropRef = useRef<{ nodeId: string; selection: NormalizedRect } | null>(null);
|
||||
const workspaceCropDetectionRequestRef = useRef(0);
|
||||
const pathEditorControlRef = useRef<CanvasPathEditorControl | null>(null);
|
||||
const pathEditorExitToolRef = useRef<CanvasTool | null>(null);
|
||||
const pathPenDraftRef = useRef<PathPenDraft | null>(null);
|
||||
const pathPenDraggingRef = useRef(false);
|
||||
const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument();
|
||||
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
||||
@@ -647,6 +655,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const [mode, setMode] = useState<"select" | "pan">("select");
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>("select");
|
||||
const [editingTextNodeId, setEditingTextNodeId] = useState<string | null>(null);
|
||||
const [pathEditingNodeId, setPathEditingNodeId] = useState<string | null>(null);
|
||||
const [canvasToolMenuOpen, setCanvasToolMenuOpen] = useState(false);
|
||||
const [shapeToolMenuOpen, setShapeToolMenuOpen] = useState(false);
|
||||
const [selectedShapeKind, setSelectedShapeKind] = useState<ShapeKind>("rectangle");
|
||||
@@ -668,6 +677,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const [shapeDraft, setShapeDraft] = useState<ShapeDraft | null>(null);
|
||||
const [penDraft, setPenDraft] = useState<PenDraft | null>(null);
|
||||
const [penSettings, setPenSettings] = useState<PenSettings>({ color: "#000000", width: 3 });
|
||||
const [pathPenDraft, setPathPenDraft] = useState<PathPenDraft | null>(null);
|
||||
const [pathPenSettings, setPathPenSettings] = useState<PenSettings>({ color: "#000000", width: 3 });
|
||||
const [canvasPanning, setCanvasPanning] = useState(false);
|
||||
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||
const [canvasFileDragging, setCanvasFileDragging] = useState(false);
|
||||
@@ -1132,6 +1143,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null;
|
||||
const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null;
|
||||
const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null;
|
||||
const pathEditingNode = pathEditingNodeId ? nodes.find((node) => node.id === pathEditingNodeId && isEditablePathNode(node)) ?? null : null;
|
||||
const hasCurrentConversation = messages.some((message) => message.content.trim() || message.title.trim());
|
||||
const selectedNodeBounds = useMemo<NodeScreenBounds | null>(() => {
|
||||
if (!selectedImageNode) return null;
|
||||
@@ -1620,6 +1632,113 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
[penSettings, selectOnlyNode, t]
|
||||
);
|
||||
|
||||
const updatePathPenDraft = useCallback((draft: PathPenDraft | null) => {
|
||||
pathPenDraftRef.current = draft;
|
||||
setPathPenDraft(draft);
|
||||
}, []);
|
||||
|
||||
const commitPathPenDrawing = useCallback(
|
||||
(draft: PathPenDraft, closed = false, editAfterCommit = true) => {
|
||||
const completed = closed ? closePathPenDraft(draft) : draft;
|
||||
if (!canCommitPathPenDraft(completed)) return false;
|
||||
const node = createPathPenNode(completed, t("pathPen"));
|
||||
setNodesWithFrameAttachment((current) => ({
|
||||
nodes: [...current, node],
|
||||
candidateIds: new Set([node.id])
|
||||
}));
|
||||
updatePathPenDraft(null);
|
||||
selectOnlyNode(node.id);
|
||||
setActiveTool(editAfterCommit ? "path" : "select");
|
||||
setMode("select");
|
||||
if (editAfterCommit) {
|
||||
pathEditorExitToolRef.current = null;
|
||||
setPathEditingNodeId(node.id);
|
||||
}
|
||||
setDirty(true);
|
||||
return true;
|
||||
},
|
||||
[selectOnlyNode, t, updatePathPenDraft]
|
||||
);
|
||||
|
||||
const finishPathPenDrawing = useCallback(
|
||||
(closed = false, editAfterCommit = true) => {
|
||||
const draft = pathPenDraftRef.current;
|
||||
if (!draft || !commitPathPenDrawing(draft, closed, editAfterCommit)) updatePathPenDraft(null);
|
||||
},
|
||||
[commitPathPenDrawing, updatePathPenDraft]
|
||||
);
|
||||
|
||||
const startPathPenDrawing = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
const stageRect = containerRef.current?.getBoundingClientRect();
|
||||
if (!stageRect) return;
|
||||
event.preventDefault();
|
||||
selectOnlyNode(null);
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
const current = pathPenDraftRef.current;
|
||||
const toPosition = (clientX: number, clientY: number, constrainFrom?: { x: number; y: number }): PathPenPosition => {
|
||||
const rawScreen = { x: clientX - stageRect.left, y: clientY - stageRect.top };
|
||||
const screen = constrainFrom ? constrainPathPenPoint(constrainFrom, rawScreen) : rawScreen;
|
||||
return {
|
||||
screen,
|
||||
world: {
|
||||
x: (screen.x - viewport.x) / viewport.k,
|
||||
y: (screen.y - viewport.y) / viewport.k
|
||||
}
|
||||
};
|
||||
};
|
||||
const lastAnchor = current?.anchors[current.anchors.length - 1];
|
||||
const position = toPosition(event.clientX, event.clientY, event.shiftKey ? lastAnchor?.screen : undefined);
|
||||
|
||||
if (isPathPenNearStart(current, position.screen)) {
|
||||
finishPathPenDrawing(true);
|
||||
return;
|
||||
}
|
||||
if (event.detail > 1 && canCommitPathPenDraft(current)) {
|
||||
finishPathPenDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDraft = current ? appendPathPenAnchor(current, position) : startPathPenDraft(pathPenSettings, viewport.k, position);
|
||||
updatePathPenDraft(nextDraft);
|
||||
let dragged = false;
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
if (!dragged && Math.hypot(moveEvent.clientX - event.clientX, moveEvent.clientY - event.clientY) < 3) return;
|
||||
dragged = true;
|
||||
pathPenDraggingRef.current = true;
|
||||
const anchor = nextDraft.anchors[nextDraft.anchors.length - 1];
|
||||
const handle = toPosition(moveEvent.clientX, moveEvent.clientY, moveEvent.shiftKey ? anchor.screen : undefined);
|
||||
updatePathPenDraft(setLastPathPenAnchorHandles(nextDraft, handle));
|
||||
};
|
||||
const cleanup = () => {
|
||||
pathPenDraggingRef.current = false;
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", cleanup);
|
||||
};
|
||||
const handleUp = (upEvent: PointerEvent) => {
|
||||
if (dragged) handleMove(upEvent);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp);
|
||||
window.addEventListener("pointercancel", cleanup);
|
||||
},
|
||||
[finishPathPenDrawing, pathPenSettings, selectOnlyNode, updatePathPenDraft]
|
||||
);
|
||||
|
||||
const finishPathPenFromDoubleClick = () => {
|
||||
const draft = pathPenDraftRef.current;
|
||||
if (!draft) return;
|
||||
const last = draft.anchors[draft.anchors.length - 1];
|
||||
const previous = draft.anchors[draft.anchors.length - 2];
|
||||
const completed = last && previous && Math.hypot(last.screen.x - previous.screen.x, last.screen.y - previous.screen.y) < 3 ? removeLastPathPenAnchor(draft) : draft;
|
||||
if (!commitPathPenDrawing(completed)) updatePathPenDraft(null);
|
||||
};
|
||||
|
||||
const startShapeDrawing = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
@@ -1732,6 +1851,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
target.closest(".frame-color-popover") ||
|
||||
target.closest(".pen-tool-controls") ||
|
||||
target.closest(".pen-color-popover") ||
|
||||
target.closest(".canvas-path-editor-overlay") ||
|
||||
target.closest(".image-action-panel") ||
|
||||
target.closest(".image-mask-overlay") ||
|
||||
target.closest(".multi-selection-toolbar") ||
|
||||
@@ -1767,6 +1887,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool === "path") {
|
||||
startPathPenDrawing(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool === "frame") {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
@@ -1923,6 +2048,23 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
startPenDrawing(event);
|
||||
return;
|
||||
}
|
||||
if (activeTool === "path") {
|
||||
if (isEditablePathNode(node) && editablePathForNode(node)) {
|
||||
const currentPathPenDraft = pathPenDraftRef.current;
|
||||
if (currentPathPenDraft) {
|
||||
if (canCommitPathPenDraft(currentPathPenDraft)) {
|
||||
commitPathPenDrawing(currentPathPenDraft, false, false);
|
||||
} else {
|
||||
updatePathPenDraft(null);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
startPathEditing(node);
|
||||
return;
|
||||
}
|
||||
startPathPenDrawing(event);
|
||||
return;
|
||||
}
|
||||
if (activeTool === "shape") {
|
||||
startShapeDrawing(event);
|
||||
return;
|
||||
@@ -2033,6 +2175,34 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
window.addEventListener("pointerup", handleUp);
|
||||
};
|
||||
|
||||
const startPathEditing = (node: CanvasNode) => {
|
||||
const pathToolActive = activeTool === "path";
|
||||
if (!canEdit || (!pathToolActive && (activeTool !== "select" || mode !== "select")) || lockedNodeIds.has(node.id) || hiddenNodeIds.has(node.id) || !isEditablePathNode(node) || !editablePathForNode(node)) return;
|
||||
selectOnlyNode(node.id);
|
||||
pathEditorExitToolRef.current = null;
|
||||
setActiveTool("path");
|
||||
setMode("select");
|
||||
setCanvasToolMenuOpen(false);
|
||||
setShapeToolMenuOpen(false);
|
||||
setPathEditingNodeId(node.id);
|
||||
};
|
||||
|
||||
const commitPathEditing = (nodeId: string, pathData: EditablePathData) => {
|
||||
const nextNodes = nodesRef.current.map((node) => (node.id === nodeId ? applyEditedPathToNode(node, pathData) : node));
|
||||
nodesRef.current = nextNodes;
|
||||
setNodes(nextNodes);
|
||||
setDirty(true);
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
const handlePathEditorClose = () => {
|
||||
const nextTool = pathEditorExitToolRef.current ?? "select";
|
||||
pathEditorExitToolRef.current = null;
|
||||
setPathEditingNodeId(null);
|
||||
setActiveTool(nextTool);
|
||||
setMode(nextTool === "pan" ? "pan" : "select");
|
||||
};
|
||||
|
||||
const handleTextNodeResizePointerDown = (event: React.PointerEvent<HTMLButtonElement>, node: CanvasNode, handle: ResizeHandle) => {
|
||||
const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0;
|
||||
const verticalDirection = handle.includes("bottom") ? 1 : handle.includes("top") ? -1 : 0;
|
||||
@@ -2646,6 +2816,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEditableShortcutTarget(event.target)) return;
|
||||
if (!canEdit) return;
|
||||
if (pathEditingNodeId) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const primary = isPrimaryShortcut(event);
|
||||
@@ -2659,6 +2830,18 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
return true;
|
||||
};
|
||||
|
||||
const currentPathPenDraft = pathPenDraftRef.current;
|
||||
if (activeTool === "path" && currentPathPenDraft) {
|
||||
if (key === "enter") return void run(() => finishPathPenDrawing());
|
||||
if (key === "escape") return void run(() => updatePathPenDraft(null));
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
return void run(() => {
|
||||
const nextDraft = removeLastPathPenAnchor(currentPathPenDraft);
|
||||
updatePathPenDraft(nextDraft.anchors.length > 0 ? nextDraft : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId || activeTool === "crop")) {
|
||||
return void run(() => {
|
||||
chooseCanvasTool("select");
|
||||
@@ -2680,6 +2863,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "v") return void run(() => chooseCanvasTool("select"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "h") return void run(() => chooseCanvasTool("pan"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "f") return void run(() => chooseCanvasTool("frame"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "p") return void run(() => chooseCanvasTool("path"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "b") return void run(() => chooseCanvasTool("pen"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "t") return void run(() => chooseCanvasTool("text"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "r") return void run(() => chooseShapeTool("rectangle"));
|
||||
if (!primary && !event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("line"));
|
||||
@@ -2867,6 +3052,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
const chooseCanvasTool = (tool: CanvasTool) => {
|
||||
if (!canEdit) return;
|
||||
if (pathEditingNodeId && tool !== "path") {
|
||||
pathEditorExitToolRef.current = tool;
|
||||
pathEditorControlRef.current?.close();
|
||||
}
|
||||
if (tool !== "path" && pathPenDraftRef.current) finishPathPenDrawing(false, false);
|
||||
if (tool !== "crop") {
|
||||
workspaceCropDetectionRequestRef.current += 1;
|
||||
setWorkspaceCropDetecting(false);
|
||||
@@ -2900,6 +3090,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
if (tool === "path") {
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
if (tool === "crop") {
|
||||
setMode("select");
|
||||
return;
|
||||
@@ -3856,6 +4050,29 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY
|
||||
};
|
||||
const draft = pathPenDraftRef.current;
|
||||
const stageRect = containerRef.current?.getBoundingClientRect();
|
||||
if (activeTool !== "path" || !draft || !stageRect || pathPenDraggingRef.current) return;
|
||||
const lastAnchor = draft.anchors[draft.anchors.length - 1];
|
||||
const rawScreen = { x: event.clientX - stageRect.left, y: event.clientY - stageRect.top };
|
||||
const screen = event.shiftKey && lastAnchor ? constrainPathPenPoint(lastAnchor.screen, rawScreen) : rawScreen;
|
||||
const viewport = viewportRef.current;
|
||||
updatePathPenDraft(
|
||||
setPathPenHover(draft, {
|
||||
screen,
|
||||
world: {
|
||||
x: (screen.x - viewport.x) / viewport.k,
|
||||
y: (screen.y - viewport.y) / viewport.k
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCanvasDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (activeTool !== "path" || !pathPenDraftRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finishPathPenFromDoubleClick();
|
||||
};
|
||||
|
||||
const handleCanvasDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -3880,11 +4097,15 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="workspace-loading">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="workspace-loading">
|
||||
<Loader2 className="spin" size={28} />
|
||||
<span>{error || t("loadingCanvas")}</span>
|
||||
</div>
|
||||
<CanvasLoading label={t("loadingCanvas")} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3914,11 +4135,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
<main className="workspace-main">
|
||||
<div
|
||||
className={`canvas-stage ${showGrid ? "show-grid" : ""} ${canvasFileDragging ? "dragging-upload" : ""} ${activeTool === "pan" ? "is-pan-tool" : ""} ${canvasPanning ? "is-panning" : ""} ${activeTool === "frame" ? "is-frame-tool" : ""} ${activeTool === "shape" ? "is-shape-tool" : ""} ${activeTool === "pen" ? "is-pen-tool" : ""} ${activeTool === "text" ? "is-text-tool" : ""} ${activeTool === "crop" ? "is-crop-tool" : ""} ${workspaceCropDetecting ? "is-crop-detecting" : ""}`}
|
||||
className={`canvas-stage ${showGrid ? "show-grid" : ""} ${canvasFileDragging ? "dragging-upload" : ""} ${activeTool === "pan" ? "is-pan-tool" : ""} ${canvasPanning ? "is-panning" : ""} ${activeTool === "frame" ? "is-frame-tool" : ""} ${activeTool === "shape" ? "is-shape-tool" : ""} ${activeTool === "pen" ? "is-pen-tool" : ""} ${activeTool === "path" ? "is-path-pen-tool" : ""} ${activeTool === "text" ? "is-text-tool" : ""} ${activeTool === "crop" ? "is-crop-tool" : ""} ${workspaceCropDetecting ? "is-crop-detecting" : ""} ${pathEditingNode ? "is-path-editing" : ""}`}
|
||||
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
|
||||
ref={containerRef}
|
||||
onPointerDown={canEdit ? handleBackgroundPointerDown : undefined}
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onDoubleClick={canEdit ? handleCanvasDoubleClick : undefined}
|
||||
onDragOver={canEdit ? handleCanvasDragOver : undefined}
|
||||
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
|
||||
onDrop={canEdit ? handleCanvasDrop : undefined}
|
||||
@@ -4010,7 +4232,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
selected={selectedIds.has(node.id) && selectedIds.size <= 1}
|
||||
mockupDragging={mockupDragNodeId === node.id}
|
||||
editing={editingTextNodeId === node.id}
|
||||
showResizeHandles={canOperateNode && !suppressSelectionFrame && mode === "select" && selectedIds.size <= 1 && editingTextNodeId !== node.id && canResizeCanvasNode(node) && !isLineShapeNode(node)}
|
||||
pathEditing={pathEditingNodeId === node.id}
|
||||
showResizeHandles={canOperateNode && !suppressSelectionFrame && mode === "select" && selectedIds.size <= 1 && editingTextNodeId !== node.id && pathEditingNodeId !== node.id && canResizeCanvasNode(node) && !isLineShapeNode(node)}
|
||||
hidden={hidden}
|
||||
locked={locked}
|
||||
mockupClipPath={mockupPrintClipPath(node, nodes)}
|
||||
@@ -4025,6 +4248,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
hasClipboard
|
||||
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
|
||||
onPointerDown={(event) => handleNodePointerDown(event, node)}
|
||||
onDoubleClick={() => {
|
||||
if (activeTool === "path" && pathPenDraftRef.current) {
|
||||
finishPathPenFromDoubleClick();
|
||||
return;
|
||||
}
|
||||
startPathEditing(node);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (!canOperateNode) return;
|
||||
if (!selectedIds.has(node.id)) selectOnlyNode(node.id);
|
||||
@@ -4038,10 +4268,21 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pathEditingNode && (
|
||||
<CanvasPathEditor
|
||||
node={pathEditingNode}
|
||||
viewport={viewport}
|
||||
stageSize={canvasStageSize}
|
||||
controlRef={pathEditorControlRef}
|
||||
onCommit={(pathData) => commitPathEditing(pathEditingNode.id, pathData)}
|
||||
onClose={handlePathEditorClose}
|
||||
/>
|
||||
)}
|
||||
<CanvasAnnotations annotations={annotations} viewport={viewport} />
|
||||
<FrameDraftOverlay draft={frameDraft} label={t("newFrameNode")} />
|
||||
<ShapeDraftOverlay draft={shapeDraft} kind={selectedShapeKind} label={shapeTitle(selectedShapeKind, t)} />
|
||||
<PenStrokeDraftOverlay draft={penDraft} />
|
||||
<PathPenDraftOverlay draft={pathPenDraft} />
|
||||
{workspaceCropDraftBounds && (
|
||||
<div className="workspace-crop-draft" style={workspaceCropDraftBounds} aria-hidden="true">
|
||||
<span className="workspace-crop-rule vertical first" />
|
||||
@@ -4352,7 +4593,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
{selectedFrameNode && selectedFrameBounds && (
|
||||
<FrameStyleToolbar node={selectedFrameNode} bounds={selectedFrameBounds} leftInset={floatingToolbarLeftInset} onPatch={(patch) => updateFrameStyle(selectedFrameNode.id, patch)} />
|
||||
)}
|
||||
{selectedShapeNode && selectedShapeBounds && !isLineShapeNode(selectedShapeNode) && (
|
||||
{selectedShapeNode && selectedShapeBounds && !pathEditingNodeId && !isLineShapeNode(selectedShapeNode) && (
|
||||
<ShapeStyleToolbar
|
||||
node={selectedShapeNode}
|
||||
bounds={selectedShapeBounds}
|
||||
@@ -4361,7 +4602,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
onDownload={() => exportCanvasNode(selectedShapeNode, "svg")}
|
||||
/>
|
||||
)}
|
||||
{selectedPenStrokeNode && selectedPenStrokeBounds && (
|
||||
{selectedPenStrokeNode && selectedPenStrokeBounds && !pathEditingNodeId && (
|
||||
<PenStrokeToolbar
|
||||
node={selectedPenStrokeNode}
|
||||
bounds={selectedPenStrokeBounds}
|
||||
@@ -4470,11 +4711,20 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
onToggleGrid={() => setShowGrid((value) => !value)}
|
||||
onUploadAsset={() => canvasAssetInputRef.current?.click()}
|
||||
/>
|
||||
{canEdit && activeTool === "pen" && (
|
||||
{canEdit && (activeTool === "pen" || activeTool === "path") && (
|
||||
<PenToolControls
|
||||
settings={penSettings}
|
||||
settings={activeTool === "path" ? pathPenSettings : penSettings}
|
||||
variant={activeTool === "path" ? "path" : "brush"}
|
||||
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
|
||||
onChange={setPenSettings}
|
||||
onChange={(settings) => {
|
||||
if (activeTool === "path") {
|
||||
setPathPenSettings(settings);
|
||||
const draft = pathPenDraftRef.current;
|
||||
if (draft) updatePathPenDraft({ ...draft, ...settings });
|
||||
return;
|
||||
}
|
||||
setPenSettings(settings);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -4634,6 +4884,7 @@ function CanvasNodeView({
|
||||
selected,
|
||||
mockupDragging,
|
||||
editing,
|
||||
pathEditing,
|
||||
showResizeHandles,
|
||||
hidden,
|
||||
locked,
|
||||
@@ -4645,6 +4896,7 @@ function CanvasNodeView({
|
||||
hasClipboard,
|
||||
contextMenuHandlers,
|
||||
onPointerDown,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onTitleRename,
|
||||
onResizePointerDown,
|
||||
@@ -4656,6 +4908,7 @@ function CanvasNodeView({
|
||||
selected: boolean;
|
||||
mockupDragging: boolean;
|
||||
editing: boolean;
|
||||
pathEditing: boolean;
|
||||
showResizeHandles: boolean;
|
||||
hidden: boolean;
|
||||
locked: boolean;
|
||||
@@ -4667,6 +4920,7 @@ function CanvasNodeView({
|
||||
hasClipboard: boolean;
|
||||
contextMenuHandlers: NodeContextMenuHandlers | null;
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onDoubleClick: () => void;
|
||||
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onTitleRename: (title: string) => void;
|
||||
onResizePointerDown: (event: React.PointerEvent<HTMLButtonElement>, handle: ResizeHandle) => void;
|
||||
@@ -4710,10 +4964,14 @@ function CanvasNodeView({
|
||||
|
||||
const nodeElement = (
|
||||
<div
|
||||
className={`canvas-node node-${node.type} tone-${node.tone} ${manualFrame ? "manual-frame-node" : ""} ${shapeNode || booleanShapeGroup ? "shape-node" : ""} ${penStroke ? "pen-stroke-node" : ""} ${visual ? "visual-node" : textOverlay ? "text-overlay-node" : "note-node"} ${selected ? "selected" : ""} ${mockupDragging ? "is-mockup-dragging" : ""} ${hidden ? "is-hidden" : ""} ${locked ? "is-locked" : ""}`}
|
||||
className={`canvas-node node-${node.type} tone-${node.tone} ${manualFrame ? "manual-frame-node" : ""} ${shapeNode || booleanShapeGroup ? "shape-node" : ""} ${penStroke ? "pen-stroke-node" : ""} ${visual ? "visual-node" : textOverlay ? "text-overlay-node" : "note-node"} ${selected ? "selected" : ""} ${mockupDragging ? "is-mockup-dragging" : ""} ${pathEditing ? "is-path-editing" : ""} ${hidden ? "is-hidden" : ""} ${locked ? "is-locked" : ""}`}
|
||||
data-canvas-node-id={node.id}
|
||||
style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
|
||||
onPointerDown={onPointerDown}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="node-content-layer" style={contentLayerStyle}>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@import "./pages/NotFoundPage/index.css";
|
||||
@import "./components/DeleteProjectDialog/index.css";
|
||||
@import "./components/AccountManagementDialog/index.css";
|
||||
@import "./components/CanvasLoading/index.css";
|
||||
@import "./components/WorkspaceTitle/index.css";
|
||||
@import "./components/CanvasAgentComposer/index.css";
|
||||
@import "./components/ImageGeneratorFloatingComposer/index.css";
|
||||
@@ -1614,6 +1615,16 @@ button:disabled {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.canvas-stage.is-path-pen-tool,
|
||||
.canvas-stage.is-path-pen-tool .canvas-world,
|
||||
.canvas-stage.is-path-pen-tool .canvas-node,
|
||||
.canvas-stage.is-path-pen-tool .artboard-image,
|
||||
.canvas-stage.is-path-pen-tool .artboard-preview,
|
||||
.canvas-stage.is-path-pen-tool .canvas-path-editor-overlay,
|
||||
.canvas-stage.is-path-pen-tool .canvas-path-editor-overlay > canvas {
|
||||
cursor: url("/cursors/pen-tool.svg?v=3") 4 4, default !important;
|
||||
}
|
||||
|
||||
.canvas-stage.is-text-tool,
|
||||
.canvas-stage.is-text-tool .canvas-world,
|
||||
.canvas-stage.is-text-tool .canvas-node,
|
||||
@@ -1708,6 +1719,24 @@ button:disabled {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvas-path-editor-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 38;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-path-editor-overlay > canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvas-node.is-path-editing {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-readonly-shield {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1810,6 +1839,51 @@ button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.path-pen-draft-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.path-pen-draft-path {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.path-pen-handle-line {
|
||||
stroke: #626b78;
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.path-pen-handle {
|
||||
fill: #fff;
|
||||
stroke: #20242b;
|
||||
stroke-width: 1.25;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.path-pen-anchor {
|
||||
fill: #fff;
|
||||
stroke: #2563eb;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.path-pen-anchor.is-first {
|
||||
fill: #2563eb;
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.path-pen-anchor.is-close-target {
|
||||
fill: #fff;
|
||||
stroke: #111827;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.frame-draft-overlay {
|
||||
position: absolute;
|
||||
z-index: 17;
|
||||
@@ -3209,12 +3283,18 @@ button:disabled {
|
||||
.workspace-toolbar {
|
||||
left: 50%;
|
||||
bottom: 14px;
|
||||
z-index: 42;
|
||||
gap: 4px;
|
||||
height: 44px;
|
||||
padding: 5px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.canvas-bottom-left,
|
||||
.pen-tool-controls {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
.workspace-toolbar.is-offset-by-files-panel {
|
||||
left: calc(var(--canvas-side-panel-width) + (100% - var(--canvas-side-panel-width)) / 2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyEditedPathToNode, editablePathForNode, isEditablePathNode, normalizedPathDataToSvg } from "../src/ui/pages/CanvasWorkspace/canvas/editablePath.ts";
|
||||
|
||||
const shapeNode = {
|
||||
id: "shape-1",
|
||||
type: "frame",
|
||||
title: "Rectangle",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 60,
|
||||
content: JSON.stringify({ kind: "rectangle", cornerRadius: 0 }),
|
||||
tone: "shape",
|
||||
status: "draft",
|
||||
layerRole: "shape",
|
||||
fillColor: "transparent",
|
||||
strokeColor: "#111827",
|
||||
strokeWidth: 2
|
||||
};
|
||||
|
||||
test("converts shape-tool geometry into commands supported by the path editor", () => {
|
||||
const path = editablePathForNode(shapeNode);
|
||||
|
||||
assert.match(path, /^M /);
|
||||
assert.match(path, / L /);
|
||||
assert.match(path, / Z$/);
|
||||
assert.doesNotMatch(path, /[AHV]/);
|
||||
});
|
||||
|
||||
test("persists an edited shape path and expands its canvas bounds without moving world vertices", () => {
|
||||
const edited = applyEditedPathToNode(shapeNode, [1, -10, 10, 2, 100, 10, 2, 100, 60, 2, -10, 60, 11]);
|
||||
const options = JSON.parse(edited.content);
|
||||
|
||||
assert.equal(options.kind, "path");
|
||||
assert.ok(Array.isArray(options.pathData));
|
||||
assert.equal(edited.x, -3);
|
||||
assert.equal(edited.width, 116);
|
||||
assert.match(editablePathForNode(edited), /^M 3 10/);
|
||||
});
|
||||
|
||||
test("keeps unmarked Brush strokes outside the vertex editor", () => {
|
||||
const brushSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><path d="M 5 5 L 90 45" fill="none" stroke="#000000" stroke-width="3"/></svg>';
|
||||
const brushNode = {
|
||||
...shapeNode,
|
||||
id: "brush-1",
|
||||
type: "image",
|
||||
title: "Brush stroke",
|
||||
width: 100,
|
||||
height: 50,
|
||||
content: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(brushSvg)}`,
|
||||
layerRole: "pen-stroke",
|
||||
strokeColor: "#000000",
|
||||
strokeWidth: 3
|
||||
};
|
||||
|
||||
assert.equal(isEditablePathNode(brushNode), false);
|
||||
assert.equal(editablePathForNode(brushNode), null);
|
||||
assert.equal(applyEditedPathToNode(brushNode, [1, 5, 5, 2, 95, 45]), brushNode);
|
||||
});
|
||||
|
||||
test("scales normalized cubic data back into SVG coordinates", () => {
|
||||
assert.equal(normalizedPathDataToSvg([1, 0, 0.5, 5, 0.25, 0, 0.75, 1, 1, 0.5, 11], 200, 100), "M 0 50 C 50 0 150 100 200 50 Z");
|
||||
});
|
||||
|
||||
test("registers SVGPathEditor on the project's Leafer 2.2 Path runtime", async () => {
|
||||
await import("@leafer-in/editor");
|
||||
const core = await import("@leafer-ui/core");
|
||||
await import("leafer-x-path-editor");
|
||||
const path = new core.Path({ path: "M 0 0 L 10 10", editable: true });
|
||||
|
||||
assert.equal(path.editInner, "SVGPathEditor");
|
||||
});
|
||||
|
||||
test("keeps the Pen tool immediately after Shape in the workspace toolbar", async () => {
|
||||
const source = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx", import.meta.url), "utf8");
|
||||
const workspaceSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/index.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(new URL("../src/ui/styles.css", import.meta.url), "utf8");
|
||||
const nodePointerSource = workspaceSource.slice(workspaceSource.indexOf("const handleNodePointerDown"), workspaceSource.indexOf("const startPathEditing"));
|
||||
|
||||
assert.ok(source.indexOf("<ShapeToolMenu") < source.indexOf("<PenTool"));
|
||||
assert.ok(source.indexOf("<PenTool") < source.indexOf("<PenLine"));
|
||||
assert.match(source, /title=\{`\$\{t\("pathPen"\)\} P`\}/);
|
||||
assert.match(source, /title=\{`\$\{t\("pen"\)\} B`\}/);
|
||||
assert.match(workspaceSource, /if \(activeTool === "pen"\) \{\s+startPenDrawing\(event\);/);
|
||||
assert.match(workspaceSource, /setActiveTool\(editAfterCommit \? "path" : "select"\);/);
|
||||
assert.match(workspaceSource, /if \(editAfterCommit\) \{\s+pathEditorExitToolRef\.current = null;\s+setPathEditingNodeId\(node\.id\);\s+\}/);
|
||||
assert.match(workspaceSource, /const startPathEditing[\s\S]*?setActiveTool\("path"\);[\s\S]*?setPathEditingNodeId\(node\.id\);/);
|
||||
assert.match(workspaceSource, /onClose=\{handlePathEditorClose\}/);
|
||||
assert.match(nodePointerSource, /if \(activeTool === "path"\) \{\s+if \(isEditablePathNode\(node\) && editablePathForNode\(node\)\) \{/);
|
||||
assert.match(nodePointerSource, /commitPathPenDrawing\(currentPathPenDraft, false, false\);/);
|
||||
assert.match(nodePointerSource, /event\.preventDefault\(\);\s+startPathEditing\(node\);\s+return;/);
|
||||
assert.doesNotMatch(nodePointerSource, /!pathPenDraftRef\.current && isEditablePathNode/);
|
||||
assert.match(workspaceSource, /if \(tool !== "path" && pathPenDraftRef\.current\) finishPathPenDrawing\(false, false\);/);
|
||||
assert.doesNotMatch(workspaceSource, /startPenDrawing\(event, true\)/);
|
||||
assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/);
|
||||
assert.match(workspaceSource, /key === "b"\) return void run\(\(\) => chooseCanvasTool\("pen"\)\)/);
|
||||
assert.match(styles, /cursor:\s*url\("\/cursors\/pen-tool\.svg\?v=3"\) 4 4, default !important;/);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
deletePathSegment,
|
||||
getClosestSegmentLocation,
|
||||
splitPathSegment
|
||||
} from "leafer-x-path-editor";
|
||||
|
||||
test("splits an existing straight segment at the clicked position", () => {
|
||||
const points = [{ x: 0, y: 0, move: true }, { x: 10, y: 0 }];
|
||||
const segment = { fromIndex: 0, toIndex: 1, insertIndex: 1 };
|
||||
|
||||
const result = splitPathSegment(points, segment, 0.4);
|
||||
|
||||
assert.deepEqual(result, [{ x: 0, y: 0, move: true }, { x: 4, y: 0 }, { x: 10, y: 0 }]);
|
||||
});
|
||||
|
||||
test("keeps a closed path closed when its closing segment is split", () => {
|
||||
const result = splitPathSegment(
|
||||
[{ x: 0, y: 0, move: true }, { x: 10, y: 0, closed: true }],
|
||||
{ fromIndex: 1, toIndex: 0, insertIndex: 2, closing: true },
|
||||
0.5
|
||||
);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ x: 0, y: 0, move: true },
|
||||
{ x: 10, y: 0, closed: false },
|
||||
{ x: 5, y: 0, closed: true }
|
||||
]);
|
||||
});
|
||||
|
||||
test("splits quadratic and cubic segments without changing their geometry", () => {
|
||||
const quadratic = splitPathSegment(
|
||||
[{ x: 0, y: 0, move: true, x2: 0, y2: 10 }, { x: 10, y: 0 }],
|
||||
{ fromIndex: 0, toIndex: 1, insertIndex: 1 },
|
||||
0.5
|
||||
);
|
||||
assert.deepEqual(quadratic, [
|
||||
{ x: 0, y: 0, move: true, x2: undefined, y2: undefined },
|
||||
{ x: 2.5, y: 5, x1: 0, y1: 5, x2: 5, y2: 5, mode: "no-mirror" },
|
||||
{ x: 10, y: 0, x1: undefined, y1: undefined }
|
||||
]);
|
||||
|
||||
const cubic = splitPathSegment(
|
||||
[{ x: 0, y: 0, move: true, x2: 0, y2: 10 }, { x: 10, y: 0, x1: 10, y1: 10 }],
|
||||
{ fromIndex: 0, toIndex: 1, insertIndex: 1 },
|
||||
0.5
|
||||
);
|
||||
assert.deepEqual(cubic, [
|
||||
{ x: 0, y: 0, move: true, x2: 0, y2: 5 },
|
||||
{ x: 5, y: 7.5, x1: 2.5, y1: 7.5, x2: 7.5, y2: 7.5, mode: "no-mirror" },
|
||||
{ x: 10, y: 0, x1: 10, y1: 5 }
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds the insertion point nearest to the pointer", () => {
|
||||
const points = [{ x: 0, y: 0, move: true }, { x: 10, y: 0 }];
|
||||
const location = getClosestSegmentLocation(points, { fromIndex: 0, toIndex: 1, insertIndex: 1 }, { x: 3, y: 5 });
|
||||
|
||||
assert.ok(Math.abs(location.t - 0.3) < 0.0001);
|
||||
assert.deepEqual(location.point, { x: 3, y: 0 });
|
||||
});
|
||||
|
||||
test("deletes the selected segment without reconnecting its endpoints", () => {
|
||||
const openResult = deletePathSegment(
|
||||
[{ x: 0, y: 0, move: true, x2: 4, y2: 0 }, { x: 10, y: 0, x1: 6, y1: 0 }],
|
||||
{ fromIndex: 0, toIndex: 1, insertIndex: 1 }
|
||||
);
|
||||
assert.deepEqual(openResult, [
|
||||
{ x: 0, y: 0, move: true, x2: undefined, y2: undefined },
|
||||
{ x: 10, y: 0, x1: undefined, y1: undefined, move: true, type: "start" }
|
||||
]);
|
||||
|
||||
const closedResult = deletePathSegment(
|
||||
[{ x: 0, y: 0, move: true, x1: 2, y1: 2 }, { x: 10, y: 0, x2: 8, y2: 2, closed: true }],
|
||||
{ fromIndex: 1, toIndex: 0, insertIndex: 2, closing: true }
|
||||
);
|
||||
assert.deepEqual(closedResult, [
|
||||
{ x: 0, y: 0, move: true, x1: undefined, y1: undefined },
|
||||
{ x: 10, y: 0, x2: undefined, y2: undefined, closed: false }
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
appendPathPenAnchor,
|
||||
closePathPenDraft,
|
||||
constrainPathPenPoint,
|
||||
createPathPenNode,
|
||||
pathPenDraftToPathData,
|
||||
setLastPathPenAnchorHandles,
|
||||
startPathPenDraft
|
||||
} from "../src/ui/pages/CanvasWorkspace/canvas/pathPen.ts";
|
||||
|
||||
const settings = { color: "#123456", width: 3 };
|
||||
|
||||
function point(x, y) {
|
||||
return { screen: { x, y }, world: { x, y } };
|
||||
}
|
||||
|
||||
test("creates one Pen anchor for each user click", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(10, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 80));
|
||||
|
||||
assert.equal(draft.anchors.length, 3);
|
||||
assert.deepEqual(pathPenDraftToPathData(draft), [1, 10, 20, 2, 110, 20, 2, 110, 80]);
|
||||
});
|
||||
|
||||
test("turns click-drag anchors into Photoshop-style mirrored Bezier handles", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(10, 20));
|
||||
draft = setLastPathPenAnchorHandles(draft, point(40, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 80));
|
||||
draft = setLastPathPenAnchorHandles(draft, point(130, 100));
|
||||
|
||||
assert.deepEqual(draft.anchors[0].handleIn?.world, { x: -20, y: 20 });
|
||||
assert.deepEqual(draft.anchors[0].handleOut?.world, { x: 40, y: 20 });
|
||||
assert.deepEqual(draft.anchors[1].handleIn?.world, { x: 90, y: 60 });
|
||||
assert.deepEqual(pathPenDraftToPathData(draft), [1, 10, 20, 5, 40, 20, 90, 60, 110, 80]);
|
||||
});
|
||||
|
||||
test("constrains Pen anchors and handles to 45-degree increments with Shift", () => {
|
||||
const constrained = constrainPathPenPoint({ x: 10, y: 10 }, { x: 42, y: 24 });
|
||||
|
||||
assert.equal(Math.round(constrained.x), 35);
|
||||
assert.equal(Math.round(constrained.y), 35);
|
||||
});
|
||||
|
||||
test("closes a Pen path back to its first user anchor", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(0, 0));
|
||||
draft = appendPathPenAnchor(draft, point(100, 0));
|
||||
draft = appendPathPenAnchor(draft, point(100, 100));
|
||||
draft = closePathPenDraft(draft);
|
||||
|
||||
assert.deepEqual(pathPenDraftToPathData(draft), [1, 0, 0, 2, 100, 0, 2, 100, 100, 11]);
|
||||
});
|
||||
|
||||
test("persists a completed Pen path as an editable shape node", () => {
|
||||
let draft = startPathPenDraft(settings, 1, point(10, 20));
|
||||
draft = appendPathPenAnchor(draft, point(110, 20));
|
||||
const node = createPathPenNode(draft, "Pen");
|
||||
const options = JSON.parse(node.content);
|
||||
|
||||
assert.equal(node.type, "frame");
|
||||
assert.equal(node.layerRole, "shape");
|
||||
assert.equal(node.strokeColor, settings.color);
|
||||
assert.equal(options.kind, "path");
|
||||
assert.deepEqual(options.pathData, [1, 0.04955, 0.5, 2, 0.95045, 0.5]);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user