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
+43
View File
@@ -1,5 +1,21 @@
# Findings: Server-Backed Brand Kit Canvas Context
## 2026-07-17 Pen Tool and Shape Path Editing
- The request is frontend-only unless canvas persistence lacks a path-compatible node representation; the existing API-first go-zero workflow does not apply until a server contract change is proven necessary.
- The integration must preserve the existing workspace visual language and place a Pen icon immediately after Shape.
- Required behavior includes free path drawing, double-click entry/exit for path editing, automatic vertex/control-point editing through `leafer-x-path-editor`, and edit support for shapes created by the existing Shape tool.
- The toolbar already has a Pen icon immediately after Shape, but it currently activates a freehand brush that stores a flattened SVG data URL as an image node; those strokes are not vertex-editable.
- Shape-tool nodes are structured `frame` nodes whose JSON content describes rectangle, ellipse, polygon, star, line, or arrow geometry. They render through React SVG, while the existing Leafer adapter only owns successful image textures and manual frames.
- The integration can stay within the existing `CanvasNode` contract by persisting editable path data inside `content`; no REST or database field is needed.
- `leafer-x-path-editor@1.1.3` installed successfully, but its published peer dependencies are pinned to Leafer 1.1.0 while this project uses `leafer-ui@2.2.2`; the initial install pulled a second Leafer 1.x graph and reported peer conflicts.
- The plugin registers an inner editor on Leafer `Path`, emits `PathEditorEvent.CHANGE`, and expects an `App` with the editor plugin enabled. Runtime integration must ensure the plugin and project resolve the same Leafer core/editor classes.
- The plugin README matches the requested vertex/control behavior. Its current source uses Backspace for anchor deletion even though the requested interaction says Enter, so the application may need an explicit Enter-key bridge while editing.
- Runtime registration succeeds with the project's `@leafer-ui/core@2.2.2` and `@leafer-in/editor@2.2.2`; `@leafer-in/state` is overridden to 2.2.2 so only one Leafer generation is shipped. The package manifest still emits peer warnings because it declares exact 1.1.0 peers.
- Editable shape and Pen paths are normalized inside the existing opaque shape JSON `content`; Brush strokes remain SVG data URLs. Both survive the current frontend/server snapshot contract without adding fields or changing APIs.
- Path edits that cross the current node bounds expand the node while preserving vertex world coordinates, preventing clipping after editor exit.
- User clarification: the existing freehand brush must remain a separate tool. The new Pen is additive, sits immediately after Shape, and creates structured editable shape paths; Brush keeps its existing SVG image-node behavior and width/color controls.
## 2026-07-16 Canvas Performance and LeaferJS Evaluation
- The current canvas is a React/DOM renderer: every visual node is a `CanvasNodeCard`, and the viewport is applied through a transformed DOM content layer.
@@ -261,3 +277,30 @@
- A hidden `settings` directive now carries only the resolved node ID with the submitted turn; optimistic chat continues to render the original visible contents, so users do not see placement metadata.
- Resolution walks submitted capsules from right to left, preferring the stable `canvas-node:` identity and falling back to source URL matching. An unmatched external capsule falls back to the preceding canvas-backed capsule; no match emits no directive and preserves default placement.
- Backend placement applies to initial placeholders, non-incremental results, and every incremental result. Multiple outputs form a left-to-right row beside the anchor while the referenced image remains unchanged.
# Pen/Brush correction (2026-07-17)
- The required toolbar order is `Shape -> PenTool -> PenLine (Brush) -> Text`; the Pen button is additive, not a replacement.
- Preserve the existing `pen` tool as continuous Brush behavior and use the separate `path` tool for new vertex-editable Pen paths.
- Only explicitly marked new Pen SVGs and Shape-tool nodes should enter `leafer-x-path-editor`; unmarked legacy Brush strokes must remain non-editable.
- Current source already has separate toolbar buttons and `P`/`B` shortcuts, but `editablePathForNode` still accepts every `pen-stroke` SVG and therefore crosses the Brush/Pen boundary.
- `isEditablePathNode` currently recognizes only Shape nodes, so the new Pen node path must also be aligned with the marker contract.
- The partial implementation already persists new Pen output through `createEditablePenPathNode` as a Shape `kind: "path"`; this is the clean existing-domain distinction, so no SVG marker or API change is required.
- The remaining fix is to remove legacy `pen-stroke` SVGs from `editablePathForNode`/`applyEditedPathToNode` and lock the `pen -> Brush` versus `path -> Pen` wiring in the regression test.
- Frontend review confirms the additive UI follows the existing compact toolbar pattern and Lucide icon system; no visual redesign is needed for this correction.
- Dependency inspection resolves `leafer-ui`, `@leafer-in/editor`, `@leafer-in/state`, `@leafer-ui/core`, and `@leafer-ui/interface` to exactly `2.2.2`; `leafer-x-path-editor` is `1.1.3`.
- The previous Vite browser-verification server on port 5177 is no longer running and must be restarted after the production build.
- `leafer-x-path-editor` is strictly an inner editor for an existing Leafer `Path`; it does not provide blank-canvas path creation. The current Pen incorrectly stops after reusing Brush sampling and selecting the resulting Shape.
- Correct Pen workflow: blank-canvas drag creates the initial Shape-backed path and immediately opens `CanvasPathEditor`; clicking an existing editable Shape while Pen is active opens the same editor. Select-mode double-click remains supported.
- The corrected implementation passes the full 43-test suite, strict TypeScript, Next production build, Vite production build, and `git diff --check`.
- Browser sessions open the authentication dialog on the fresh Vite origin because the prior route mocks were origin/session scoped; restore focused auth/project mocks before interaction verification.
- Browser mock requirements are a stored `img-infinite-canvas.auth.session`, `/api/auth/profile`, the Lovart-compatible query-project fallback, `/api/projects/path-editor-test`, plus any initial Brand Kit/thread reads made after workspace hydration.
- The focused browser fixture can use one persisted rectangle Shape plus an empty canvas area, mock the Lovart query route as unavailable, and return the normal project document fallback; save-canvas responses must also be mocked because closing Path Editor commits immediately.
- Real-browser Pen verification passes: toolbar order is Shape, Pen, Brush, Text; drawing with Pen creates a second Shape node and immediately mounts one Path Editor overlay with two Leafer canvases while hiding the React preview.
- Real-browser Brush verification passes after closing the editor: Brush creates one `pen-stroke-node`, mounts no editor overlay, leaves Shape count unchanged, and remains the active repeated-drawing tool.
- Browser probing found one remaining wiring defect: with Pen active, clicks hit the existing Shape's SVG/DOM node but no editor overlay mounts and Pen stays active, so an earlier branch in `handleNodePointerDown` is intercepting before the Path branch.
- While verifying, the shared worktree gained a separate anchor-based `pathPen` implementation and updated regressions. Preserve it: Pen now creates anchors/control handles independently from Brush, but its commit and existing-Shape node branch still need to hand off to `CanvasPathEditor`.
- The first post-fix browser probe was stale: Vite logged a Fast Refresh invalidation because `createEditablePenPathNode` disappeared during the concurrent anchor-Pen change. A full reload is required before treating the browser result as current.
- After a clean Vite restart, active Pen clicking the existing rectangle mounts one Path Editor overlay, hides `shape-1`, and switches the active tool to Select.
- Anchor-based Pen creation also hands off correctly: two blank-canvas anchors plus Enter create a second Shape and immediately mount Path Editor for its new node ID.
- Final clean-browser Brush check passes on the anchor-Pen build: it creates exactly one `pen-stroke-node`, mounts no Path Editor overlay, and remains active for repeated freehand strokes.
- The only browser console errors are the two deliberate 404 responses used to force the mocked Lovart query-project route onto the normal project-document fallback; there are no runtime/editor errors.
- Final dependency and build checks confirm the frontend design integration remains on the existing compact toolbar system and exact Leafer 2.2.2 runtime; no version upgrade or API contract change was introduced.
+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
+38
View File
@@ -1,5 +1,17 @@
# Progress Log
## 2026-07-17 Pen Tool and Shape Path Editing
- Loaded repository instructions, go-zero decision rules, frontend design guidance, and the file-based planning workflow.
- Preserved all existing plan history and started Phase 35 for the requested Pen tool and path editing integration.
- No backend contract change has been assumed; discovery will determine whether the existing canvas node schema can persist editable paths.
- Traced the current toolbar, freehand Pen implementation, shape-node JSON model, React SVG previews, and Leafer renderer boundary.
- Installed `leafer-x-path-editor@1.1.3`; dependency inspection exposed a Leafer 1.1/2.2 peer-version mismatch that must be resolved before UI wiring.
- Kept Leafer at 2.2.2, aligned the plugin runtime dependencies, and added a dedicated interactive path-editor overlay with programmatic enter/exit and Enter-to-delete bridging.
- Added normalized shape-path persistence, SVG pen-path write-back, shape preview/export support, toolbar integration, and focused regression coverage.
- Focused path tests, strict TypeScript compilation, and `git diff --check` pass.
- Corrected the tool model after user clarification: restored Brush as the original freehand SVG tool and added a separate Pen mode that produces editable normalized path nodes.
## Session: 2026-07-15 (CAD Semantics and Crop)
### Phase 23
@@ -268,3 +280,29 @@
- Rechecked `/Users/liangxu/Desktop/花语江南6-204方案.dwg`: conversion completes in about 10 seconds, emits an 8.49 MB SVG instead of roughly 50 MB, and frames the visible drawing at a 1.43:1 aspect ratio.
- Browser import, nonblank rendering, compact vector toolbar, source SVG download, save, and reload persistence all pass at `http://127.0.0.1:5174`.
- Final `pnpm test`, TypeScript validation, `pnpm build`, and `git diff --check` pass; the build retains only the repository's existing Vite large-chunk warning.
# Session: 2026-07-17
### Phase 35: Pen/Brush correction
- **Status:** complete
- Resumed from the existing Leafer path-editor implementation after the user clarified that Pen must be added after Shape without replacing Brush.
- Locked the correction boundary: `path` is the new editable Pen, `pen` remains the existing continuous Brush, and only marked Pen SVGs plus Shape nodes are vertex-editable.
- Confirmed the toolbar and shortcuts are already separate; isolated the remaining regression to pen-node creation and editable-node detection.
- The focused red test reproduced the boundary bug: marked/unmarked SVG fixtures disagreed with the current Shape-backed Pen contract, and unmarked Brush SVGs were still returned by `editablePathForNode`.
- Removed legacy Brush SVGs from the vertex-editing contract; the focused regression now passes while the new Pen remains a Shape-backed editable path.
- Verification so far: strict TypeScript passes, all 43 frontend tests pass, `git diff --check` passes, and the resolved Leafer dependency tree remains entirely on 2.2.2.
- First production-build attempt stopped because another `next build` process owns the repository build lock; no files were reverted or lock files removed.
- User clarified that the Pen itself must be the Path Editor, not merely Brush sampling persisted as a Shape. Added a new regression expectation for immediate editor entry and Pen-tool editing of existing Shapes.
- First editor-entry patch matched the background handler's duplicate Path branch; TypeScript failed on undefined `node`, so the fix is being moved to the node handler and the source regression is narrowed to that scope.
- Corrected editor-entry wiring now passes focused tests and TypeScript; the complete 43-test suite and Next/Vite production build also pass.
- Started the updated Vite preview at `http://127.0.0.1:5179`; browser verification is restoring the existing mocked project flow on that fresh origin.
- Browser interaction now confirms the new Pen opens `leafer-x-path-editor` immediately after drawing, while Brush retains its prior continuous stroke behavior and remains selected after commit.
- Existing-Shape editing under active Pen did not yet open in the browser despite a confirmed Shape hit; tracing the node pointer handler's earlier branches before finalizing.
- Rebased the remaining fix mentally onto the concurrently added anchor-based Pen implementation; no user changes will be reverted. The needed integration is now `pathPen` commit/click -> `CanvasPathEditor`.
- Added regression expectations for explicit Pen completion opening Path Editor, active-Pen Shape clicks entering Path Editor only when no Pen draft is open, and tool switching committing without forcing editor entry.
- The first anchor-Pen node patch hit the duplicate background branch; the scoped regression and TypeScript failed as intended before browser verification.
- Focused Path Editor and anchor-Pen tests now pass (11/11) with strict TypeScript; browser will reload fully to replace the invalidated hot module.
- Clean-browser verification passes for both Path Editor handoffs: active-Pen Shape click and completed new Pen path. Captured `output/playwright/path-editor-pen-final.png`.
- Rechecked Brush after the clean anchor-Pen reload: continuous freehand behavior remains intact, no editor opens, and Brush stays active.
- Final verification passes on the merged anchor-Pen implementation: 48 frontend tests, strict TypeScript, `git diff --check`, exact Leafer 2.2.2 dependency resolution, Next production build, and Vite production build.
- Reran the full frontend production build after the lock cleared; Next and Vite both pass, with only the existing large-chunk warning.
- Confirmed both local previews still respond with HTTP 200 at `http://127.0.0.1:5175` and `http://127.0.0.1:5177`.
+23 -1
View File
@@ -4,7 +4,7 @@
Persist Brand Kits by authenticated user, bind one optional Brand Kit to each project/canvas, expose a canvas-header selector, and make every Agent Chat turn and newly created thread use the selected kit as authoritative creative context.
## Current Phase
Phase 32
Phase 35
## Phases
@@ -221,6 +221,16 @@ Phase 32
- [x] Run frontend tests, typecheck, build, and preview verification
- **Status:** complete
### Phase 35: Pen Tool and Shape Path Editing
- [x] Trace the workspace shape tool, canvas-node representation, Leafer renderer, and persistence behavior
- [x] Install and integrate `leafer-x-path-editor` without breaking existing shape creation
- [x] Add a new Pen icon immediately after Shape while preserving the existing Brush tool unchanged
- [x] Enable double-click path editing for pen paths and shapes created by the Shape tool
- [x] Keep legacy/unmarked Brush strokes outside the vertex-editor path contract
- [x] Cover editor lifecycle, persistence, and interaction regressions
- [x] Run frontend tests, typecheck/build, and browser verification
- **Status:** complete
## Invariants
- Brand Kits are owned and queried only through the authenticated user ID from request context.
- A project may reference zero or one Brand Kit owned by the same user.
@@ -243,6 +253,7 @@ Phase 32
- User-message image directives render through one attachment capsule/preview path regardless of raster or SVG source format.
- SVG remains the persisted canvas source; transparent WebP derivatives exist only for model-compatible image inputs.
- CAD source nodes are immutable during crop; every crop is a sibling asset positioned beside the source.
- Pen and Brush remain distinct workspace tools: Pen creates vertex-editable paths, while Brush keeps the existing continuous freehand behavior.
- CAD semantic context is bounded, persisted with the relevant source/crop, and sent as model context without becoming visible user-message copy.
## Errors Encountered
@@ -258,7 +269,18 @@ Phase 32
| First i18n patch used an inexact Chinese source string | 1 | Re-read the locale file and patched the exact key/value context. |
| Existing account-device test called removal without an authenticated session context | 1 | Update the test to resolve the issued bearer identity and assert the new current-session contract. |
| Preview backend rejected `/dev/stdin` because go-zero infers config format from the filename extension | 1 | Use a `.yaml` symlink to stdin so the transformed preview config retains a recognized extension without creating a repository config file. |
| Path editor typecheck treated the asynchronously initialized Leafer App as nullable | 1 | Use a non-null local `nextApp` after construction while retaining a nullable cleanup reference. |
| Node's TypeScript test loader could not resolve the extensionless `shapeNode` import from `editablePath.ts` | 1 | Use the explicit `.ts` extension, which is supported by the no-emit bundler configuration and Node test runner. |
| TypeScript rejected the explicit `.ts` import until the no-emit project opted in | 1 | Enable `allowImportingTsExtensions`, matching the Node test loader and bundler-only output model. |
| Pen-path test expected unchanged local coordinates after a control point expanded the node bounds | 1 | Keep the world-coordinate-preserving implementation and use an in-bounds control point for the write-back assertion. |
| Initially repurposed the existing freehand Brush button as Pen | 1 | Restore Brush unchanged and add Pen as a separate tool immediately after Shape. |
| A shell health-check used zsh's read-only `status` variable | 1 | Rename the variable to `http_status`; subsequent endpoint checks returned 200. |
| Next standalone preview was first launched with `next start`, then without copied static assets | 1 | Use the already-running project dev server for the final preview and Vite for isolated mocked browser verification. |
| A second Next development server refused to start because the existing repo-wide dev lock is active | 1 | Use the already-built Next production server on the alternate preview port. |
| Final frontend build found another active `next build` process | 1 | Inspect the active process and wait for it to finish before retrying once; do not remove build locks blindly. |
| Path-tool editor branch was first patched into the duplicate background handler | 1 | TypeScript caught undefined `node`; restore the background branch and scope the node regression to `handleNodePointerDown`. |
| Anchor-Pen follow-up again matched the background Path branch | 1 | Scoped regression and TypeScript caught it; patch using the node handler's unique text-node condition. |
| Browser kept an obsolete Pen module after concurrent exports changed | 1 | Vite reported Fast Refresh invalidation; perform a full page reload after typecheck/build settles before re-verifying. |
| BSD `sed` did not support the GNU `0,/pattern/` address used for the preview port substitution | 1 | Replace it with a portable anchored substitution; the backend now listens on 8889. |
| The in-app browser runtime reported no available browser backends | 1 | Keep the verified local preview running and report the browser-only visual inspection gap; do not substitute an unrelated browser surface against the browser skill rules. |
| Escaping the `psql \d` meta-command through Docker produced an invalid command | 1 | Query `information_schema.columns` and `pg_indexes` with regular SQL instead. |