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:
2026-07-17 11:28:29 +08:00
parent fe4b08f548
commit d424b56076
30 changed files with 3791 additions and 44 deletions
+12
View File
@@ -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
+77
View File
@@ -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'}
+16
View File
@@ -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

+7 -4
View File
@@ -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",
+3
View File
@@ -375,6 +375,9 @@
"pen": "画笔",
"penColor": "画笔颜色",
"penWidth": "画笔粗细",
"pathPen": "钢笔",
"pathPenColor": "钢笔颜色",
"pathPenWidth": "钢笔粗细",
"grid": "网格",
"smartArtboard": "智能画板",
"shapeTool": "形状",
+4 -6
View File
@@ -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 />;
+18 -5
View File
@@ -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;
+271 -13
View File
@@ -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}>
+80
View File
@@ -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);
}
+101
View File
@@ -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 }
]);
});
+68
View File
@@ -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]);
});
+1
View File
@@ -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