From d424b56076647e839378a78f031d9a123b9587dd Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 17 Jul 2026 11:28:29 +0800 Subject: [PATCH] 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. --- findings.md | 43 + frontend/package.json | 12 + .../patches/leafer-x-path-editor@1.1.3.patch | 1963 +++++++++++++++++ frontend/pnpm-lock.yaml | 77 + frontend/public/cursors/pen-tool.svg | 16 + frontend/src/i18n/locales/en-US.json | 11 +- frontend/src/i18n/locales/zh-CN.json | 3 + frontend/src/ui/App.tsx | 10 +- frontend/src/ui/components/BrandMark.tsx | 23 +- .../src/ui/components/CanvasLoading/index.css | 178 ++ .../src/ui/components/CanvasLoading/index.tsx | 99 + .../canvas/CanvasPathEditor.tsx | 155 ++ .../pages/CanvasWorkspace/canvas/PenTool.tsx | 35 +- .../canvas/ShapeNodePreview/index.tsx | 13 + .../canvas/ShapeStyleToolbar/index.tsx | 2 +- .../canvas/WorkspaceToolbar.tsx | 7 +- .../CanvasWorkspace/canvas/editablePath.ts | 222 ++ .../CanvasWorkspace/canvas/nodeExport.ts | 5 + .../pages/CanvasWorkspace/canvas/pathPen.ts | 258 +++ .../pages/CanvasWorkspace/canvas/shapeNode.ts | 20 +- .../ui/pages/CanvasWorkspace/canvas/types.ts | 2 +- .../src/ui/pages/CanvasWorkspace/index.tsx | 284 ++- frontend/src/ui/styles.css | 80 + frontend/tests/editablePath.test.mjs | 101 + frontend/tests/pathEditorSegments.test.mjs | 83 + frontend/tests/pathPen.test.mjs | 68 + frontend/tsconfig.json | 1 + frontend/tsconfig.tsbuildinfo | 2 +- progress.md | 38 + task_plan.md | 24 +- 30 files changed, 3791 insertions(+), 44 deletions(-) create mode 100644 frontend/patches/leafer-x-path-editor@1.1.3.patch create mode 100644 frontend/public/cursors/pen-tool.svg create mode 100644 frontend/src/ui/components/CanvasLoading/index.css create mode 100644 frontend/src/ui/components/CanvasLoading/index.tsx create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/editablePath.ts create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/pathPen.ts create mode 100644 frontend/tests/editablePath.test.mjs create mode 100644 frontend/tests/pathEditorSegments.test.mjs create mode 100644 frontend/tests/pathPen.test.mjs diff --git a/findings.md b/findings.md index 87382c2..182644f 100644 --- a/findings.md +++ b/findings.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index 162a625..bca6230 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" + } } } diff --git a/frontend/patches/leafer-x-path-editor@1.1.3.patch b/frontend/patches/leafer-x-path-editor@1.1.3.patch new file mode 100644 index 0000000..6544d42 --- /dev/null +++ b/frontend/patches/leafer-x-path-editor@1.1.3.patch @@ -0,0 +1,1963 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index f7a34bfc04a4661ee493c1e8fcb6c0356cdcf530..0fea36da222e9fe024a14b3a4d46b8a6c50a59da 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -36,12 +36,15 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr + const { M, L, C, Q, Z } = core.PathCommandMap; + function pathData2Point(path) { + const pointData = []; ++ let subpathStartIndex = -1; + for (let i = 0; i < path.length; i++) { + const point = {}; + if (path[i] === M) { + point.x = path[++i]; + point.y = path[++i]; + point.type = 'start'; ++ point.move = true; ++ subpathStartIndex = pointData.length; + } + else if (path[i] === L) { + point.x = path[++i]; +@@ -62,63 +65,84 @@ function pathData2Point(path) { + point.y = path[++i]; + } + else if (path[i] === Z) { +- point.x = pointData[0].x; +- point.y = pointData[0].y; ++ const start = pointData[subpathStartIndex]; ++ const end = pointData[pointData.length - 1]; ++ if (!start || !end) ++ continue; ++ if (pointData.length - 1 > subpathStartIndex && ++ start.x === end.x && ++ start.y === end.y) { ++ pointData.pop(); ++ if (end.x1 !== undefined && end.y1 !== undefined) { ++ start.x1 = end.x1; ++ start.y1 = end.y1; ++ } ++ } ++ pointData[pointData.length - 1].closed = true; ++ continue; + } + pointData.push(point); + } +- const head = pointData[0]; +- const tail = pointData[pointData.length - 1]; +- if (tail.type !== 'end') { +- if (head.x === tail.x && head.y === tail.y) { +- pointData.pop(); +- if (tail.x2 !== undefined && tail.x2 !== undefined) { +- pointData[pointData.length - 1].x2 = tail.x2; +- pointData[pointData.length - 1].y2 = tail.y2; +- } +- if (tail.x1 !== undefined && tail.y1 !== undefined) { +- pointData[0].x1 = tail.x1; +- pointData[0].y1 = tail.y1; ++ for (let endIndex = pointData.length - 1; endIndex > 0;) { ++ let startIndex = endIndex; ++ while (startIndex > 0 && !pointData[startIndex].move) ++ startIndex--; ++ const start = pointData[startIndex]; ++ const end = pointData[endIndex]; ++ if (endIndex > startIndex && ++ !end.closed && ++ start.x === end.x && ++ start.y === end.y) { ++ pointData.splice(endIndex, 1); ++ const newEnd = pointData[endIndex - 1]; ++ newEnd.closed = true; ++ if (end.x1 !== undefined && end.y1 !== undefined) { ++ start.x1 = end.x1; ++ start.y1 = end.y1; + } + } ++ endIndex = startIndex - 1; + } + return pointData; + } + function point2PathData(points) { + const pathData = []; +- const getNext = (index) => { +- if (index === points.length - 1) { +- return points[0]; ++ if (!points.length) ++ return pathData; ++ const pushSegment = (prev, next) => { ++ const { x, y, x1, y1 } = next; ++ if (prev.x2 === undefined && ++ prev.y2 === undefined && ++ x1 === undefined && ++ y1 === undefined) { ++ pathData.push(L, x, y); ++ } ++ else if (prev.x2 !== undefined && ++ prev.y2 !== undefined && ++ x1 !== undefined && ++ y1 !== undefined) { ++ pathData.push(C, prev.x2, prev.y2, x1, y1, x, y); ++ } ++ else if (prev.x2 !== undefined && prev.y2 !== undefined) { ++ pathData.push(Q, prev.x2, prev.y2, x, y); ++ } ++ else if (x1 !== undefined && y1 !== undefined) { ++ pathData.push(Q, x1, y1, x, y); + } +- return points[index + 1]; + }; +- pathData.push(M, points[0].x, points[0].y); ++ let subpathStart = points[0]; + for (let i = 0; i < points.length; i++) { +- const prev = points[i]; +- const next = getNext(i); +- const { type, x, y, x1, y1 } = next; +- if (type === 'end') { +- continue; ++ const point = points[i]; ++ if (i === 0 || point.move || point.type === 'start') { ++ pathData.push(M, point.x, point.y); ++ subpathStart = point; + } + else { +- if (prev.x2 === undefined && +- prev.y2 === undefined && +- x1 === undefined && +- y1 === undefined) { +- pathData.push(L, x, y); +- } +- else if (prev.x2 !== undefined && +- prev.y2 !== undefined && +- x1 !== undefined && +- y1 !== undefined) { +- pathData.push(C, prev.x2 || 0, prev.y2 || 0, x1, y1, x, y); +- } +- else if (prev.x2 !== undefined && prev.y2 !== undefined) { +- pathData.push(Q, prev.x2 || 0, prev.y2 || 0, x, y); +- } +- else if (x1 !== undefined && y1 !== undefined) { +- pathData.push(Q, x1, y1, x, y); +- } ++ pushSegment(points[i - 1], point); ++ } ++ if (point.closed) { ++ pushSegment(point, subpathStart); ++ pathData.push(Z); + } + } + return pathData; +@@ -138,6 +162,134 @@ function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + ++const hasPoint = (x, y) => x !== undefined && y !== undefined; ++const lerp = (from, to, t) => ({ ++ x: from.x + (to.x - from.x) * t, ++ y: from.y + (to.y - from.y) * t, ++}); ++const distanceSquared = (left, right) => Math.pow(left.x - right.x, 2) + Math.pow(left.y - right.y, 2); ++function getSegmentPoints(points, segment) { ++ const from = points[segment.fromIndex]; ++ const to = points[segment.toIndex]; ++ const fromCoordinate = { x: from.x, y: from.y }; ++ const toCoordinate = { x: to.x, y: to.y }; ++ const outgoing = hasPoint(from.x2, from.y2) ++ ? { x: from.x2, y: from.y2 } ++ : undefined; ++ const incoming = hasPoint(to.x1, to.y1) ++ ? { x: to.x1, y: to.y1 } ++ : undefined; ++ return { from, to, fromCoordinate, toCoordinate, outgoing, incoming }; ++} ++function getPathSegmentPoint(points, segment, t) { ++ const { fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(points, segment); ++ if (outgoing && incoming) { ++ const left = lerp(fromCoordinate, outgoing, t); ++ const center = lerp(outgoing, incoming, t); ++ const right = lerp(incoming, toCoordinate, t); ++ return lerp(lerp(left, center, t), lerp(center, right, t), t); ++ } ++ const control = outgoing || incoming; ++ if (control) { ++ return lerp(lerp(fromCoordinate, control, t), lerp(control, toCoordinate, t), t); ++ } ++ return lerp(fromCoordinate, toCoordinate, t); ++} ++function getClosestSegmentLocation(points, segment, target) { ++ const { fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(points, segment); ++ if (!outgoing && !incoming) { ++ const dx = toCoordinate.x - fromCoordinate.x; ++ const dy = toCoordinate.y - fromCoordinate.y; ++ const lengthSquared = dx * dx + dy * dy; ++ const t = lengthSquared ++ ? Math.max(0, Math.min(1, ((target.x - fromCoordinate.x) * dx + (target.y - fromCoordinate.y) * dy) / lengthSquared)) ++ : 0; ++ return { point: getPathSegmentPoint(points, segment, t), t }; ++ } ++ const samples = 40; ++ let bestT = 0; ++ let bestDistance = Number.POSITIVE_INFINITY; ++ for (let index = 0; index <= samples; index++) { ++ const t = index / samples; ++ const distance = distanceSquared(getPathSegmentPoint(points, segment, t), target); ++ if (distance < bestDistance) { ++ bestDistance = distance; ++ bestT = t; ++ } ++ } ++ let left = Math.max(0, bestT - 1 / samples); ++ let right = Math.min(1, bestT + 1 / samples); ++ for (let index = 0; index < 12; index++) { ++ const leftThird = left + (right - left) / 3; ++ const rightThird = right - (right - left) / 3; ++ if (distanceSquared(getPathSegmentPoint(points, segment, leftThird), target) <= ++ distanceSquared(getPathSegmentPoint(points, segment, rightThird), target)) { ++ right = rightThird; ++ } ++ else { ++ left = leftThird; ++ } ++ } ++ const t = (left + right) / 2; ++ return { point: getPathSegmentPoint(points, segment, t), t }; ++} ++function splitPathSegment(points, segment, rawT) { ++ const nextPoints = points.map((point) => (Object.assign({}, point))); ++ const t = Math.max(0.001, Math.min(0.999, rawT)); ++ const { from, to, fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(nextPoints, segment); ++ let newPoint; ++ if (outgoing && incoming) { ++ const left = lerp(fromCoordinate, outgoing, t); ++ const center = lerp(outgoing, incoming, t); ++ const right = lerp(incoming, toCoordinate, t); ++ const leftCenter = lerp(left, center, t); ++ const rightCenter = lerp(center, right, t); ++ const point = lerp(leftCenter, rightCenter, t); ++ from.x2 = left.x; ++ from.y2 = left.y; ++ to.x1 = right.x; ++ to.y1 = right.y; ++ newPoint = Object.assign(Object.assign({}, point), { x1: leftCenter.x, y1: leftCenter.y, x2: rightCenter.x, y2: rightCenter.y, mode: 'no-mirror' }); ++ } ++ else if (outgoing || incoming) { ++ const control = outgoing || incoming; ++ const left = lerp(fromCoordinate, control, t); ++ const right = lerp(control, toCoordinate, t); ++ const point = lerp(left, right, t); ++ from.x2 = undefined; ++ from.y2 = undefined; ++ to.x1 = undefined; ++ to.y1 = undefined; ++ newPoint = Object.assign(Object.assign({}, point), { x1: left.x, y1: left.y, x2: right.x, y2: right.y, mode: 'no-mirror' }); ++ } ++ else { ++ newPoint = getPathSegmentPoint(nextPoints, segment, t); ++ } ++ if (segment.closing) { ++ from.closed = false; ++ newPoint.closed = true; ++ } ++ nextPoints.splice(segment.insertIndex, 0, newPoint); ++ return nextPoints; ++} ++function deletePathSegment(points, segment) { ++ const nextPoints = points.map((point) => (Object.assign({}, point))); ++ const from = nextPoints[segment.fromIndex]; ++ const to = nextPoints[segment.toIndex]; ++ from.x2 = undefined; ++ from.y2 = undefined; ++ to.x1 = undefined; ++ to.y1 = undefined; ++ if (segment.closing) { ++ from.closed = false; ++ } ++ else { ++ to.move = true; ++ to.type = 'start'; ++ } ++ return nextPoints; ++} ++ + class PathEditorEvent extends core.Event { + constructor(type, data) { + super(type); +@@ -195,11 +347,8 @@ let SVGPathEditor = class SVGPathEditor extends editor.InnerEditor { + }), + this.controlLineBox.on_(core.PointerEvent.ENTER, this.handleControlLineEnter.bind(this)), + this.controlLineBox.on_(core.PointerEvent.LEAVE, this.handleControlLineLeave.bind(this)), +- this.controlLineBox.on_(core.PointerEvent.TAP, (e) => { +- if (e.target.data.isAnchor) { +- this.addPointAfter(e.target.data.index, e.target.x, e.target.y); +- } +- }), ++ this.controlLineBox.on_(core.PointerEvent.MOVE, this.handleControlLineMove.bind(this)), ++ this.controlLineBox.on_(core.PointerEvent.TAP, this.handleControlLineTap.bind(this)), + ]; + } + isCtrl() { +@@ -210,12 +359,13 @@ let SVGPathEditor = class SVGPathEditor extends editor.InnerEditor { + isDel() { + if (this.downKey.length !== 1) + return false; +- return this.downKey.some((val) => [8].includes(val)); ++ return this.downKey.some((val) => [8, 46].includes(val)); + } + addKeyEventListener() { + const handleKeyDownEvent = (e) => { + this.downKey.push(e.keyCode); + if (this.isDel()) { ++ e.preventDefault(); + this.handleDeletePoint(); + } + }; +@@ -276,50 +426,127 @@ let SVGPathEditor = class SVGPathEditor extends editor.InnerEditor { + }; + } + onUnload() { ++ this.clearHoverAnchor(); + this.closeInnerEditor(); + this.removeKeyEventListener(); + this.editBox.remove(this.view); + } + handleDeletePoint() { ++ if (this.selectControlLine) { ++ this.points = deletePathSegment(this.points, this.selectControlLine); ++ this.selectControlLine = undefined; ++ this.reDraw(); ++ return; ++ } + if (this.selectPointIdx === undefined) + return; + this.points.splice(this.selectPointIdx, 1); + this.selectPointIdx = undefined; + this.reDraw(); + } ++ isSameSegment(left, right) { ++ return !!(left && ++ right && ++ left.fromIndex === right.fromIndex && ++ left.toIndex === right.toIndex && ++ left.insertIndex === right.insertIndex && ++ !!left.closing === !!right.closing); ++ } ++ clearRemoveAnchorTimer() { ++ if (this.removeAnchorTimer) ++ clearTimeout(this.removeAnchorTimer); ++ this.removeAnchorTimer = undefined; ++ } ++ clearHoverAnchor() { ++ var _a; ++ this.clearRemoveAnchorTimer(); ++ if ((_a = this.hoverAnchorPoint) === null || _a === void 0 ? void 0 : _a.parent) { ++ this.controlLineBox.remove(this.hoverAnchorPoint); ++ } ++ this.hoverAnchorPoint = undefined; ++ } ++ scheduleHoverAnchorRemoval() { ++ this.clearRemoveAnchorTimer(); ++ const anchorPoint = this.hoverAnchorPoint; ++ this.removeAnchorTimer = setTimeout(() => { ++ if (this.hoverAnchorPoint === anchorPoint) ++ this.clearHoverAnchor(); ++ }, 80); ++ } ++ updateHoverAnchor(controlLine, pointer) { ++ const segment = controlLine.data.segment; ++ const location = pointer && this.isSameSegment(segment, this.selectControlLine) ++ ? getClosestSegmentLocation(this.points, segment, pointer) ++ : { point: getPathSegmentPoint(this.points, segment, 0.5), t: 0.5 }; ++ const size = this.pointRadius * 1.6; ++ if (!this.hoverAnchorPoint) { ++ this.hoverAnchorPoint = new core.Ellipse(Object.assign(Object.assign({}, this.pointStyle), { width: size, height: size, offsetX: -size / 2, offsetY: -size / 2, fill: 'white', stroke: '#2193FF', strokeWidth: 2, hitRadius: 4, cursor: 'copy', data: { isAnchor: true } })); ++ this.controlLineBox.add(this.hoverAnchorPoint); ++ } ++ this.hoverAnchorPoint.set({ x: location.point.x, y: location.point.y }); ++ this.hoverAnchorPoint.data.segment = segment; ++ this.hoverAnchorPoint.data.t = location.t; ++ } + handleControlLineEnter(e) { +- e.target.state = 'hover'; ++ this.clearRemoveAnchorTimer(); ++ if (e.target.data.isAnchor) ++ return; ++ if (!e.target.data.isControlLine) ++ return; ++ const segment = e.target.data.segment; ++ e.target.state = this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : 'hover'; + this.hoverControlLine = e.target; +- const { isStraight, start, end } = e.target.data; +- if (isStraight) { +- const centerX = (start.x + end.x) / 2; +- const centerY = (start.y + end.y) / 2; +- const anchorPoint = new core.Ellipse(Object.assign({ x: centerX, y: centerY, width: this.pointRadius, height: this.pointRadius, offsetX: -this.pointRadius, offsetY: -this.pointRadius, cursor: 'pointer', data: { +- isAnchor: true, +- index: e.target.data.index, +- } }, this.pointStyle)); +- this.controlLineBox.add(anchorPoint); +- e.target.data.anchorPoint = anchorPoint; +- } ++ this.updateHoverAnchor(e.target, e.getInnerPoint(this.controlLineBox)); + } + handleControlLineLeave(e) { +- if (this.hoverControlLine) { +- this.hoverControlLine.state = undefined; +- } +- if (e.target.data.anchorPoint) { +- this.controlLineBox.remove(e.target.data.anchorPoint); ++ if (e.target.data.isControlLine) { ++ const segment = e.target.data.segment; ++ e.target.state = this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : undefined; ++ if (this.hoverControlLine === e.target) ++ this.hoverControlLine = undefined; + } +- else if (e.target.data.isAnchor) { +- this.controlLineBox.remove(e.target); ++ this.scheduleHoverAnchorRemoval(); ++ } ++ handleControlLineMove(e) { ++ if (!e.target.data.isControlLine) ++ return; ++ this.updateHoverAnchor(e.target, e.getInnerPoint(this.controlLineBox)); ++ } ++ handleControlLineTap(e) { ++ if (e.target.data.isAnchor) { ++ const segment = e.target.data.segment; ++ this.points = splitPathSegment(this.points, segment, e.target.data.t); ++ this.selectControlLine = undefined; ++ this.selectPointIdx = segment.insertIndex; ++ this.clearHoverAnchor(); ++ this.reDraw(); ++ return; + } ++ if (!e.target.data.isControlLine) ++ return; ++ this.handleSelectPoint(); ++ this.selectControlLine = e.target.data.segment; ++ this.updateControl(); ++ this.updateControlLines(); + } + updateControlLines() { ++ this.clearHoverAnchor(); + const controlLines = []; +- for (let i = 0; i < this.points.length; i++) { +- const currentPoint = this.points[i]; +- const prevPoint = i === 0 ? this.points[this.points.length - 1] : this.points[i - 1]; ++ const addControlLine = (fromIndex, toIndex, insertIndex, closing = false) => { ++ const currentPoint = this.points[toIndex]; ++ const prevPoint = this.points[fromIndex]; + const { x, y, x1, y1 } = currentPoint; + const { x: prevX, y: prevY, x2: prevX2, y2: prevY2 } = prevPoint; ++ const segment = { ++ fromIndex, ++ toIndex, ++ insertIndex, ++ closing, ++ }; + let path = ``; + if (x1 !== undefined && + y1 !== undefined && +@@ -339,34 +566,44 @@ let SVGPathEditor = class SVGPathEditor extends editor.InnerEditor { + const controlLine = new core.Path({ + path, + stroke: 'transparent', +- strokeWidth: 2, ++ strokeWidth: 1.5, ++ hitRadius: 7, ++ hitStroke: 'all', ++ cursor: 'pointer', + states: { + hover: { +- stroke: 'red', ++ stroke: '#2193FF', ++ strokeWidth: 2, ++ }, ++ selected: { ++ stroke: '#EF4444', ++ strokeWidth: 2, + }, + }, ++ state: this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : undefined, + editable: false, + }); +- if (path.indexOf('L') > -1) { +- controlLine.data.isStraight = true; +- controlLine.data.start = { x: prevX, y: prevY }; +- controlLine.data.end = { x, y }; +- controlLine.data.index = i; +- } ++ controlLine.data.isControlLine = true; ++ controlLine.data.segment = segment; + controlLines.push(controlLine); ++ }; ++ let subpathStartIndex = 0; ++ for (let i = 0; i < this.points.length; i++) { ++ const currentPoint = this.points[i]; ++ if (i === 0 || currentPoint.move || currentPoint.type === 'start') { ++ subpathStartIndex = i; ++ } ++ else { ++ addControlLine(i - 1, i, i); ++ } ++ if (currentPoint.closed) { ++ addControlLine(i, subpathStartIndex, i + 1, true); ++ } + } + this.controlLineBox.set({ children: controlLines }); + } +- addPointAfter(index, x, y) { +- const newPoint = { x, y }; +- this.points.splice(index, 0, newPoint); +- if (index < this.selectPointIdx) { +- this.selectPointIdx++; +- } +- this.handleSelectPoint(); +- this.selectPointIdx = index; +- this.reDraw(); +- } + calculateMirrorMode(points) { + return points.map((point) => { + const { x, y, x1, y1, x2, y2 } = point; +@@ -649,7 +886,9 @@ let SVGPathEditor = class SVGPathEditor extends editor.InnerEditor { + handlePointDown(e) { + const { innerId } = e.target; + const pointIdx = this.pointIdxMap.get(innerId); ++ this.selectControlLine = undefined; + this.handleSelectPoint(pointIdx.index); ++ this.updateControlLines(); + } + handlePointDrag(e) { + const { moveX, moveY } = e; +@@ -686,8 +925,10 @@ let SVGPathEditor = class SVGPathEditor extends editor.InnerEditor { + if (prevSelectPoint) { + prevSelectPoint.state = 'unSelect'; + } +- if (index === undefined) ++ if (index === undefined) { ++ this.selectPointIdx = undefined; + return; ++ } + this.selectPointIdx = index; + const selectPoint = this.pointsBox.children[index]; + selectPoint.state = 'select'; +@@ -894,3 +1135,6 @@ SVGPathEditor = __decorate([ + core.Path.setEditInner('SVGPathEditor'); + + exports.PathEditorEvent = PathEditorEvent; ++exports.deletePathSegment = deletePathSegment; ++exports.getClosestSegmentLocation = getClosestSegmentLocation; ++exports.splitPathSegment = splitPathSegment; +diff --git a/dist/index.esm.js b/dist/index.esm.js +index e5a9ddf630b97cef3408f471f63c8d7107417da3..86a377d386b266f3ddac5606ffc84a18c10a0dc4 100644 +--- a/dist/index.esm.js ++++ b/dist/index.esm.js +@@ -34,12 +34,15 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr + const { M, L, C, Q, Z } = PathCommandMap; + function pathData2Point(path) { + const pointData = []; ++ let subpathStartIndex = -1; + for (let i = 0; i < path.length; i++) { + const point = {}; + if (path[i] === M) { + point.x = path[++i]; + point.y = path[++i]; + point.type = 'start'; ++ point.move = true; ++ subpathStartIndex = pointData.length; + } + else if (path[i] === L) { + point.x = path[++i]; +@@ -60,63 +63,84 @@ function pathData2Point(path) { + point.y = path[++i]; + } + else if (path[i] === Z) { +- point.x = pointData[0].x; +- point.y = pointData[0].y; ++ const start = pointData[subpathStartIndex]; ++ const end = pointData[pointData.length - 1]; ++ if (!start || !end) ++ continue; ++ if (pointData.length - 1 > subpathStartIndex && ++ start.x === end.x && ++ start.y === end.y) { ++ pointData.pop(); ++ if (end.x1 !== undefined && end.y1 !== undefined) { ++ start.x1 = end.x1; ++ start.y1 = end.y1; ++ } ++ } ++ pointData[pointData.length - 1].closed = true; ++ continue; + } + pointData.push(point); + } +- const head = pointData[0]; +- const tail = pointData[pointData.length - 1]; +- if (tail.type !== 'end') { +- if (head.x === tail.x && head.y === tail.y) { +- pointData.pop(); +- if (tail.x2 !== undefined && tail.x2 !== undefined) { +- pointData[pointData.length - 1].x2 = tail.x2; +- pointData[pointData.length - 1].y2 = tail.y2; +- } +- if (tail.x1 !== undefined && tail.y1 !== undefined) { +- pointData[0].x1 = tail.x1; +- pointData[0].y1 = tail.y1; ++ for (let endIndex = pointData.length - 1; endIndex > 0;) { ++ let startIndex = endIndex; ++ while (startIndex > 0 && !pointData[startIndex].move) ++ startIndex--; ++ const start = pointData[startIndex]; ++ const end = pointData[endIndex]; ++ if (endIndex > startIndex && ++ !end.closed && ++ start.x === end.x && ++ start.y === end.y) { ++ pointData.splice(endIndex, 1); ++ const newEnd = pointData[endIndex - 1]; ++ newEnd.closed = true; ++ if (end.x1 !== undefined && end.y1 !== undefined) { ++ start.x1 = end.x1; ++ start.y1 = end.y1; + } + } ++ endIndex = startIndex - 1; + } + return pointData; + } + function point2PathData(points) { + const pathData = []; +- const getNext = (index) => { +- if (index === points.length - 1) { +- return points[0]; ++ if (!points.length) ++ return pathData; ++ const pushSegment = (prev, next) => { ++ const { x, y, x1, y1 } = next; ++ if (prev.x2 === undefined && ++ prev.y2 === undefined && ++ x1 === undefined && ++ y1 === undefined) { ++ pathData.push(L, x, y); ++ } ++ else if (prev.x2 !== undefined && ++ prev.y2 !== undefined && ++ x1 !== undefined && ++ y1 !== undefined) { ++ pathData.push(C, prev.x2, prev.y2, x1, y1, x, y); ++ } ++ else if (prev.x2 !== undefined && prev.y2 !== undefined) { ++ pathData.push(Q, prev.x2, prev.y2, x, y); ++ } ++ else if (x1 !== undefined && y1 !== undefined) { ++ pathData.push(Q, x1, y1, x, y); + } +- return points[index + 1]; + }; +- pathData.push(M, points[0].x, points[0].y); ++ let subpathStart = points[0]; + for (let i = 0; i < points.length; i++) { +- const prev = points[i]; +- const next = getNext(i); +- const { type, x, y, x1, y1 } = next; +- if (type === 'end') { +- continue; ++ const point = points[i]; ++ if (i === 0 || point.move || point.type === 'start') { ++ pathData.push(M, point.x, point.y); ++ subpathStart = point; + } + else { +- if (prev.x2 === undefined && +- prev.y2 === undefined && +- x1 === undefined && +- y1 === undefined) { +- pathData.push(L, x, y); +- } +- else if (prev.x2 !== undefined && +- prev.y2 !== undefined && +- x1 !== undefined && +- y1 !== undefined) { +- pathData.push(C, prev.x2 || 0, prev.y2 || 0, x1, y1, x, y); +- } +- else if (prev.x2 !== undefined && prev.y2 !== undefined) { +- pathData.push(Q, prev.x2 || 0, prev.y2 || 0, x, y); +- } +- else if (x1 !== undefined && y1 !== undefined) { +- pathData.push(Q, x1, y1, x, y); +- } ++ pushSegment(points[i - 1], point); ++ } ++ if (point.closed) { ++ pushSegment(point, subpathStart); ++ pathData.push(Z); + } + } + return pathData; +@@ -136,6 +160,134 @@ function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + ++const hasPoint = (x, y) => x !== undefined && y !== undefined; ++const lerp = (from, to, t) => ({ ++ x: from.x + (to.x - from.x) * t, ++ y: from.y + (to.y - from.y) * t, ++}); ++const distanceSquared = (left, right) => Math.pow(left.x - right.x, 2) + Math.pow(left.y - right.y, 2); ++function getSegmentPoints(points, segment) { ++ const from = points[segment.fromIndex]; ++ const to = points[segment.toIndex]; ++ const fromCoordinate = { x: from.x, y: from.y }; ++ const toCoordinate = { x: to.x, y: to.y }; ++ const outgoing = hasPoint(from.x2, from.y2) ++ ? { x: from.x2, y: from.y2 } ++ : undefined; ++ const incoming = hasPoint(to.x1, to.y1) ++ ? { x: to.x1, y: to.y1 } ++ : undefined; ++ return { from, to, fromCoordinate, toCoordinate, outgoing, incoming }; ++} ++function getPathSegmentPoint(points, segment, t) { ++ const { fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(points, segment); ++ if (outgoing && incoming) { ++ const left = lerp(fromCoordinate, outgoing, t); ++ const center = lerp(outgoing, incoming, t); ++ const right = lerp(incoming, toCoordinate, t); ++ return lerp(lerp(left, center, t), lerp(center, right, t), t); ++ } ++ const control = outgoing || incoming; ++ if (control) { ++ return lerp(lerp(fromCoordinate, control, t), lerp(control, toCoordinate, t), t); ++ } ++ return lerp(fromCoordinate, toCoordinate, t); ++} ++function getClosestSegmentLocation(points, segment, target) { ++ const { fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(points, segment); ++ if (!outgoing && !incoming) { ++ const dx = toCoordinate.x - fromCoordinate.x; ++ const dy = toCoordinate.y - fromCoordinate.y; ++ const lengthSquared = dx * dx + dy * dy; ++ const t = lengthSquared ++ ? Math.max(0, Math.min(1, ((target.x - fromCoordinate.x) * dx + (target.y - fromCoordinate.y) * dy) / lengthSquared)) ++ : 0; ++ return { point: getPathSegmentPoint(points, segment, t), t }; ++ } ++ const samples = 40; ++ let bestT = 0; ++ let bestDistance = Number.POSITIVE_INFINITY; ++ for (let index = 0; index <= samples; index++) { ++ const t = index / samples; ++ const distance = distanceSquared(getPathSegmentPoint(points, segment, t), target); ++ if (distance < bestDistance) { ++ bestDistance = distance; ++ bestT = t; ++ } ++ } ++ let left = Math.max(0, bestT - 1 / samples); ++ let right = Math.min(1, bestT + 1 / samples); ++ for (let index = 0; index < 12; index++) { ++ const leftThird = left + (right - left) / 3; ++ const rightThird = right - (right - left) / 3; ++ if (distanceSquared(getPathSegmentPoint(points, segment, leftThird), target) <= ++ distanceSquared(getPathSegmentPoint(points, segment, rightThird), target)) { ++ right = rightThird; ++ } ++ else { ++ left = leftThird; ++ } ++ } ++ const t = (left + right) / 2; ++ return { point: getPathSegmentPoint(points, segment, t), t }; ++} ++function splitPathSegment(points, segment, rawT) { ++ const nextPoints = points.map((point) => (Object.assign({}, point))); ++ const t = Math.max(0.001, Math.min(0.999, rawT)); ++ const { from, to, fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(nextPoints, segment); ++ let newPoint; ++ if (outgoing && incoming) { ++ const left = lerp(fromCoordinate, outgoing, t); ++ const center = lerp(outgoing, incoming, t); ++ const right = lerp(incoming, toCoordinate, t); ++ const leftCenter = lerp(left, center, t); ++ const rightCenter = lerp(center, right, t); ++ const point = lerp(leftCenter, rightCenter, t); ++ from.x2 = left.x; ++ from.y2 = left.y; ++ to.x1 = right.x; ++ to.y1 = right.y; ++ newPoint = Object.assign(Object.assign({}, point), { x1: leftCenter.x, y1: leftCenter.y, x2: rightCenter.x, y2: rightCenter.y, mode: 'no-mirror' }); ++ } ++ else if (outgoing || incoming) { ++ const control = outgoing || incoming; ++ const left = lerp(fromCoordinate, control, t); ++ const right = lerp(control, toCoordinate, t); ++ const point = lerp(left, right, t); ++ from.x2 = undefined; ++ from.y2 = undefined; ++ to.x1 = undefined; ++ to.y1 = undefined; ++ newPoint = Object.assign(Object.assign({}, point), { x1: left.x, y1: left.y, x2: right.x, y2: right.y, mode: 'no-mirror' }); ++ } ++ else { ++ newPoint = getPathSegmentPoint(nextPoints, segment, t); ++ } ++ if (segment.closing) { ++ from.closed = false; ++ newPoint.closed = true; ++ } ++ nextPoints.splice(segment.insertIndex, 0, newPoint); ++ return nextPoints; ++} ++function deletePathSegment(points, segment) { ++ const nextPoints = points.map((point) => (Object.assign({}, point))); ++ const from = nextPoints[segment.fromIndex]; ++ const to = nextPoints[segment.toIndex]; ++ from.x2 = undefined; ++ from.y2 = undefined; ++ to.x1 = undefined; ++ to.y1 = undefined; ++ if (segment.closing) { ++ from.closed = false; ++ } ++ else { ++ to.move = true; ++ to.type = 'start'; ++ } ++ return nextPoints; ++} ++ + class PathEditorEvent extends Event { + constructor(type, data) { + super(type); +@@ -193,11 +345,8 @@ let SVGPathEditor = class SVGPathEditor extends InnerEditor { + }), + this.controlLineBox.on_(PointerEvent.ENTER, this.handleControlLineEnter.bind(this)), + this.controlLineBox.on_(PointerEvent.LEAVE, this.handleControlLineLeave.bind(this)), +- this.controlLineBox.on_(PointerEvent.TAP, (e) => { +- if (e.target.data.isAnchor) { +- this.addPointAfter(e.target.data.index, e.target.x, e.target.y); +- } +- }), ++ this.controlLineBox.on_(PointerEvent.MOVE, this.handleControlLineMove.bind(this)), ++ this.controlLineBox.on_(PointerEvent.TAP, this.handleControlLineTap.bind(this)), + ]; + } + isCtrl() { +@@ -208,12 +357,13 @@ let SVGPathEditor = class SVGPathEditor extends InnerEditor { + isDel() { + if (this.downKey.length !== 1) + return false; +- return this.downKey.some((val) => [8].includes(val)); ++ return this.downKey.some((val) => [8, 46].includes(val)); + } + addKeyEventListener() { + const handleKeyDownEvent = (e) => { + this.downKey.push(e.keyCode); + if (this.isDel()) { ++ e.preventDefault(); + this.handleDeletePoint(); + } + }; +@@ -274,50 +424,127 @@ let SVGPathEditor = class SVGPathEditor extends InnerEditor { + }; + } + onUnload() { ++ this.clearHoverAnchor(); + this.closeInnerEditor(); + this.removeKeyEventListener(); + this.editBox.remove(this.view); + } + handleDeletePoint() { ++ if (this.selectControlLine) { ++ this.points = deletePathSegment(this.points, this.selectControlLine); ++ this.selectControlLine = undefined; ++ this.reDraw(); ++ return; ++ } + if (this.selectPointIdx === undefined) + return; + this.points.splice(this.selectPointIdx, 1); + this.selectPointIdx = undefined; + this.reDraw(); + } ++ isSameSegment(left, right) { ++ return !!(left && ++ right && ++ left.fromIndex === right.fromIndex && ++ left.toIndex === right.toIndex && ++ left.insertIndex === right.insertIndex && ++ !!left.closing === !!right.closing); ++ } ++ clearRemoveAnchorTimer() { ++ if (this.removeAnchorTimer) ++ clearTimeout(this.removeAnchorTimer); ++ this.removeAnchorTimer = undefined; ++ } ++ clearHoverAnchor() { ++ var _a; ++ this.clearRemoveAnchorTimer(); ++ if ((_a = this.hoverAnchorPoint) === null || _a === void 0 ? void 0 : _a.parent) { ++ this.controlLineBox.remove(this.hoverAnchorPoint); ++ } ++ this.hoverAnchorPoint = undefined; ++ } ++ scheduleHoverAnchorRemoval() { ++ this.clearRemoveAnchorTimer(); ++ const anchorPoint = this.hoverAnchorPoint; ++ this.removeAnchorTimer = setTimeout(() => { ++ if (this.hoverAnchorPoint === anchorPoint) ++ this.clearHoverAnchor(); ++ }, 80); ++ } ++ updateHoverAnchor(controlLine, pointer) { ++ const segment = controlLine.data.segment; ++ const location = pointer && this.isSameSegment(segment, this.selectControlLine) ++ ? getClosestSegmentLocation(this.points, segment, pointer) ++ : { point: getPathSegmentPoint(this.points, segment, 0.5), t: 0.5 }; ++ const size = this.pointRadius * 1.6; ++ if (!this.hoverAnchorPoint) { ++ this.hoverAnchorPoint = new Ellipse(Object.assign(Object.assign({}, this.pointStyle), { width: size, height: size, offsetX: -size / 2, offsetY: -size / 2, fill: 'white', stroke: '#2193FF', strokeWidth: 2, hitRadius: 4, cursor: 'copy', data: { isAnchor: true } })); ++ this.controlLineBox.add(this.hoverAnchorPoint); ++ } ++ this.hoverAnchorPoint.set({ x: location.point.x, y: location.point.y }); ++ this.hoverAnchorPoint.data.segment = segment; ++ this.hoverAnchorPoint.data.t = location.t; ++ } + handleControlLineEnter(e) { +- e.target.state = 'hover'; ++ this.clearRemoveAnchorTimer(); ++ if (e.target.data.isAnchor) ++ return; ++ if (!e.target.data.isControlLine) ++ return; ++ const segment = e.target.data.segment; ++ e.target.state = this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : 'hover'; + this.hoverControlLine = e.target; +- const { isStraight, start, end } = e.target.data; +- if (isStraight) { +- const centerX = (start.x + end.x) / 2; +- const centerY = (start.y + end.y) / 2; +- const anchorPoint = new Ellipse(Object.assign({ x: centerX, y: centerY, width: this.pointRadius, height: this.pointRadius, offsetX: -this.pointRadius, offsetY: -this.pointRadius, cursor: 'pointer', data: { +- isAnchor: true, +- index: e.target.data.index, +- } }, this.pointStyle)); +- this.controlLineBox.add(anchorPoint); +- e.target.data.anchorPoint = anchorPoint; +- } ++ this.updateHoverAnchor(e.target, e.getInnerPoint(this.controlLineBox)); + } + handleControlLineLeave(e) { +- if (this.hoverControlLine) { +- this.hoverControlLine.state = undefined; +- } +- if (e.target.data.anchorPoint) { +- this.controlLineBox.remove(e.target.data.anchorPoint); ++ if (e.target.data.isControlLine) { ++ const segment = e.target.data.segment; ++ e.target.state = this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : undefined; ++ if (this.hoverControlLine === e.target) ++ this.hoverControlLine = undefined; + } +- else if (e.target.data.isAnchor) { +- this.controlLineBox.remove(e.target); ++ this.scheduleHoverAnchorRemoval(); ++ } ++ handleControlLineMove(e) { ++ if (!e.target.data.isControlLine) ++ return; ++ this.updateHoverAnchor(e.target, e.getInnerPoint(this.controlLineBox)); ++ } ++ handleControlLineTap(e) { ++ if (e.target.data.isAnchor) { ++ const segment = e.target.data.segment; ++ this.points = splitPathSegment(this.points, segment, e.target.data.t); ++ this.selectControlLine = undefined; ++ this.selectPointIdx = segment.insertIndex; ++ this.clearHoverAnchor(); ++ this.reDraw(); ++ return; + } ++ if (!e.target.data.isControlLine) ++ return; ++ this.handleSelectPoint(); ++ this.selectControlLine = e.target.data.segment; ++ this.updateControl(); ++ this.updateControlLines(); + } + updateControlLines() { ++ this.clearHoverAnchor(); + const controlLines = []; +- for (let i = 0; i < this.points.length; i++) { +- const currentPoint = this.points[i]; +- const prevPoint = i === 0 ? this.points[this.points.length - 1] : this.points[i - 1]; ++ const addControlLine = (fromIndex, toIndex, insertIndex, closing = false) => { ++ const currentPoint = this.points[toIndex]; ++ const prevPoint = this.points[fromIndex]; + const { x, y, x1, y1 } = currentPoint; + const { x: prevX, y: prevY, x2: prevX2, y2: prevY2 } = prevPoint; ++ const segment = { ++ fromIndex, ++ toIndex, ++ insertIndex, ++ closing, ++ }; + let path = ``; + if (x1 !== undefined && + y1 !== undefined && +@@ -337,34 +564,44 @@ let SVGPathEditor = class SVGPathEditor extends InnerEditor { + const controlLine = new Path({ + path, + stroke: 'transparent', +- strokeWidth: 2, ++ strokeWidth: 1.5, ++ hitRadius: 7, ++ hitStroke: 'all', ++ cursor: 'pointer', + states: { + hover: { +- stroke: 'red', ++ stroke: '#2193FF', ++ strokeWidth: 2, ++ }, ++ selected: { ++ stroke: '#EF4444', ++ strokeWidth: 2, + }, + }, ++ state: this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : undefined, + editable: false, + }); +- if (path.indexOf('L') > -1) { +- controlLine.data.isStraight = true; +- controlLine.data.start = { x: prevX, y: prevY }; +- controlLine.data.end = { x, y }; +- controlLine.data.index = i; +- } ++ controlLine.data.isControlLine = true; ++ controlLine.data.segment = segment; + controlLines.push(controlLine); ++ }; ++ let subpathStartIndex = 0; ++ for (let i = 0; i < this.points.length; i++) { ++ const currentPoint = this.points[i]; ++ if (i === 0 || currentPoint.move || currentPoint.type === 'start') { ++ subpathStartIndex = i; ++ } ++ else { ++ addControlLine(i - 1, i, i); ++ } ++ if (currentPoint.closed) { ++ addControlLine(i, subpathStartIndex, i + 1, true); ++ } + } + this.controlLineBox.set({ children: controlLines }); + } +- addPointAfter(index, x, y) { +- const newPoint = { x, y }; +- this.points.splice(index, 0, newPoint); +- if (index < this.selectPointIdx) { +- this.selectPointIdx++; +- } +- this.handleSelectPoint(); +- this.selectPointIdx = index; +- this.reDraw(); +- } + calculateMirrorMode(points) { + return points.map((point) => { + const { x, y, x1, y1, x2, y2 } = point; +@@ -647,7 +884,9 @@ let SVGPathEditor = class SVGPathEditor extends InnerEditor { + handlePointDown(e) { + const { innerId } = e.target; + const pointIdx = this.pointIdxMap.get(innerId); ++ this.selectControlLine = undefined; + this.handleSelectPoint(pointIdx.index); ++ this.updateControlLines(); + } + handlePointDrag(e) { + const { moveX, moveY } = e; +@@ -684,8 +923,10 @@ let SVGPathEditor = class SVGPathEditor extends InnerEditor { + if (prevSelectPoint) { + prevSelectPoint.state = 'unSelect'; + } +- if (index === undefined) ++ if (index === undefined) { ++ this.selectPointIdx = undefined; + return; ++ } + this.selectPointIdx = index; + const selectPoint = this.pointsBox.children[index]; + selectPoint.state = 'select'; +@@ -891,4 +1132,4 @@ SVGPathEditor = __decorate([ + ], SVGPathEditor); + Path.setEditInner('SVGPathEditor'); + +-export { PathEditorEvent }; ++export { PathEditorEvent, deletePathSegment, getClosestSegmentLocation, splitPathSegment }; +diff --git a/dist/index.esm.min.js b/dist/index.esm.min.js +index 718545713f40aa629b9da0aa7d1077bf998325e1..9bddb2e87b923c7864cd4dcfc17c9d751ad5a6bf 100644 +--- a/dist/index.esm.min.js ++++ b/dist/index.esm.min.js +@@ -1 +1 @@ +-import{PathCommandMap as t,Event as i,Path as e,Box as o,DragEvent as s,PointerEvent as n,Ellipse as r}from"@leafer-ui/core";import{registerInnerEditor as h,InnerEditor as a}from"@leafer-in/editor";import"@leafer-in/state";"function"==typeof SuppressedError&&SuppressedError;const{M:d,L:l,C:c,Q:x,Z:p}=t;function y(t){const i=[];i.push(d,t[0].x,t[0].y);for(let o=0;o{t.target!==this.editTarget&&this.editor.closeInnerEditor()})),this.controlLineBox.on_(n.ENTER,this.handleControlLineEnter.bind(this)),this.controlLineBox.on_(n.LEAVE,this.handleControlLineLeave.bind(this)),this.controlLineBox.on_(n.TAP,(t=>{t.target.data.isAnchor&&this.addPointAfter(t.target.data.index,t.target.x,t.target.y)}))]}isCtrl(){return 1===this.downKey.length&&this.downKey.some((t=>[17,91].includes(t)))}isDel(){return 1===this.downKey.length&&this.downKey.some((t=>[8].includes(t)))}addKeyEventListener(){const t=t=>{this.downKey.push(t.keyCode),this.isDel()&&this.handleDeletePoint()};document.addEventListener("keydown",t);const i=t=>{this.downKey=this.downKey.filter((i=>t.keyCode!==i))};document.addEventListener("keyup",i),this.keyEvents.push({type:"keydown",event:t}),this.keyEvents.push({type:"keyup",event:i})}removeKeyEventListener(){this.keyEvents.forEach((({type:t,event:i})=>{document.removeEventListener(t,i)})),this.keyEvents=[]}onLoad(){var t;this.calculatePointStyle(this.editTarget.worldTransform),this.points=this.innerTransformPoints(function(t){const i=[];for(let e=0;e-1&&(y.data.isStraight=!0,y.data.start={x:d,y:l},y.data.end={x:n,y:r},y.data.index=i),t.push(y)}this.controlLineBox.set({children:t})}addPointAfter(t,i,e){const o={x:i,y:e};this.points.splice(t,0,o),t{const{x:i,y:e,x1:o,y1:s,x2:n,y2:r}=t;if(void 0!==o&&void 0!==s&&null!=n&&void 0!==r){let h="no-mirror";if(0===f(i,e,o,s,n,r)){h="mirror-angle";g(u(i,e,o,s),1)===g(u(i,e,n,r),1)&&(h="mirror-angle-length")}return Object.assign(Object.assign({},t),{mode:h})}return t}))}calculatePointStyle(t){const{scaleX:i,scaleY:e}=t;if(i===e){const t=v(6*i,5,7);this.pointRadius=t,this.pointStyle.width=2*t,this.pointStyle.height=2*t,this.pointStyle.strokeWidth=v(1.4*i,1,2)}}handlePointTap(t){if(this.isCtrl()){const{innerId:i}=t.target,e=this.pointIdxMap.get(i),o=this.points[e.index];if(void 0!==o.x1&&void 0!==o.y1||void 0!==o.x2&&void 0!==o.y2)o.x1=void 0,o.x2=void 0,o.y1=void 0,o.y2=void 0;else{const[t,i,s,n]=this.getDefaultControls(this.points[e.leftIdx],this.points[e.index],this.points[e.rightIdx]);o.x1=t,o.y1=i,o.x2=s,o.y2=n,o.mode="mirror-angle-length"}this.drawInnerPath()}this.updateControl(),this.updateControlLines()}getDefaultControls(t,i,e){const o=.6*u(e.x,e.y,t.x,t.y),s=e.x-t.x,n=e.y-t.y,r=Math.sqrt(s*s+n*n),h=s/r,a=n/r,d=o/2,l={x:i.x+d*h,y:i.y+d*a},c={x:i.x-d*h,y:i.y-d*a};return[c.x,c.y,l.x,l.y]}handleControlDown(t){this.selectControlPoint=t.target}handleControlUp(){this.selectControlPoint=null}handleControlDrag(t){const{isLeft:i,isRight:o}=t.target.data;if("Ellipse"!==t.target.tag)return;const{moveX:s,moveY:n}=t,{innerId:h,x:a,y:d}=t.target,{x:l,y:c}=this.getTransformMovePosition(s,n),x=a+l,p=d+c;t.target.set({x:x,y:p});const y=this.controlMap.get(h);if(y){this.isCtrl()&&(y.mode="no-mirror"),i?(y.x1=x,y.y1=p):o&&(y.x2=x,y.y2=p);const{x:t,y:s,x1:n,y1:h,x2:a,y2:d,mode:g}=y;if(void 0!==n&&void 0!==h&&void 0!==a&&void 0!==d&&"mirror-angle-length"!==g){if("mirror-angle"!==g){const e=120*v(u(t,s,n,h)+u(t,s,a,d),40,800)/150;if(f(t,s,n,h,a,d)<=e){let e,o,r,l;this.controlAbsorbStatus.isMirrorAngle=!0,i?(e=a,o=d,r=n,l=h):(e=n,o=h,r=a,l=d);const{x:c,y:x}=this.getMirrorPoint(e,o,t,s,r,l);i?(y.x1=c,y.y1=x):(y.x2=c,y.y2=x)}else this.controlAbsorbStatus.isMirrorAngle=!1}if(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===g){const e=u(t,s,n,h),o=u(t,s,a,d),r=this.pointRadius/2;Math.abs(e-o)<=r?(i?(y.x1=t-(a-t),y.y1=s-(d-s)):(y.x2=t-(n-t),y.y2=s-(h-s)),this.controlAbsorbStatus.isMirrorAngleLength=!0):this.controlAbsorbStatus.isMirrorAngleLength=!1}else this.controlAbsorbStatus.isMirrorAngleLength&&(this.controlAbsorbStatus.isMirrorAngleLength=!1)}if("mirror-angle-length"===y.mode)i?(y.x2-=l,y.y2-=c):o&&(y.x1-=l,y.y1-=c);else if("mirror-angle"===y.mode){let t=y.x1,e=y.y1,s=y.x2,n=y.y2;o&&(t=y.x2,e=y.y2,s=y.x1,n=y.y1);const{x:r,y:h}=y,{x:a,y:d}=this.getMirrorPoint(t,e,r,h,s,n);i?(y.x2=a,y.y2=d):(y.x1=a,y.y1=d)}let b,m,w=[];if(i?(b=y.x1,m=y.y1):(b=y.x2,m=y.y2),this.controlAbsorbStatus.isMirrorAngle){const i=new e({path:`M ${t} ${s} L ${b} ${m}`,stroke:"red",strokeWidth:1,editable:!1});w.push(i)}if(this.controlAbsorbStatus.isMirrorAngleLength){this.selectControlPoint.fill="red";const t=new r({x:b,y:m,width:this.pointRadius,height:this.pointRadius,offsetX:-this.pointRadius/2,offsetY:-this.pointRadius/2,fill:"red"});t.fill="red",w.push(t)}this.controlAbsorbBox.set({children:[...w]}),this.updateControl(),this.updateControlLines(),this.drawInnerPath()}}getMirrorPoint(t,i,e,o,s,n){const r=u(s,n,e,o),h=t-e,a=i-o,d=Math.sqrt(h*h+a*a);return{x:e-h/d*r,y:o-a/d*r}}handleControlDragEnd(t){const{innerId:i}=t.target,e=this.controlMap.get(i);if(e){const{x1:t,y1:i,x2:o,y2:s}=e;void 0!==t&&void 0!==i&&void 0!==o&&void 0!==s&&(this.controlAbsorbStatus.isMirrorAngle&&(e.mode="mirror-angle"),(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===e.mode)&&this.controlAbsorbStatus.isMirrorAngleLength&&(e.mode="mirror-angle-length"))}this.controlAbsorbStatus={},this.controlAbsorbBox.set({children:[]})}handlePointDown(t){const{innerId:i}=t.target,e=this.pointIdxMap.get(i);this.handleSelectPoint(e.index)}handlePointDrag(t){const{moveX:i,moveY:e}=t,{innerId:o}=t.target;let{x:s,y:n}=t.target;const{x:r,y:h}=this.getTransformMovePosition(i,e),a=s+r,d=n+h;t.target.set({x:a,y:d});const l=this.pointIdxMap.get(o);if(l){const{index:t}=l,i=this.points[t];i.x=a,i.y=d,void 0!==i.x1&&(i.x1+=r),void 0!==i.y1&&(i.y1+=h),void 0!==i.x2&&(i.x2+=r),void 0!==i.y2&&(i.y2+=h),this.updateControl(),this.drawInnerPath(),this.updateControlLines()}}handleSelectPoint(t){const i=this.pointsBox.children[this.selectPointIdx];if(i&&(i.state="unSelect"),void 0===t)return;this.selectPointIdx=t;this.pointsBox.children[t].state="select"}innerTransformPoints(t,i,e){i=i||this.editTarget.worldTransform,e=e||this.editTarget.boxBounds;let{scaleX:o,scaleY:s}=i;o=Math.abs(o),s=Math.abs(s);const{x:n,y:r}=e;return t.map((t=>{const i=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==i[t]&&(i[t]=(i[t]-n)*o)})),["y","y1","y2"].forEach((t=>{void 0!==i[t]&&(i[t]=(i[t]-r)*s)})),i}))}outerTransformPoints(t,i,e){i=i||this.editTarget.worldTransform,e=e||this.editTarget.boxBounds;let{scaleX:o,scaleY:s}=i;o=Math.abs(o),s=Math.abs(s);const{x:n,y:r}=e;return t.map((t=>{const i=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==i[t]&&(i[t]=g(i[t]/o+n))})),["y","y1","y2"].forEach((t=>{void 0!==i[t]&&(i[t]=g(i[t]/s+r))})),i}))}getTransformMovePosition(t,i){const{worldTransform:e}=this.transform;let{a:o,b:s,c:n,d:r,scaleX:h,scaleY:a}=e;return h=Math.abs(h),a=Math.abs(a),{x:(t/=h)*o+(i/=a)*s,y:t*n+i*r}}closeInnerEditor(){var t;null===(t=this.editTarget.parent)||void 0===t||t.remove(this.editTargetDuplicate);const i=this.editTarget.clone();this.editTarget.visible=!0,this.editTarget.path=y(this.outerTransformPoints(this.points)),this.editor.emitEvent(new b(b.CHANGE,{value:this.editTarget,oldValue:i})),this.editor.selector.targetStroker.visible=!0,this.editor.off_(this.eventIds),this.eventIds=[],this.points=[]}onDestroy(){this.pointsBox=null}drawInnerPath(){this.editTargetDuplicate.set({path:y(this.outerTransformPoints(this.points))}),this.drawStroke(),this.updateControlLines()}drawPoints(){let t,i,e;const o=new Map(this.pointIdxMap),s=this.selectPointIdx,n=this.points.map(((n,h)=>{const{x:a=0,y:d=0,type:l}=n;if("end"===l)return null;const c=new r(Object.assign({x:a,y:d,cursor:"move",offsetX:-this.pointRadius,offsetY:-this.pointRadius,states:{select:Object.assign({},this.selectPointStyle),unSelect:Object.assign({},this.unSelectPointStyle)},state:s===h?"select":"unSelect"},this.pointStyle)),x={index:h,leftIdx:i,rightIdx:h};return t||(t=x),o.set(c.innerId,x),e&&(e.rightIdx=h),e=x,i=h,c})).filter(Boolean);t.leftIdx=i,e.rightIdx=t.index,this.pointIdxMap=o,this.pointsBox.set({children:n})}drawStroke(){const t=new e({stroke:"#2193FF",strokeWidth:1,path:y(this.points),editable:!1});this.strokeBox.set({children:[t]})}createControl(t){if(!t)return[];const{x:i,y:o,x1:s,x2:n,y1:h,y2:a}=t;let d,l;void 0!==s&&void 0!==h&&(d=new r(Object.assign(Object.assign({x:s,y:h},this.pointStyle),{cursor:"pointer",data:{isLeft:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(d.innerId,t)),void 0!==n&&void 0!==a&&(l=new r(Object.assign(Object.assign({x:n,y:a},this.pointStyle),{cursor:"pointer",data:{isRight:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(l.innerId,t));let c,x="";return d&&l?x=`M ${s} ${h} L ${i} ${o} L ${n} ${a}`:d?x=`M ${s} ${h} L ${i} ${o}`:l&&(x=`M ${i} ${o} L ${n} ${a}`),x&&(c=new e({path:x,stroke:"#2193FF",strokeWidth:1,editable:!1})),[c,d,l].filter(Boolean)}updateControl(){if(void 0===this.selectPointIdx)return void this.controlsBox.set({children:[]});const t=this.points[this.selectPointIdx],i=this.points[this.selectPointIdx-1],e=this.points[this.selectPointIdx+1];if(void 0===t)return;if(this.selectControlPoint){const t=this.controlMap.get(this.selectControlPoint.innerId);this.controlMap.clear(),this.controlMap.set(this.selectControlPoint.innerId,t)}else this.controlMap.clear();const o=this.createControl(i),s=this.createControl(t),n=this.createControl(e);this.controlsBox.set({children:[...o,...s,...n]})}};m=function(t,i,e,o){var s,n=arguments.length,r=n<3?i:null===o?o=Object.getOwnPropertyDescriptor(i,e):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,i,e,o);else for(var h=t.length-1;h>=0;h--)(s=t[h])&&(r=(n<3?s(r):n>3?s(i,e,r):s(i,e))||r);return n>3&&r&&Object.defineProperty(i,e,r),r}([h()],m),e.setEditInner("SVGPathEditor");export{b as PathEditorEvent}; ++import{PathCommandMap as t,Event as e,Path as o,Box as i,DragEvent as n,PointerEvent as s,Ellipse as r}from"@leafer-ui/core";import{registerInnerEditor as h,InnerEditor as a}from"@leafer-in/editor";import"@leafer-in/state";"function"==typeof SuppressedError&&SuppressedError;const{M:l,L:d,C:c,Q:x,Z:g}=t;function y(t){const e=[];if(!t.length)return e;const o=(t,o)=>{const{x:i,y:n,x1:s,y1:r}=o;void 0===t.x2&&void 0===t.y2&&void 0===s&&void 0===r?e.push(d,i,n):void 0!==t.x2&&void 0!==t.y2&&void 0!==s&&void 0!==r?e.push(c,t.x2,t.y2,s,r,i,n):void 0!==t.x2&&void 0!==t.y2?e.push(x,t.x2,t.y2,i,n):void 0!==s&&void 0!==r&&e.push(x,s,r,i,n)};let i=t[0];for(let n=0;nvoid 0!==t&&void 0!==e,b=(t,e,o)=>({x:t.x+(e.x-t.x)*o,y:t.y+(e.y-t.y)*o}),P=(t,e)=>Math.pow(t.x-e.x,2)+Math.pow(t.y-e.y,2);function w(t,e){const o=t[e.fromIndex],i=t[e.toIndex];return{from:o,to:i,fromCoordinate:{x:o.x,y:o.y},toCoordinate:{x:i.x,y:i.y},outgoing:m(o.x2,o.y2)?{x:o.x2,y:o.y2}:void 0,incoming:m(i.x1,i.y1)?{x:i.x1,y:i.y1}:void 0}}function C(t,e,o){const{fromCoordinate:i,toCoordinate:n,outgoing:s,incoming:r}=w(t,e);if(s&&r){const t=b(i,s,o),e=b(s,r,o),h=b(r,n,o);return b(b(t,e,o),b(e,h,o),o)}const h=s||r;return h?b(b(i,h,o),b(h,n,o),o):b(i,n,o)}function M(t,e,o){const{fromCoordinate:i,toCoordinate:n,outgoing:s,incoming:r}=w(t,e);if(!s&&!r){const s=n.x-i.x,r=n.y-i.y,h=s*s+r*r,a=h?Math.max(0,Math.min(1,((o.x-i.x)*s+(o.y-i.y)*r)/h)):0;return{point:C(t,e,a),t:a}}let h=0,a=Number.POSITIVE_INFINITY;for(let i=0;i<=40;i++){const n=i/40,s=P(C(t,e,n),o);sObject.assign({},t))),n=Math.max(.001,Math.min(.999,o)),{from:s,to:r,fromCoordinate:h,toCoordinate:a,outgoing:l,incoming:d}=w(i,e);let c;if(l&&d){const t=b(h,l,n),e=b(l,d,n),o=b(d,a,n),i=b(t,e,n),x=b(e,o,n),g=b(i,x,n);s.x2=t.x,s.y2=t.y,r.x1=o.x,r.y1=o.y,c=Object.assign(Object.assign({},g),{x1:i.x,y1:i.y,x2:x.x,y2:x.y,mode:"no-mirror"})}else if(l||d){const t=l||d,e=b(h,t,n),o=b(t,a,n),i=b(e,o,n);s.x2=void 0,s.y2=void 0,r.x1=void 0,r.y1=void 0,c=Object.assign(Object.assign({},i),{x1:e.x,y1:e.y,x2:o.x,y2:o.y,mode:"no-mirror"})}else c=C(i,e,n);return e.closing&&(s.closed=!1,c.closed=!0),i.splice(e.insertIndex,0,c),i}function L(t,e){const o=t.map((t=>Object.assign({},t))),i=o[e.fromIndex],n=o[e.toIndex];return i.x2=void 0,i.y2=void 0,n.x1=void 0,n.y1=void 0,e.closing?i.closed=!1:(n.move=!0,n.type="start"),o}class I extends e{constructor(t,e){super(t),e&&Object.assign(this,e)}}I.CHANGE="pathEditor.change";let T=class extends a{get tag(){return"SVGPathEditor"}constructor(t){super(t),this.strokeBox=new i,this.pointsBox=new i,this.controlsBox=new i,this.controlAbsorbBox=new i,this.controlLineBox=new i,this.points=[],this.pointIdxMap=new Map,this.controlMap=new Map,this.controlAbsorbStatus={},this.keyEvents=[],this.downKey=[],this.selectPointStyle={stroke:"white",fill:"#2193FF"},this.unSelectPointStyle={stroke:"#2193FF",fill:"white"},this.pointRadius=6,this.pointStyle=Object.assign({width:2*this.pointRadius,height:2*this.pointRadius,strokeWidth:2},this.unSelectPointStyle),this.pointsBox=new i,this.controlsBox=new i,this.strokeBox=new i,this.controlAbsorbBox=new i,this.controlLineBox=new i,this.view.addMany(this.strokeBox,this.controlLineBox,this.controlsBox,this.pointsBox,this.controlAbsorbBox),this.eventIds=[this.pointsBox.on_(n.DRAG,this.handlePointDrag.bind(this)),this.pointsBox.on_(s.DOWN,this.handlePointDown.bind(this)),this.pointsBox.on_(s.TAP,this.handlePointTap.bind(this)),this.controlsBox.on_(n.DRAG,this.handleControlDrag.bind(this)),this.controlsBox.on_(n.END,this.handleControlDragEnd.bind(this)),this.controlsBox.on_(s.DOWN,this.handleControlDown.bind(this)),this.controlsBox.on_(s.UP,this.handleControlUp.bind(this)),this.editor.app.on_(s.DOUBLE_TAP,(t=>{t.target!==this.editTarget&&this.editor.closeInnerEditor()})),this.controlLineBox.on_(s.ENTER,this.handleControlLineEnter.bind(this)),this.controlLineBox.on_(s.LEAVE,this.handleControlLineLeave.bind(this)),this.controlLineBox.on_(s.MOVE,this.handleControlLineMove.bind(this)),this.controlLineBox.on_(s.TAP,this.handleControlLineTap.bind(this))]}isCtrl(){return 1===this.downKey.length&&this.downKey.some((t=>[17,91].includes(t)))}isDel(){return 1===this.downKey.length&&this.downKey.some((t=>[8,46].includes(t)))}addKeyEventListener(){const t=t=>{this.downKey.push(t.keyCode),this.isDel()&&(t.preventDefault(),this.handleDeletePoint())};document.addEventListener("keydown",t);const e=t=>{this.downKey=this.downKey.filter((e=>t.keyCode!==e))};document.addEventListener("keyup",e),this.keyEvents.push({type:"keydown",event:t}),this.keyEvents.push({type:"keyup",event:e})}removeKeyEventListener(){this.keyEvents.forEach((({type:t,event:e})=>{document.removeEventListener(t,e)})),this.keyEvents=[]}onLoad(){var t;this.calculatePointStyle(this.editTarget.worldTransform),this.points=this.innerTransformPoints(function(t){const e=[];let o=-1;for(let i=0;io&&t.x===i.x&&t.y===i.y&&(e.pop(),void 0!==i.x1&&void 0!==i.y1&&(t.x1=i.x1,t.y1=i.y1)),e[e.length-1].closed=!0;continue}e.push(n)}for(let t=e.length-1;t>0;){let o=t;for(;o>0&&!e[o].move;)o--;const i=e[o],n=e[t];t>o&&!n.closed&&i.x===n.x&&i.y===n.y&&(e.splice(t,1),e[t-1].closed=!0,void 0!==n.x1&&void 0!==n.y1&&(i.x1=n.x1,i.y1=n.y1)),t=o-1}return e}(this.editTarget.getPath())),this.points=this.calculateMirrorMode(this.points),this.editTargetDuplicate=this.editTarget.clone(),null===(t=this.editTarget.parent)||void 0===t||t.add(this.editTargetDuplicate),this.drawPoints(),this.drawInnerPath();const e=this.editTarget.clone();this.editTarget.visible=!1,this.editor.emitEvent(new I(I.CHANGE,{value:this.editTarget,oldValue:e})),this.editor.selector.targetStroker.visible=!1,this.editBox.add(this.view),this.addKeyEventListener()}reDraw(){if(!this.transform)return;const{worldTransform:t,boxBounds:e}=this.transform;this.points=this.innerTransformPoints(this.outerTransformPoints(this.points,t,e)),this.drawPoints(),this.drawInnerPath(),this.updateControl()}onUpdate(){const{boxBounds:t,worldTransform:e}=this.editTarget,{scaleX:o,scaleY:i}=e;this.calculatePointStyle(e),!this.transform||o===this.transform.worldTransform.scaleX&&i===this.transform.worldTransform.scaleY||this.reDraw(),this.transform={worldTransform:Object.assign({},e),boxBounds:Object.assign({},t)}}onUnload(){this.clearHoverAnchor(),this.closeInnerEditor(),this.removeKeyEventListener(),this.editBox.remove(this.view)}handleDeletePoint(){if(this.selectControlLine)return this.points=L(this.points,this.selectControlLine),this.selectControlLine=void 0,void this.reDraw();void 0!==this.selectPointIdx&&(this.points.splice(this.selectPointIdx,1),this.selectPointIdx=void 0,this.reDraw())}isSameSegment(t,e){return!(!t||!e||t.fromIndex!==e.fromIndex||t.toIndex!==e.toIndex||t.insertIndex!==e.insertIndex||!!t.closing!=!!e.closing)}clearRemoveAnchorTimer(){this.removeAnchorTimer&&clearTimeout(this.removeAnchorTimer),this.removeAnchorTimer=void 0}clearHoverAnchor(){var t;this.clearRemoveAnchorTimer(),(null===(t=this.hoverAnchorPoint)||void 0===t?void 0:t.parent)&&this.controlLineBox.remove(this.hoverAnchorPoint),this.hoverAnchorPoint=void 0}scheduleHoverAnchorRemoval(){this.clearRemoveAnchorTimer();const t=this.hoverAnchorPoint;this.removeAnchorTimer=setTimeout((()=>{this.hoverAnchorPoint===t&&this.clearHoverAnchor()}),80)}updateHoverAnchor(t,e){const o=t.data.segment,i=e&&this.isSameSegment(o,this.selectControlLine)?M(this.points,o,e):{point:C(this.points,o,.5),t:.5},n=1.6*this.pointRadius;this.hoverAnchorPoint||(this.hoverAnchorPoint=new r(Object.assign(Object.assign({},this.pointStyle),{width:n,height:n,offsetX:-n/2,offsetY:-n/2,fill:"white",stroke:"#2193FF",strokeWidth:2,hitRadius:4,cursor:"copy",data:{isAnchor:!0}})),this.controlLineBox.add(this.hoverAnchorPoint)),this.hoverAnchorPoint.set({x:i.point.x,y:i.point.y}),this.hoverAnchorPoint.data.segment=o,this.hoverAnchorPoint.data.t=i.t}handleControlLineEnter(t){if(this.clearRemoveAnchorTimer(),t.target.data.isAnchor)return;if(!t.target.data.isControlLine)return;const e=t.target.data.segment;t.target.state=this.isSameSegment(e,this.selectControlLine)?"selected":"hover",this.hoverControlLine=t.target,this.updateHoverAnchor(t.target,t.getInnerPoint(this.controlLineBox))}handleControlLineLeave(t){if(t.target.data.isControlLine){const e=t.target.data.segment;t.target.state=this.isSameSegment(e,this.selectControlLine)?"selected":void 0,this.hoverControlLine===t.target&&(this.hoverControlLine=void 0)}this.scheduleHoverAnchorRemoval()}handleControlLineMove(t){t.target.data.isControlLine&&this.updateHoverAnchor(t.target,t.getInnerPoint(this.controlLineBox))}handleControlLineTap(t){if(t.target.data.isAnchor){const e=t.target.data.segment;return this.points=A(this.points,e,t.target.data.t),this.selectControlLine=void 0,this.selectPointIdx=e.insertIndex,this.clearHoverAnchor(),void this.reDraw()}t.target.data.isControlLine&&(this.handleSelectPoint(),this.selectControlLine=t.target.data.segment,this.updateControl(),this.updateControlLines())}updateControlLines(){this.clearHoverAnchor();const t=[],e=(e,i,n,s=!1)=>{const r=this.points[i],h=this.points[e],{x:a,y:l,x1:d,y1:c}=r,{x:x,y:g,x2:y,y2:p}=h,u={fromIndex:e,toIndex:i,insertIndex:n,closing:s};let v="";v=void 0!==d&&void 0!==c&&void 0!==y&&void 0!==p?`M ${x} ${g} C ${y} ${p} ${d} ${c} ${a} ${l}`:void 0!==y&&void 0!==p?`M ${x} ${g} Q ${y} ${p} ${a} ${l}`:void 0!==d&&void 0!==c?`M ${x} ${g} Q ${d} ${c} ${a} ${l}`:`M ${x} ${g} L ${a} ${l}`;const f=new o({path:v,stroke:"transparent",strokeWidth:1.5,hitRadius:7,hitStroke:"all",cursor:"pointer",states:{hover:{stroke:"#2193FF",strokeWidth:2},selected:{stroke:"#EF4444",strokeWidth:2}},state:this.isSameSegment(u,this.selectControlLine)?"selected":void 0,editable:!1});f.data.isControlLine=!0,f.data.segment=u,t.push(f)};let i=0;for(let t=0;t{const{x:e,y:o,x1:i,y1:n,x2:s,y2:r}=t;if(void 0!==i&&void 0!==n&&null!=s&&void 0!==r){let h="no-mirror";if(0===v(e,o,i,n,s,r)){h="mirror-angle";p(u(e,o,i,n),1)===p(u(e,o,s,r),1)&&(h="mirror-angle-length")}return Object.assign(Object.assign({},t),{mode:h})}return t}))}calculatePointStyle(t){const{scaleX:e,scaleY:o}=t;if(e===o){const t=f(6*e,5,7);this.pointRadius=t,this.pointStyle.width=2*t,this.pointStyle.height=2*t,this.pointStyle.strokeWidth=f(1.4*e,1,2)}}handlePointTap(t){if(this.isCtrl()){const{innerId:e}=t.target,o=this.pointIdxMap.get(e),i=this.points[o.index];if(void 0!==i.x1&&void 0!==i.y1||void 0!==i.x2&&void 0!==i.y2)i.x1=void 0,i.x2=void 0,i.y1=void 0,i.y2=void 0;else{const[t,e,n,s]=this.getDefaultControls(this.points[o.leftIdx],this.points[o.index],this.points[o.rightIdx]);i.x1=t,i.y1=e,i.x2=n,i.y2=s,i.mode="mirror-angle-length"}this.drawInnerPath()}this.updateControl(),this.updateControlLines()}getDefaultControls(t,e,o){const i=.6*u(o.x,o.y,t.x,t.y),n=o.x-t.x,s=o.y-t.y,r=Math.sqrt(n*n+s*s),h=n/r,a=s/r,l=i/2,d={x:e.x+l*h,y:e.y+l*a},c={x:e.x-l*h,y:e.y-l*a};return[c.x,c.y,d.x,d.y]}handleControlDown(t){this.selectControlPoint=t.target}handleControlUp(){this.selectControlPoint=null}handleControlDrag(t){const{isLeft:e,isRight:i}=t.target.data;if("Ellipse"!==t.target.tag)return;const{moveX:n,moveY:s}=t,{innerId:h,x:a,y:l}=t.target,{x:d,y:c}=this.getTransformMovePosition(n,s),x=a+d,g=l+c;t.target.set({x:x,y:g});const y=this.controlMap.get(h);if(y){this.isCtrl()&&(y.mode="no-mirror"),e?(y.x1=x,y.y1=g):i&&(y.x2=x,y.y2=g);const{x:t,y:n,x1:s,y1:h,x2:a,y2:l,mode:p}=y;if(void 0!==s&&void 0!==h&&void 0!==a&&void 0!==l&&"mirror-angle-length"!==p){if("mirror-angle"!==p){const o=120*f(u(t,n,s,h)+u(t,n,a,l),40,800)/150;if(v(t,n,s,h,a,l)<=o){let o,i,r,d;this.controlAbsorbStatus.isMirrorAngle=!0,e?(o=a,i=l,r=s,d=h):(o=s,i=h,r=a,d=l);const{x:c,y:x}=this.getMirrorPoint(o,i,t,n,r,d);e?(y.x1=c,y.y1=x):(y.x2=c,y.y2=x)}else this.controlAbsorbStatus.isMirrorAngle=!1}if(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===p){const o=u(t,n,s,h),i=u(t,n,a,l),r=this.pointRadius/2;Math.abs(o-i)<=r?(e?(y.x1=t-(a-t),y.y1=n-(l-n)):(y.x2=t-(s-t),y.y2=n-(h-n)),this.controlAbsorbStatus.isMirrorAngleLength=!0):this.controlAbsorbStatus.isMirrorAngleLength=!1}else this.controlAbsorbStatus.isMirrorAngleLength&&(this.controlAbsorbStatus.isMirrorAngleLength=!1)}if("mirror-angle-length"===y.mode)e?(y.x2-=d,y.y2-=c):i&&(y.x1-=d,y.y1-=c);else if("mirror-angle"===y.mode){let t=y.x1,o=y.y1,n=y.x2,s=y.y2;i&&(t=y.x2,o=y.y2,n=y.x1,s=y.y1);const{x:r,y:h}=y,{x:a,y:l}=this.getMirrorPoint(t,o,r,h,n,s);e?(y.x2=a,y.y2=l):(y.x1=a,y.y1=l)}let m,b,P=[];if(e?(m=y.x1,b=y.y1):(m=y.x2,b=y.y2),this.controlAbsorbStatus.isMirrorAngle){const e=new o({path:`M ${t} ${n} L ${m} ${b}`,stroke:"red",strokeWidth:1,editable:!1});P.push(e)}if(this.controlAbsorbStatus.isMirrorAngleLength){this.selectControlPoint.fill="red";const t=new r({x:m,y:b,width:this.pointRadius,height:this.pointRadius,offsetX:-this.pointRadius/2,offsetY:-this.pointRadius/2,fill:"red"});t.fill="red",P.push(t)}this.controlAbsorbBox.set({children:[...P]}),this.updateControl(),this.updateControlLines(),this.drawInnerPath()}}getMirrorPoint(t,e,o,i,n,s){const r=u(n,s,o,i),h=t-o,a=e-i,l=Math.sqrt(h*h+a*a);return{x:o-h/l*r,y:i-a/l*r}}handleControlDragEnd(t){const{innerId:e}=t.target,o=this.controlMap.get(e);if(o){const{x1:t,y1:e,x2:i,y2:n}=o;void 0!==t&&void 0!==e&&void 0!==i&&void 0!==n&&(this.controlAbsorbStatus.isMirrorAngle&&(o.mode="mirror-angle"),(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===o.mode)&&this.controlAbsorbStatus.isMirrorAngleLength&&(o.mode="mirror-angle-length"))}this.controlAbsorbStatus={},this.controlAbsorbBox.set({children:[]})}handlePointDown(t){const{innerId:e}=t.target,o=this.pointIdxMap.get(e);this.selectControlLine=void 0,this.handleSelectPoint(o.index),this.updateControlLines()}handlePointDrag(t){const{moveX:e,moveY:o}=t,{innerId:i}=t.target;let{x:n,y:s}=t.target;const{x:r,y:h}=this.getTransformMovePosition(e,o),a=n+r,l=s+h;t.target.set({x:a,y:l});const d=this.pointIdxMap.get(i);if(d){const{index:t}=d,e=this.points[t];e.x=a,e.y=l,void 0!==e.x1&&(e.x1+=r),void 0!==e.y1&&(e.y1+=h),void 0!==e.x2&&(e.x2+=r),void 0!==e.y2&&(e.y2+=h),this.updateControl(),this.drawInnerPath(),this.updateControlLines()}}handleSelectPoint(t){const e=this.pointsBox.children[this.selectPointIdx];if(e&&(e.state="unSelect"),void 0===t)return void(this.selectPointIdx=void 0);this.selectPointIdx=t;this.pointsBox.children[t].state="select"}innerTransformPoints(t,e,o){e=e||this.editTarget.worldTransform,o=o||this.editTarget.boxBounds;let{scaleX:i,scaleY:n}=e;i=Math.abs(i),n=Math.abs(n);const{x:s,y:r}=o;return t.map((t=>{const e=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==e[t]&&(e[t]=(e[t]-s)*i)})),["y","y1","y2"].forEach((t=>{void 0!==e[t]&&(e[t]=(e[t]-r)*n)})),e}))}outerTransformPoints(t,e,o){e=e||this.editTarget.worldTransform,o=o||this.editTarget.boxBounds;let{scaleX:i,scaleY:n}=e;i=Math.abs(i),n=Math.abs(n);const{x:s,y:r}=o;return t.map((t=>{const e=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==e[t]&&(e[t]=p(e[t]/i+s))})),["y","y1","y2"].forEach((t=>{void 0!==e[t]&&(e[t]=p(e[t]/n+r))})),e}))}getTransformMovePosition(t,e){const{worldTransform:o}=this.transform;let{a:i,b:n,c:s,d:r,scaleX:h,scaleY:a}=o;return h=Math.abs(h),a=Math.abs(a),{x:(t/=h)*i+(e/=a)*n,y:t*s+e*r}}closeInnerEditor(){var t;null===(t=this.editTarget.parent)||void 0===t||t.remove(this.editTargetDuplicate);const e=this.editTarget.clone();this.editTarget.visible=!0,this.editTarget.path=y(this.outerTransformPoints(this.points)),this.editor.emitEvent(new I(I.CHANGE,{value:this.editTarget,oldValue:e})),this.editor.selector.targetStroker.visible=!0,this.editor.off_(this.eventIds),this.eventIds=[],this.points=[]}onDestroy(){this.pointsBox=null}drawInnerPath(){this.editTargetDuplicate.set({path:y(this.outerTransformPoints(this.points))}),this.drawStroke(),this.updateControlLines()}drawPoints(){let t,e,o;const i=new Map(this.pointIdxMap),n=this.selectPointIdx,s=this.points.map(((s,h)=>{const{x:a=0,y:l=0,type:d}=s;if("end"===d)return null;const c=new r(Object.assign({x:a,y:l,cursor:"move",offsetX:-this.pointRadius,offsetY:-this.pointRadius,states:{select:Object.assign({},this.selectPointStyle),unSelect:Object.assign({},this.unSelectPointStyle)},state:n===h?"select":"unSelect"},this.pointStyle)),x={index:h,leftIdx:e,rightIdx:h};return t||(t=x),i.set(c.innerId,x),o&&(o.rightIdx=h),o=x,e=h,c})).filter(Boolean);t.leftIdx=e,o.rightIdx=t.index,this.pointIdxMap=i,this.pointsBox.set({children:s})}drawStroke(){const t=new o({stroke:"#2193FF",strokeWidth:1,path:y(this.points),editable:!1});this.strokeBox.set({children:[t]})}createControl(t){if(!t)return[];const{x:e,y:i,x1:n,x2:s,y1:h,y2:a}=t;let l,d;void 0!==n&&void 0!==h&&(l=new r(Object.assign(Object.assign({x:n,y:h},this.pointStyle),{cursor:"pointer",data:{isLeft:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(l.innerId,t)),void 0!==s&&void 0!==a&&(d=new r(Object.assign(Object.assign({x:s,y:a},this.pointStyle),{cursor:"pointer",data:{isRight:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(d.innerId,t));let c,x="";return l&&d?x=`M ${n} ${h} L ${e} ${i} L ${s} ${a}`:l?x=`M ${n} ${h} L ${e} ${i}`:d&&(x=`M ${e} ${i} L ${s} ${a}`),x&&(c=new o({path:x,stroke:"#2193FF",strokeWidth:1,editable:!1})),[c,l,d].filter(Boolean)}updateControl(){if(void 0===this.selectPointIdx)return void this.controlsBox.set({children:[]});const t=this.points[this.selectPointIdx],e=this.points[this.selectPointIdx-1],o=this.points[this.selectPointIdx+1];if(void 0===t)return;if(this.selectControlPoint){const t=this.controlMap.get(this.selectControlPoint.innerId);this.controlMap.clear(),this.controlMap.set(this.selectControlPoint.innerId,t)}else this.controlMap.clear();const i=this.createControl(e),n=this.createControl(t),s=this.createControl(o);this.controlsBox.set({children:[...i,...n,...s]})}};T=function(t,e,o,i){var n,s=arguments.length,r=s<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,o):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,o,i);else for(var h=t.length-1;h>=0;h--)(n=t[h])&&(r=(s<3?n(r):s>3?n(e,o,r):n(e,o))||r);return s>3&&r&&Object.defineProperty(e,o,r),r}([h()],T),o.setEditInner("SVGPathEditor");export{I as PathEditorEvent,L as deletePathSegment,M as getClosestSegmentLocation,A as splitPathSegment}; +diff --git a/dist/index.min.cjs b/dist/index.min.cjs +index c29e650918870792070a761d7b42eab336118d51..ded0aa5eefb57249516e2af3ec3c22e04e01e0f0 100644 +--- a/dist/index.min.cjs ++++ b/dist/index.min.cjs +@@ -1 +1 @@ +-"use strict";var t=require("@leafer-ui/core"),e=require("@leafer-in/editor");require("@leafer-in/state"),"function"==typeof SuppressedError&&SuppressedError;const{M:i,L:o,C:s,Q:n,Z:r}=t.PathCommandMap;function h(t){const e=[];e.push(i,t[0].x,t[0].y);for(let i=0;i{t.target!==this.editTarget&&this.editor.closeInnerEditor()})),this.controlLineBox.on_(t.PointerEvent.ENTER,this.handleControlLineEnter.bind(this)),this.controlLineBox.on_(t.PointerEvent.LEAVE,this.handleControlLineLeave.bind(this)),this.controlLineBox.on_(t.PointerEvent.TAP,(t=>{t.target.data.isAnchor&&this.addPointAfter(t.target.data.index,t.target.x,t.target.y)}))]}isCtrl(){return 1===this.downKey.length&&this.downKey.some((t=>[17,91].includes(t)))}isDel(){return 1===this.downKey.length&&this.downKey.some((t=>[8].includes(t)))}addKeyEventListener(){const t=t=>{this.downKey.push(t.keyCode),this.isDel()&&this.handleDeletePoint()};document.addEventListener("keydown",t);const e=t=>{this.downKey=this.downKey.filter((e=>t.keyCode!==e))};document.addEventListener("keyup",e),this.keyEvents.push({type:"keydown",event:t}),this.keyEvents.push({type:"keyup",event:e})}removeKeyEventListener(){this.keyEvents.forEach((({type:t,event:e})=>{document.removeEventListener(t,e)})),this.keyEvents=[]}onLoad(){var t;this.calculatePointStyle(this.editTarget.worldTransform),this.points=this.innerTransformPoints(function(t){const e=[];for(let h=0;h-1&&(y.data.isStraight=!0,y.data.start={x:d,y:l},y.data.end={x:n,y:r},y.data.index=i),e.push(y)}this.controlLineBox.set({children:e})}addPointAfter(t,e,i){const o={x:e,y:i};this.points.splice(t,0,o),t{const{x:e,y:i,x1:o,y1:s,x2:n,y2:r}=t;if(void 0!==o&&void 0!==s&&null!=n&&void 0!==r){let h="no-mirror";if(0===l(e,i,o,s,n,r)){h="mirror-angle";a(d(e,i,o,s),1)===a(d(e,i,n,r),1)&&(h="mirror-angle-length")}return Object.assign(Object.assign({},t),{mode:h})}return t}))}calculatePointStyle(t){const{scaleX:e,scaleY:i}=t;if(e===i){const t=c(6*e,5,7);this.pointRadius=t,this.pointStyle.width=2*t,this.pointStyle.height=2*t,this.pointStyle.strokeWidth=c(1.4*e,1,2)}}handlePointTap(t){if(this.isCtrl()){const{innerId:e}=t.target,i=this.pointIdxMap.get(e),o=this.points[i.index];if(void 0!==o.x1&&void 0!==o.y1||void 0!==o.x2&&void 0!==o.y2)o.x1=void 0,o.x2=void 0,o.y1=void 0,o.y2=void 0;else{const[t,e,s,n]=this.getDefaultControls(this.points[i.leftIdx],this.points[i.index],this.points[i.rightIdx]);o.x1=t,o.y1=e,o.x2=s,o.y2=n,o.mode="mirror-angle-length"}this.drawInnerPath()}this.updateControl(),this.updateControlLines()}getDefaultControls(t,e,i){const o=.6*d(i.x,i.y,t.x,t.y),s=i.x-t.x,n=i.y-t.y,r=Math.sqrt(s*s+n*n),h=s/r,a=n/r,l=o/2,c={x:e.x+l*h,y:e.y+l*a},x={x:e.x-l*h,y:e.y-l*a};return[x.x,x.y,c.x,c.y]}handleControlDown(t){this.selectControlPoint=t.target}handleControlUp(){this.selectControlPoint=null}handleControlDrag(e){const{isLeft:i,isRight:o}=e.target.data;if("Ellipse"!==e.target.tag)return;const{moveX:s,moveY:n}=e,{innerId:r,x:h,y:a}=e.target,{x:x,y:p}=this.getTransformMovePosition(s,n),y=h+x,g=a+p;e.target.set({x:y,y:g});const u=this.controlMap.get(r);if(u){this.isCtrl()&&(u.mode="no-mirror"),i?(u.x1=y,u.y1=g):o&&(u.x2=y,u.y2=g);const{x:e,y:s,x1:n,y1:r,x2:h,y2:a,mode:v}=u;if(void 0!==n&&void 0!==r&&void 0!==h&&void 0!==a&&"mirror-angle-length"!==v){if("mirror-angle"!==v){const t=120*c(d(e,s,n,r)+d(e,s,h,a),40,800)/150;if(l(e,s,n,r,h,a)<=t){let t,o,d,l;this.controlAbsorbStatus.isMirrorAngle=!0,i?(t=h,o=a,d=n,l=r):(t=n,o=r,d=h,l=a);const{x:c,y:x}=this.getMirrorPoint(t,o,e,s,d,l);i?(u.x1=c,u.y1=x):(u.x2=c,u.y2=x)}else this.controlAbsorbStatus.isMirrorAngle=!1}if(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===v){const t=d(e,s,n,r),o=d(e,s,h,a),l=this.pointRadius/2;Math.abs(t-o)<=l?(i?(u.x1=e-(h-e),u.y1=s-(a-s)):(u.x2=e-(n-e),u.y2=s-(r-s)),this.controlAbsorbStatus.isMirrorAngleLength=!0):this.controlAbsorbStatus.isMirrorAngleLength=!1}else this.controlAbsorbStatus.isMirrorAngleLength&&(this.controlAbsorbStatus.isMirrorAngleLength=!1)}if("mirror-angle-length"===u.mode)i?(u.x2-=x,u.y2-=p):o&&(u.x1-=x,u.y1-=p);else if("mirror-angle"===u.mode){let t=u.x1,e=u.y1,s=u.x2,n=u.y2;o&&(t=u.x2,e=u.y2,s=u.x1,n=u.y1);const{x:r,y:h}=u,{x:a,y:d}=this.getMirrorPoint(t,e,r,h,s,n);i?(u.x2=a,u.y2=d):(u.x1=a,u.y1=d)}let f,b,P=[];if(i?(f=u.x1,b=u.y1):(f=u.x2,b=u.y2),this.controlAbsorbStatus.isMirrorAngle){const i=new t.Path({path:`M ${e} ${s} L ${f} ${b}`,stroke:"red",strokeWidth:1,editable:!1});P.push(i)}if(this.controlAbsorbStatus.isMirrorAngleLength){this.selectControlPoint.fill="red";const e=new t.Ellipse({x:f,y:b,width:this.pointRadius,height:this.pointRadius,offsetX:-this.pointRadius/2,offsetY:-this.pointRadius/2,fill:"red"});e.fill="red",P.push(e)}this.controlAbsorbBox.set({children:[...P]}),this.updateControl(),this.updateControlLines(),this.drawInnerPath()}}getMirrorPoint(t,e,i,o,s,n){const r=d(s,n,i,o),h=t-i,a=e-o,l=Math.sqrt(h*h+a*a);return{x:i-h/l*r,y:o-a/l*r}}handleControlDragEnd(t){const{innerId:e}=t.target,i=this.controlMap.get(e);if(i){const{x1:t,y1:e,x2:o,y2:s}=i;void 0!==t&&void 0!==e&&void 0!==o&&void 0!==s&&(this.controlAbsorbStatus.isMirrorAngle&&(i.mode="mirror-angle"),(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===i.mode)&&this.controlAbsorbStatus.isMirrorAngleLength&&(i.mode="mirror-angle-length"))}this.controlAbsorbStatus={},this.controlAbsorbBox.set({children:[]})}handlePointDown(t){const{innerId:e}=t.target,i=this.pointIdxMap.get(e);this.handleSelectPoint(i.index)}handlePointDrag(t){const{moveX:e,moveY:i}=t,{innerId:o}=t.target;let{x:s,y:n}=t.target;const{x:r,y:h}=this.getTransformMovePosition(e,i),a=s+r,d=n+h;t.target.set({x:a,y:d});const l=this.pointIdxMap.get(o);if(l){const{index:t}=l,e=this.points[t];e.x=a,e.y=d,void 0!==e.x1&&(e.x1+=r),void 0!==e.y1&&(e.y1+=h),void 0!==e.x2&&(e.x2+=r),void 0!==e.y2&&(e.y2+=h),this.updateControl(),this.drawInnerPath(),this.updateControlLines()}}handleSelectPoint(t){const e=this.pointsBox.children[this.selectPointIdx];if(e&&(e.state="unSelect"),void 0===t)return;this.selectPointIdx=t;this.pointsBox.children[t].state="select"}innerTransformPoints(t,e,i){e=e||this.editTarget.worldTransform,i=i||this.editTarget.boxBounds;let{scaleX:o,scaleY:s}=e;o=Math.abs(o),s=Math.abs(s);const{x:n,y:r}=i;return t.map((t=>{const e=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==e[t]&&(e[t]=(e[t]-n)*o)})),["y","y1","y2"].forEach((t=>{void 0!==e[t]&&(e[t]=(e[t]-r)*s)})),e}))}outerTransformPoints(t,e,i){e=e||this.editTarget.worldTransform,i=i||this.editTarget.boxBounds;let{scaleX:o,scaleY:s}=e;o=Math.abs(o),s=Math.abs(s);const{x:n,y:r}=i;return t.map((t=>{const e=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==e[t]&&(e[t]=a(e[t]/o+n))})),["y","y1","y2"].forEach((t=>{void 0!==e[t]&&(e[t]=a(e[t]/s+r))})),e}))}getTransformMovePosition(t,e){const{worldTransform:i}=this.transform;let{a:o,b:s,c:n,d:r,scaleX:h,scaleY:a}=i;return h=Math.abs(h),a=Math.abs(a),{x:(t/=h)*o+(e/=a)*s,y:t*n+e*r}}closeInnerEditor(){var t;null===(t=this.editTarget.parent)||void 0===t||t.remove(this.editTargetDuplicate);const e=this.editTarget.clone();this.editTarget.visible=!0,this.editTarget.path=h(this.outerTransformPoints(this.points)),this.editor.emitEvent(new x(x.CHANGE,{value:this.editTarget,oldValue:e})),this.editor.selector.targetStroker.visible=!0,this.editor.off_(this.eventIds),this.eventIds=[],this.points=[]}onDestroy(){this.pointsBox=null}drawInnerPath(){this.editTargetDuplicate.set({path:h(this.outerTransformPoints(this.points))}),this.drawStroke(),this.updateControlLines()}drawPoints(){let e,i,o;const s=new Map(this.pointIdxMap),n=this.selectPointIdx,r=this.points.map(((r,h)=>{const{x:a=0,y:d=0,type:l}=r;if("end"===l)return null;const c=new t.Ellipse(Object.assign({x:a,y:d,cursor:"move",offsetX:-this.pointRadius,offsetY:-this.pointRadius,states:{select:Object.assign({},this.selectPointStyle),unSelect:Object.assign({},this.unSelectPointStyle)},state:n===h?"select":"unSelect"},this.pointStyle)),x={index:h,leftIdx:i,rightIdx:h};return e||(e=x),s.set(c.innerId,x),o&&(o.rightIdx=h),o=x,i=h,c})).filter(Boolean);e.leftIdx=i,o.rightIdx=e.index,this.pointIdxMap=s,this.pointsBox.set({children:r})}drawStroke(){const e=new t.Path({stroke:"#2193FF",strokeWidth:1,path:h(this.points),editable:!1});this.strokeBox.set({children:[e]})}createControl(e){if(!e)return[];const{x:i,y:o,x1:s,x2:n,y1:r,y2:h}=e;let a,d;void 0!==s&&void 0!==r&&(a=new t.Ellipse(Object.assign(Object.assign({x:s,y:r},this.pointStyle),{cursor:"pointer",data:{isLeft:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(a.innerId,e)),void 0!==n&&void 0!==h&&(d=new t.Ellipse(Object.assign(Object.assign({x:n,y:h},this.pointStyle),{cursor:"pointer",data:{isRight:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(d.innerId,e));let l,c="";return a&&d?c=`M ${s} ${r} L ${i} ${o} L ${n} ${h}`:a?c=`M ${s} ${r} L ${i} ${o}`:d&&(c=`M ${i} ${o} L ${n} ${h}`),c&&(l=new t.Path({path:c,stroke:"#2193FF",strokeWidth:1,editable:!1})),[l,a,d].filter(Boolean)}updateControl(){if(void 0===this.selectPointIdx)return void this.controlsBox.set({children:[]});const t=this.points[this.selectPointIdx],e=this.points[this.selectPointIdx-1],i=this.points[this.selectPointIdx+1];if(void 0===t)return;if(this.selectControlPoint){const t=this.controlMap.get(this.selectControlPoint.innerId);this.controlMap.clear(),this.controlMap.set(this.selectControlPoint.innerId,t)}else this.controlMap.clear();const o=this.createControl(e),s=this.createControl(t),n=this.createControl(i);this.controlsBox.set({children:[...o,...s,...n]})}};p=function(t,e,i,o){var s,n=arguments.length,r=n<3?e:null===o?o=Object.getOwnPropertyDescriptor(e,i):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,i,o);else for(var h=t.length-1;h>=0;h--)(s=t[h])&&(r=(n<3?s(r):n>3?s(e,i,r):s(e,i))||r);return n>3&&r&&Object.defineProperty(e,i,r),r}([e.registerInnerEditor()],p),t.Path.setEditInner("SVGPathEditor"),exports.PathEditorEvent=x; ++"use strict";var t=require("@leafer-ui/core"),e=require("@leafer-in/editor");require("@leafer-in/state"),"function"==typeof SuppressedError&&SuppressedError;const{M:o,L:i,C:n,Q:s,Z:r}=t.PathCommandMap;function h(t){const e=[];if(!t.length)return e;const h=(t,o)=>{const{x:r,y:h,x1:a,y1:l}=o;void 0===t.x2&&void 0===t.y2&&void 0===a&&void 0===l?e.push(i,r,h):void 0!==t.x2&&void 0!==t.y2&&void 0!==a&&void 0!==l?e.push(n,t.x2,t.y2,a,l,r,h):void 0!==t.x2&&void 0!==t.y2?e.push(s,t.x2,t.y2,r,h):void 0!==a&&void 0!==l&&e.push(s,a,l,r,h)};let a=t[0];for(let i=0;ivoid 0!==t&&void 0!==e,g=(t,e,o)=>({x:t.x+(e.x-t.x)*o,y:t.y+(e.y-t.y)*o}),p=(t,e)=>Math.pow(t.x-e.x,2)+Math.pow(t.y-e.y,2);function v(t,e){const o=t[e.fromIndex],i=t[e.toIndex];return{from:o,to:i,fromCoordinate:{x:o.x,y:o.y},toCoordinate:{x:i.x,y:i.y},outgoing:x(o.x2,o.y2)?{x:o.x2,y:o.y2}:void 0,incoming:x(i.x1,i.y1)?{x:i.x1,y:i.y1}:void 0}}function y(t,e,o){const{fromCoordinate:i,toCoordinate:n,outgoing:s,incoming:r}=v(t,e);if(s&&r){const t=g(i,s,o),e=g(s,r,o),h=g(r,n,o);return g(g(t,e,o),g(e,h,o),o)}const h=s||r;return h?g(g(i,h,o),g(h,n,o),o):g(i,n,o)}function u(t,e,o){const{fromCoordinate:i,toCoordinate:n,outgoing:s,incoming:r}=v(t,e);if(!s&&!r){const s=n.x-i.x,r=n.y-i.y,h=s*s+r*r,a=h?Math.max(0,Math.min(1,((o.x-i.x)*s+(o.y-i.y)*r)/h)):0;return{point:y(t,e,a),t:a}}let h=0,a=Number.POSITIVE_INFINITY;for(let i=0;i<=40;i++){const n=i/40,s=p(y(t,e,n),o);sObject.assign({},t))),n=Math.max(.001,Math.min(.999,o)),{from:s,to:r,fromCoordinate:h,toCoordinate:a,outgoing:l,incoming:d}=v(i,e);let c;if(l&&d){const t=g(h,l,n),e=g(l,d,n),o=g(d,a,n),i=g(t,e,n),x=g(e,o,n),p=g(i,x,n);s.x2=t.x,s.y2=t.y,r.x1=o.x,r.y1=o.y,c=Object.assign(Object.assign({},p),{x1:i.x,y1:i.y,x2:x.x,y2:x.y,mode:"no-mirror"})}else if(l||d){const t=l||d,e=g(h,t,n),o=g(t,a,n),i=g(e,o,n);s.x2=void 0,s.y2=void 0,r.x1=void 0,r.y1=void 0,c=Object.assign(Object.assign({},i),{x1:e.x,y1:e.y,x2:o.x,y2:o.y,mode:"no-mirror"})}else c=y(i,e,n);return e.closing&&(s.closed=!1,c.closed=!0),i.splice(e.insertIndex,0,c),i}function m(t,e){const o=t.map((t=>Object.assign({},t))),i=o[e.fromIndex],n=o[e.toIndex];return i.x2=void 0,i.y2=void 0,n.x1=void 0,n.y1=void 0,e.closing?i.closed=!1:(n.move=!0,n.type="start"),o}class b extends t.Event{constructor(t,e){super(t),e&&Object.assign(this,e)}}b.CHANGE="pathEditor.change";let P=class extends e.InnerEditor{get tag(){return"SVGPathEditor"}constructor(e){super(e),this.strokeBox=new t.Box,this.pointsBox=new t.Box,this.controlsBox=new t.Box,this.controlAbsorbBox=new t.Box,this.controlLineBox=new t.Box,this.points=[],this.pointIdxMap=new Map,this.controlMap=new Map,this.controlAbsorbStatus={},this.keyEvents=[],this.downKey=[],this.selectPointStyle={stroke:"white",fill:"#2193FF"},this.unSelectPointStyle={stroke:"#2193FF",fill:"white"},this.pointRadius=6,this.pointStyle=Object.assign({width:2*this.pointRadius,height:2*this.pointRadius,strokeWidth:2},this.unSelectPointStyle),this.pointsBox=new t.Box,this.controlsBox=new t.Box,this.strokeBox=new t.Box,this.controlAbsorbBox=new t.Box,this.controlLineBox=new t.Box,this.view.addMany(this.strokeBox,this.controlLineBox,this.controlsBox,this.pointsBox,this.controlAbsorbBox),this.eventIds=[this.pointsBox.on_(t.DragEvent.DRAG,this.handlePointDrag.bind(this)),this.pointsBox.on_(t.PointerEvent.DOWN,this.handlePointDown.bind(this)),this.pointsBox.on_(t.PointerEvent.TAP,this.handlePointTap.bind(this)),this.controlsBox.on_(t.DragEvent.DRAG,this.handleControlDrag.bind(this)),this.controlsBox.on_(t.DragEvent.END,this.handleControlDragEnd.bind(this)),this.controlsBox.on_(t.PointerEvent.DOWN,this.handleControlDown.bind(this)),this.controlsBox.on_(t.PointerEvent.UP,this.handleControlUp.bind(this)),this.editor.app.on_(t.PointerEvent.DOUBLE_TAP,(t=>{t.target!==this.editTarget&&this.editor.closeInnerEditor()})),this.controlLineBox.on_(t.PointerEvent.ENTER,this.handleControlLineEnter.bind(this)),this.controlLineBox.on_(t.PointerEvent.LEAVE,this.handleControlLineLeave.bind(this)),this.controlLineBox.on_(t.PointerEvent.MOVE,this.handleControlLineMove.bind(this)),this.controlLineBox.on_(t.PointerEvent.TAP,this.handleControlLineTap.bind(this))]}isCtrl(){return 1===this.downKey.length&&this.downKey.some((t=>[17,91].includes(t)))}isDel(){return 1===this.downKey.length&&this.downKey.some((t=>[8,46].includes(t)))}addKeyEventListener(){const t=t=>{this.downKey.push(t.keyCode),this.isDel()&&(t.preventDefault(),this.handleDeletePoint())};document.addEventListener("keydown",t);const e=t=>{this.downKey=this.downKey.filter((e=>t.keyCode!==e))};document.addEventListener("keyup",e),this.keyEvents.push({type:"keydown",event:t}),this.keyEvents.push({type:"keyup",event:e})}removeKeyEventListener(){this.keyEvents.forEach((({type:t,event:e})=>{document.removeEventListener(t,e)})),this.keyEvents=[]}onLoad(){var t;this.calculatePointStyle(this.editTarget.worldTransform),this.points=this.innerTransformPoints(function(t){const e=[];let h=-1;for(let a=0;ah&&t.x===o.x&&t.y===o.y&&(e.pop(),void 0!==o.x1&&void 0!==o.y1&&(t.x1=o.x1,t.y1=o.y1)),e[e.length-1].closed=!0;continue}e.push(l)}for(let t=e.length-1;t>0;){let o=t;for(;o>0&&!e[o].move;)o--;const i=e[o],n=e[t];t>o&&!n.closed&&i.x===n.x&&i.y===n.y&&(e.splice(t,1),e[t-1].closed=!0,void 0!==n.x1&&void 0!==n.y1&&(i.x1=n.x1,i.y1=n.y1)),t=o-1}return e}(this.editTarget.getPath())),this.points=this.calculateMirrorMode(this.points),this.editTargetDuplicate=this.editTarget.clone(),null===(t=this.editTarget.parent)||void 0===t||t.add(this.editTargetDuplicate),this.drawPoints(),this.drawInnerPath();const e=this.editTarget.clone();this.editTarget.visible=!1,this.editor.emitEvent(new b(b.CHANGE,{value:this.editTarget,oldValue:e})),this.editor.selector.targetStroker.visible=!1,this.editBox.add(this.view),this.addKeyEventListener()}reDraw(){if(!this.transform)return;const{worldTransform:t,boxBounds:e}=this.transform;this.points=this.innerTransformPoints(this.outerTransformPoints(this.points,t,e)),this.drawPoints(),this.drawInnerPath(),this.updateControl()}onUpdate(){const{boxBounds:t,worldTransform:e}=this.editTarget,{scaleX:o,scaleY:i}=e;this.calculatePointStyle(e),!this.transform||o===this.transform.worldTransform.scaleX&&i===this.transform.worldTransform.scaleY||this.reDraw(),this.transform={worldTransform:Object.assign({},e),boxBounds:Object.assign({},t)}}onUnload(){this.clearHoverAnchor(),this.closeInnerEditor(),this.removeKeyEventListener(),this.editBox.remove(this.view)}handleDeletePoint(){if(this.selectControlLine)return this.points=m(this.points,this.selectControlLine),this.selectControlLine=void 0,void this.reDraw();void 0!==this.selectPointIdx&&(this.points.splice(this.selectPointIdx,1),this.selectPointIdx=void 0,this.reDraw())}isSameSegment(t,e){return!(!t||!e||t.fromIndex!==e.fromIndex||t.toIndex!==e.toIndex||t.insertIndex!==e.insertIndex||!!t.closing!=!!e.closing)}clearRemoveAnchorTimer(){this.removeAnchorTimer&&clearTimeout(this.removeAnchorTimer),this.removeAnchorTimer=void 0}clearHoverAnchor(){var t;this.clearRemoveAnchorTimer(),(null===(t=this.hoverAnchorPoint)||void 0===t?void 0:t.parent)&&this.controlLineBox.remove(this.hoverAnchorPoint),this.hoverAnchorPoint=void 0}scheduleHoverAnchorRemoval(){this.clearRemoveAnchorTimer();const t=this.hoverAnchorPoint;this.removeAnchorTimer=setTimeout((()=>{this.hoverAnchorPoint===t&&this.clearHoverAnchor()}),80)}updateHoverAnchor(e,o){const i=e.data.segment,n=o&&this.isSameSegment(i,this.selectControlLine)?u(this.points,i,o):{point:y(this.points,i,.5),t:.5},s=1.6*this.pointRadius;this.hoverAnchorPoint||(this.hoverAnchorPoint=new t.Ellipse(Object.assign(Object.assign({},this.pointStyle),{width:s,height:s,offsetX:-s/2,offsetY:-s/2,fill:"white",stroke:"#2193FF",strokeWidth:2,hitRadius:4,cursor:"copy",data:{isAnchor:!0}})),this.controlLineBox.add(this.hoverAnchorPoint)),this.hoverAnchorPoint.set({x:n.point.x,y:n.point.y}),this.hoverAnchorPoint.data.segment=i,this.hoverAnchorPoint.data.t=n.t}handleControlLineEnter(t){if(this.clearRemoveAnchorTimer(),t.target.data.isAnchor)return;if(!t.target.data.isControlLine)return;const e=t.target.data.segment;t.target.state=this.isSameSegment(e,this.selectControlLine)?"selected":"hover",this.hoverControlLine=t.target,this.updateHoverAnchor(t.target,t.getInnerPoint(this.controlLineBox))}handleControlLineLeave(t){if(t.target.data.isControlLine){const e=t.target.data.segment;t.target.state=this.isSameSegment(e,this.selectControlLine)?"selected":void 0,this.hoverControlLine===t.target&&(this.hoverControlLine=void 0)}this.scheduleHoverAnchorRemoval()}handleControlLineMove(t){t.target.data.isControlLine&&this.updateHoverAnchor(t.target,t.getInnerPoint(this.controlLineBox))}handleControlLineTap(t){if(t.target.data.isAnchor){const e=t.target.data.segment;return this.points=f(this.points,e,t.target.data.t),this.selectControlLine=void 0,this.selectPointIdx=e.insertIndex,this.clearHoverAnchor(),void this.reDraw()}t.target.data.isControlLine&&(this.handleSelectPoint(),this.selectControlLine=t.target.data.segment,this.updateControl(),this.updateControlLines())}updateControlLines(){this.clearHoverAnchor();const e=[],o=(o,i,n,s=!1)=>{const r=this.points[i],h=this.points[o],{x:a,y:l,x1:d,y1:c}=r,{x:x,y:g,x2:p,y2:v}=h,y={fromIndex:o,toIndex:i,insertIndex:n,closing:s};let u="";u=void 0!==d&&void 0!==c&&void 0!==p&&void 0!==v?`M ${x} ${g} C ${p} ${v} ${d} ${c} ${a} ${l}`:void 0!==p&&void 0!==v?`M ${x} ${g} Q ${p} ${v} ${a} ${l}`:void 0!==d&&void 0!==c?`M ${x} ${g} Q ${d} ${c} ${a} ${l}`:`M ${x} ${g} L ${a} ${l}`;const f=new t.Path({path:u,stroke:"transparent",strokeWidth:1.5,hitRadius:7,hitStroke:"all",cursor:"pointer",states:{hover:{stroke:"#2193FF",strokeWidth:2},selected:{stroke:"#EF4444",strokeWidth:2}},state:this.isSameSegment(y,this.selectControlLine)?"selected":void 0,editable:!1});f.data.isControlLine=!0,f.data.segment=y,e.push(f)};let i=0;for(let t=0;t{const{x:e,y:o,x1:i,y1:n,x2:s,y2:r}=t;if(void 0!==i&&void 0!==n&&null!=s&&void 0!==r){let h="no-mirror";if(0===d(e,o,i,n,s,r)){h="mirror-angle";a(l(e,o,i,n),1)===a(l(e,o,s,r),1)&&(h="mirror-angle-length")}return Object.assign(Object.assign({},t),{mode:h})}return t}))}calculatePointStyle(t){const{scaleX:e,scaleY:o}=t;if(e===o){const t=c(6*e,5,7);this.pointRadius=t,this.pointStyle.width=2*t,this.pointStyle.height=2*t,this.pointStyle.strokeWidth=c(1.4*e,1,2)}}handlePointTap(t){if(this.isCtrl()){const{innerId:e}=t.target,o=this.pointIdxMap.get(e),i=this.points[o.index];if(void 0!==i.x1&&void 0!==i.y1||void 0!==i.x2&&void 0!==i.y2)i.x1=void 0,i.x2=void 0,i.y1=void 0,i.y2=void 0;else{const[t,e,n,s]=this.getDefaultControls(this.points[o.leftIdx],this.points[o.index],this.points[o.rightIdx]);i.x1=t,i.y1=e,i.x2=n,i.y2=s,i.mode="mirror-angle-length"}this.drawInnerPath()}this.updateControl(),this.updateControlLines()}getDefaultControls(t,e,o){const i=.6*l(o.x,o.y,t.x,t.y),n=o.x-t.x,s=o.y-t.y,r=Math.sqrt(n*n+s*s),h=n/r,a=s/r,d=i/2,c={x:e.x+d*h,y:e.y+d*a},x={x:e.x-d*h,y:e.y-d*a};return[x.x,x.y,c.x,c.y]}handleControlDown(t){this.selectControlPoint=t.target}handleControlUp(){this.selectControlPoint=null}handleControlDrag(e){const{isLeft:o,isRight:i}=e.target.data;if("Ellipse"!==e.target.tag)return;const{moveX:n,moveY:s}=e,{innerId:r,x:h,y:a}=e.target,{x:x,y:g}=this.getTransformMovePosition(n,s),p=h+x,v=a+g;e.target.set({x:p,y:v});const y=this.controlMap.get(r);if(y){this.isCtrl()&&(y.mode="no-mirror"),o?(y.x1=p,y.y1=v):i&&(y.x2=p,y.y2=v);const{x:e,y:n,x1:s,y1:r,x2:h,y2:a,mode:u}=y;if(void 0!==s&&void 0!==r&&void 0!==h&&void 0!==a&&"mirror-angle-length"!==u){if("mirror-angle"!==u){const t=120*c(l(e,n,s,r)+l(e,n,h,a),40,800)/150;if(d(e,n,s,r,h,a)<=t){let t,i,l,d;this.controlAbsorbStatus.isMirrorAngle=!0,o?(t=h,i=a,l=s,d=r):(t=s,i=r,l=h,d=a);const{x:c,y:x}=this.getMirrorPoint(t,i,e,n,l,d);o?(y.x1=c,y.y1=x):(y.x2=c,y.y2=x)}else this.controlAbsorbStatus.isMirrorAngle=!1}if(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===u){const t=l(e,n,s,r),i=l(e,n,h,a),d=this.pointRadius/2;Math.abs(t-i)<=d?(o?(y.x1=e-(h-e),y.y1=n-(a-n)):(y.x2=e-(s-e),y.y2=n-(r-n)),this.controlAbsorbStatus.isMirrorAngleLength=!0):this.controlAbsorbStatus.isMirrorAngleLength=!1}else this.controlAbsorbStatus.isMirrorAngleLength&&(this.controlAbsorbStatus.isMirrorAngleLength=!1)}if("mirror-angle-length"===y.mode)o?(y.x2-=x,y.y2-=g):i&&(y.x1-=x,y.y1-=g);else if("mirror-angle"===y.mode){let t=y.x1,e=y.y1,n=y.x2,s=y.y2;i&&(t=y.x2,e=y.y2,n=y.x1,s=y.y1);const{x:r,y:h}=y,{x:a,y:l}=this.getMirrorPoint(t,e,r,h,n,s);o?(y.x2=a,y.y2=l):(y.x1=a,y.y1=l)}let f,m,b=[];if(o?(f=y.x1,m=y.y1):(f=y.x2,m=y.y2),this.controlAbsorbStatus.isMirrorAngle){const o=new t.Path({path:`M ${e} ${n} L ${f} ${m}`,stroke:"red",strokeWidth:1,editable:!1});b.push(o)}if(this.controlAbsorbStatus.isMirrorAngleLength){this.selectControlPoint.fill="red";const e=new t.Ellipse({x:f,y:m,width:this.pointRadius,height:this.pointRadius,offsetX:-this.pointRadius/2,offsetY:-this.pointRadius/2,fill:"red"});e.fill="red",b.push(e)}this.controlAbsorbBox.set({children:[...b]}),this.updateControl(),this.updateControlLines(),this.drawInnerPath()}}getMirrorPoint(t,e,o,i,n,s){const r=l(n,s,o,i),h=t-o,a=e-i,d=Math.sqrt(h*h+a*a);return{x:o-h/d*r,y:i-a/d*r}}handleControlDragEnd(t){const{innerId:e}=t.target,o=this.controlMap.get(e);if(o){const{x1:t,y1:e,x2:i,y2:n}=o;void 0!==t&&void 0!==e&&void 0!==i&&void 0!==n&&(this.controlAbsorbStatus.isMirrorAngle&&(o.mode="mirror-angle"),(this.controlAbsorbStatus.isMirrorAngle||"mirror-angle"===o.mode)&&this.controlAbsorbStatus.isMirrorAngleLength&&(o.mode="mirror-angle-length"))}this.controlAbsorbStatus={},this.controlAbsorbBox.set({children:[]})}handlePointDown(t){const{innerId:e}=t.target,o=this.pointIdxMap.get(e);this.selectControlLine=void 0,this.handleSelectPoint(o.index),this.updateControlLines()}handlePointDrag(t){const{moveX:e,moveY:o}=t,{innerId:i}=t.target;let{x:n,y:s}=t.target;const{x:r,y:h}=this.getTransformMovePosition(e,o),a=n+r,l=s+h;t.target.set({x:a,y:l});const d=this.pointIdxMap.get(i);if(d){const{index:t}=d,e=this.points[t];e.x=a,e.y=l,void 0!==e.x1&&(e.x1+=r),void 0!==e.y1&&(e.y1+=h),void 0!==e.x2&&(e.x2+=r),void 0!==e.y2&&(e.y2+=h),this.updateControl(),this.drawInnerPath(),this.updateControlLines()}}handleSelectPoint(t){const e=this.pointsBox.children[this.selectPointIdx];if(e&&(e.state="unSelect"),void 0===t)return void(this.selectPointIdx=void 0);this.selectPointIdx=t;this.pointsBox.children[t].state="select"}innerTransformPoints(t,e,o){e=e||this.editTarget.worldTransform,o=o||this.editTarget.boxBounds;let{scaleX:i,scaleY:n}=e;i=Math.abs(i),n=Math.abs(n);const{x:s,y:r}=o;return t.map((t=>{const e=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==e[t]&&(e[t]=(e[t]-s)*i)})),["y","y1","y2"].forEach((t=>{void 0!==e[t]&&(e[t]=(e[t]-r)*n)})),e}))}outerTransformPoints(t,e,o){e=e||this.editTarget.worldTransform,o=o||this.editTarget.boxBounds;let{scaleX:i,scaleY:n}=e;i=Math.abs(i),n=Math.abs(n);const{x:s,y:r}=o;return t.map((t=>{const e=Object.assign({},t);return["x","x1","x2"].forEach((t=>{void 0!==e[t]&&(e[t]=a(e[t]/i+s))})),["y","y1","y2"].forEach((t=>{void 0!==e[t]&&(e[t]=a(e[t]/n+r))})),e}))}getTransformMovePosition(t,e){const{worldTransform:o}=this.transform;let{a:i,b:n,c:s,d:r,scaleX:h,scaleY:a}=o;return h=Math.abs(h),a=Math.abs(a),{x:(t/=h)*i+(e/=a)*n,y:t*s+e*r}}closeInnerEditor(){var t;null===(t=this.editTarget.parent)||void 0===t||t.remove(this.editTargetDuplicate);const e=this.editTarget.clone();this.editTarget.visible=!0,this.editTarget.path=h(this.outerTransformPoints(this.points)),this.editor.emitEvent(new b(b.CHANGE,{value:this.editTarget,oldValue:e})),this.editor.selector.targetStroker.visible=!0,this.editor.off_(this.eventIds),this.eventIds=[],this.points=[]}onDestroy(){this.pointsBox=null}drawInnerPath(){this.editTargetDuplicate.set({path:h(this.outerTransformPoints(this.points))}),this.drawStroke(),this.updateControlLines()}drawPoints(){let e,o,i;const n=new Map(this.pointIdxMap),s=this.selectPointIdx,r=this.points.map(((r,h)=>{const{x:a=0,y:l=0,type:d}=r;if("end"===d)return null;const c=new t.Ellipse(Object.assign({x:a,y:l,cursor:"move",offsetX:-this.pointRadius,offsetY:-this.pointRadius,states:{select:Object.assign({},this.selectPointStyle),unSelect:Object.assign({},this.unSelectPointStyle)},state:s===h?"select":"unSelect"},this.pointStyle)),x={index:h,leftIdx:o,rightIdx:h};return e||(e=x),n.set(c.innerId,x),i&&(i.rightIdx=h),i=x,o=h,c})).filter(Boolean);e.leftIdx=o,i.rightIdx=e.index,this.pointIdxMap=n,this.pointsBox.set({children:r})}drawStroke(){const e=new t.Path({stroke:"#2193FF",strokeWidth:1,path:h(this.points),editable:!1});this.strokeBox.set({children:[e]})}createControl(e){if(!e)return[];const{x:o,y:i,x1:n,x2:s,y1:r,y2:h}=e;let a,l;void 0!==n&&void 0!==r&&(a=new t.Ellipse(Object.assign(Object.assign({x:n,y:r},this.pointStyle),{cursor:"pointer",data:{isLeft:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(a.innerId,e)),void 0!==s&&void 0!==h&&(l=new t.Ellipse(Object.assign(Object.assign({x:s,y:h},this.pointStyle),{cursor:"pointer",data:{isRight:!0},offsetX:-this.pointRadius,offsetY:-this.pointRadius})),this.controlMap.set(l.innerId,e));let d,c="";return a&&l?c=`M ${n} ${r} L ${o} ${i} L ${s} ${h}`:a?c=`M ${n} ${r} L ${o} ${i}`:l&&(c=`M ${o} ${i} L ${s} ${h}`),c&&(d=new t.Path({path:c,stroke:"#2193FF",strokeWidth:1,editable:!1})),[d,a,l].filter(Boolean)}updateControl(){if(void 0===this.selectPointIdx)return void this.controlsBox.set({children:[]});const t=this.points[this.selectPointIdx],e=this.points[this.selectPointIdx-1],o=this.points[this.selectPointIdx+1];if(void 0===t)return;if(this.selectControlPoint){const t=this.controlMap.get(this.selectControlPoint.innerId);this.controlMap.clear(),this.controlMap.set(this.selectControlPoint.innerId,t)}else this.controlMap.clear();const i=this.createControl(e),n=this.createControl(t),s=this.createControl(o);this.controlsBox.set({children:[...i,...n,...s]})}};P=function(t,e,o,i){var n,s=arguments.length,r=s<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,o):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,o,i);else for(var h=t.length-1;h>=0;h--)(n=t[h])&&(r=(s<3?n(r):s>3?n(e,o,r):n(e,o))||r);return s>3&&r&&Object.defineProperty(e,o,r),r}([e.registerInnerEditor()],P),t.Path.setEditInner("SVGPathEditor"),exports.PathEditorEvent=b,exports.deletePathSegment=m,exports.getClosestSegmentLocation=u,exports.splitPathSegment=f; +diff --git a/src/index.ts b/src/index.ts +index 18bd15470c59fcf04cfc7d2c80e3029c25dc8b6e..b690a1e1b14b4378bffe4031c6f9f314d9ac40b7 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1,3 +1,9 @@ + import './svg-path-editor'; + + export { PathEditorEvent } from './event'; ++export { ++ deletePathSegment, ++ getClosestSegmentLocation, ++ splitPathSegment, ++} from './segment'; ++export type { IPathSegment } from './type'; +diff --git a/src/segment.ts b/src/segment.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..8830cebdedd6ed2dcff8c39e1d38db371ff846d7 +--- /dev/null ++++ b/src/segment.ts +@@ -0,0 +1,186 @@ ++import { IPathSegment, IPoint } from './type'; ++ ++interface ICoordinate { ++ x: number; ++ y: number; ++} ++ ++export interface IClosestSegmentLocation { ++ point: ICoordinate; ++ t: number; ++} ++ ++const hasPoint = (x?: number, y?: number): x is number => ++ x !== undefined && y !== undefined; ++ ++const lerp = (from: ICoordinate, to: ICoordinate, t: number) => ({ ++ x: from.x + (to.x - from.x) * t, ++ y: from.y + (to.y - from.y) * t, ++}); ++ ++const distanceSquared = (left: ICoordinate, right: ICoordinate) => ++ Math.pow(left.x - right.x, 2) + Math.pow(left.y - right.y, 2); ++ ++function getSegmentPoints(points: IPoint[], segment: IPathSegment) { ++ const from = points[segment.fromIndex] as Required> & IPoint; ++ const to = points[segment.toIndex] as Required> & IPoint; ++ const fromCoordinate = { x: from.x, y: from.y }; ++ const toCoordinate = { x: to.x, y: to.y }; ++ const outgoing = hasPoint(from.x2, from.y2) ++ ? { x: from.x2, y: from.y2 as number } ++ : undefined; ++ const incoming = hasPoint(to.x1, to.y1) ++ ? { x: to.x1, y: to.y1 as number } ++ : undefined; ++ ++ return { from, to, fromCoordinate, toCoordinate, outgoing, incoming }; ++} ++ ++export function getPathSegmentPoint( ++ points: IPoint[], ++ segment: IPathSegment, ++ t: number ++) { ++ const { fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(points, segment); ++ if (outgoing && incoming) { ++ const left = lerp(fromCoordinate, outgoing, t); ++ const center = lerp(outgoing, incoming, t); ++ const right = lerp(incoming, toCoordinate, t); ++ return lerp(lerp(left, center, t), lerp(center, right, t), t); ++ } ++ ++ const control = outgoing || incoming; ++ if (control) { ++ return lerp(lerp(fromCoordinate, control, t), lerp(control, toCoordinate, t), t); ++ } ++ ++ return lerp(fromCoordinate, toCoordinate, t); ++} ++ ++export function getClosestSegmentLocation( ++ points: IPoint[], ++ segment: IPathSegment, ++ target: ICoordinate ++): IClosestSegmentLocation { ++ const { fromCoordinate, toCoordinate, outgoing, incoming } = getSegmentPoints(points, segment); ++ if (!outgoing && !incoming) { ++ const dx = toCoordinate.x - fromCoordinate.x; ++ const dy = toCoordinate.y - fromCoordinate.y; ++ const lengthSquared = dx * dx + dy * dy; ++ const t = lengthSquared ++ ? Math.max(0, Math.min(1, ((target.x - fromCoordinate.x) * dx + (target.y - fromCoordinate.y) * dy) / lengthSquared)) ++ : 0; ++ return { point: getPathSegmentPoint(points, segment, t), t }; ++ } ++ ++ const samples = 40; ++ let bestT = 0; ++ let bestDistance = Number.POSITIVE_INFINITY; ++ for (let index = 0; index <= samples; index++) { ++ const t = index / samples; ++ const distance = distanceSquared( ++ getPathSegmentPoint(points, segment, t), ++ target ++ ); ++ if (distance < bestDistance) { ++ bestDistance = distance; ++ bestT = t; ++ } ++ } ++ ++ let left = Math.max(0, bestT - 1 / samples); ++ let right = Math.min(1, bestT + 1 / samples); ++ for (let index = 0; index < 12; index++) { ++ const leftThird = left + (right - left) / 3; ++ const rightThird = right - (right - left) / 3; ++ if ( ++ distanceSquared(getPathSegmentPoint(points, segment, leftThird), target) <= ++ distanceSquared(getPathSegmentPoint(points, segment, rightThird), target) ++ ) { ++ right = rightThird; ++ } else { ++ left = leftThird; ++ } ++ } ++ const t = (left + right) / 2; ++ return { point: getPathSegmentPoint(points, segment, t), t }; ++} ++ ++export function splitPathSegment( ++ points: IPoint[], ++ segment: IPathSegment, ++ rawT: number ++) { ++ const nextPoints = points.map((point) => ({ ...point })); ++ const t = Math.max(0.001, Math.min(0.999, rawT)); ++ const { from, to, fromCoordinate, toCoordinate, outgoing, incoming } = ++ getSegmentPoints(nextPoints, segment); ++ let newPoint: IPoint; ++ ++ if (outgoing && incoming) { ++ const left = lerp(fromCoordinate, outgoing, t); ++ const center = lerp(outgoing, incoming, t); ++ const right = lerp(incoming, toCoordinate, t); ++ const leftCenter = lerp(left, center, t); ++ const rightCenter = lerp(center, right, t); ++ const point = lerp(leftCenter, rightCenter, t); ++ ++ from.x2 = left.x; ++ from.y2 = left.y; ++ to.x1 = right.x; ++ to.y1 = right.y; ++ newPoint = { ++ ...point, ++ x1: leftCenter.x, ++ y1: leftCenter.y, ++ x2: rightCenter.x, ++ y2: rightCenter.y, ++ mode: 'no-mirror', ++ }; ++ } else if (outgoing || incoming) { ++ const control = outgoing || (incoming as ICoordinate); ++ const left = lerp(fromCoordinate, control, t); ++ const right = lerp(control, toCoordinate, t); ++ const point = lerp(left, right, t); ++ ++ from.x2 = undefined; ++ from.y2 = undefined; ++ to.x1 = undefined; ++ to.y1 = undefined; ++ newPoint = { ++ ...point, ++ x1: left.x, ++ y1: left.y, ++ x2: right.x, ++ y2: right.y, ++ mode: 'no-mirror', ++ }; ++ } else { ++ newPoint = getPathSegmentPoint(nextPoints, segment, t); ++ } ++ ++ if (segment.closing) { ++ from.closed = false; ++ newPoint.closed = true; ++ } ++ nextPoints.splice(segment.insertIndex, 0, newPoint); ++ return nextPoints; ++} ++ ++export function deletePathSegment(points: IPoint[], segment: IPathSegment) { ++ const nextPoints = points.map((point) => ({ ...point })); ++ const from = nextPoints[segment.fromIndex]; ++ const to = nextPoints[segment.toIndex]; ++ ++ from.x2 = undefined; ++ from.y2 = undefined; ++ to.x1 = undefined; ++ to.y1 = undefined; ++ if (segment.closing) { ++ from.closed = false; ++ } else { ++ to.move = true; ++ to.type = 'start'; ++ } ++ return nextPoints; ++} +diff --git a/src/state.d.ts b/src/state.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..6a04676e7fe99d024976c053c0bff0a3ef889e5b +--- /dev/null ++++ b/src/state.d.ts +@@ -0,0 +1 @@ ++declare module '@leafer-in/state'; +diff --git a/src/svg-path-editor.ts b/src/svg-path-editor.ts +index 97c706aefa54897e26c67fe929e0697e2e65ec18..d5d3829d7461b17e859fc5601501228b20228b2f 100644 +--- a/src/svg-path-editor.ts ++++ b/src/svg-path-editor.ts +@@ -17,7 +17,13 @@ import { + point2PathData, + toFixed, + } from './utils'; +-import { AnyObject, IPoint, MirrorMode, PointIdx } from './type'; ++import { AnyObject, IPathSegment, IPoint, MirrorMode, PointIdx } from './type'; ++import { ++ deletePathSegment, ++ getClosestSegmentLocation, ++ getPathSegmentPoint, ++ splitPathSegment, ++} from './segment'; + import { PathEditorEvent } from './event'; + import '@leafer-in/state'; + +@@ -62,6 +68,14 @@ export class SVGPathEditor extends InnerEditor { + // 当前 hover 的控制线 + private hoverControlLine?: Path; + ++ // 当前选中的线段 ++ private selectControlLine?: IPathSegment; ++ ++ // hover 线段上的待添加锚点 ++ private hoverAnchorPoint?: Ellipse; ++ ++ private removeAnchorTimer?: ReturnType; ++ + // 当前选中的 控制点 + private selectControlPoint?: Ellipse; + +@@ -142,11 +156,14 @@ export class SVGPathEditor extends InnerEditor { + PointerEvent.LEAVE, + this.handleControlLineLeave.bind(this) + ), +- this.controlLineBox.on_(PointerEvent.TAP, (e: any) => { +- if (e.target.data.isAnchor) { +- this.addPointAfter(e.target.data.index, e.target.x, e.target.y); +- } +- }), ++ this.controlLineBox.on_( ++ PointerEvent.MOVE, ++ this.handleControlLineMove.bind(this) ++ ), ++ this.controlLineBox.on_( ++ PointerEvent.TAP, ++ this.handleControlLineTap.bind(this) ++ ), + ]; + } + +@@ -159,13 +176,14 @@ export class SVGPathEditor extends InnerEditor { + // 是否按下了 删除键 + private isDel() { + if (this.downKey.length !== 1) return false; +- return this.downKey.some((val) => [8].includes(val)); ++ return this.downKey.some((val) => [8, 46].includes(val)); + } + + private addKeyEventListener() { + const handleKeyDownEvent = (e: any) => { + this.downKey.push(e.keyCode); + if (this.isDel()) { ++ e.preventDefault(); + this.handleDeletePoint(); + } + }; +@@ -259,56 +277,101 @@ export class SVGPathEditor extends InnerEditor { + } + + public onUnload(): void { ++ this.clearHoverAnchor(); + this.closeInnerEditor(); + this.removeKeyEventListener(); + // 4. 卸载控制点 + this.editBox.remove(this.view); + } + +- /** +- * 删除选中顶点 +- * +- * @memberof SVGPathEditor +- */ ++ /** 删除选中的线段或顶点 */ + handleDeletePoint() { ++ if (this.selectControlLine) { ++ this.points = deletePathSegment(this.points, this.selectControlLine); ++ this.selectControlLine = undefined; ++ this.reDraw(); ++ return; ++ } + if (this.selectPointIdx === undefined) return; + this.points.splice(this.selectPointIdx, 1); + this.selectPointIdx = undefined; + this.reDraw(); + } + +- /** +- * 处理控制线的 hover 事件 +- * +- * @param {*} e +- * @memberof SVGPathEditor +- */ +- handleControlLineEnter(e: any) { +- e.target.state = 'hover'; +- this.hoverControlLine = e.target; ++ private isSameSegment(left?: IPathSegment, right?: IPathSegment) { ++ return !!( ++ left && ++ right && ++ left.fromIndex === right.fromIndex && ++ left.toIndex === right.toIndex && ++ left.insertIndex === right.insertIndex && ++ !!left.closing === !!right.closing ++ ); ++ } + +- const { isStraight, start, end } = e.target.data; +- if (isStraight) { +- const centerX = (start.x + end.x) / 2; +- const centerY = (start.y + end.y) / 2; +- const anchorPoint = new Ellipse({ +- x: centerX, +- y: centerY, +- width: this.pointRadius, +- height: this.pointRadius, +- offsetX: -this.pointRadius, +- offsetY: -this.pointRadius, +- cursor: 'pointer', +- data: { +- isAnchor: true, +- index: e.target.data.index, +- }, ++ private clearRemoveAnchorTimer() { ++ if (this.removeAnchorTimer) clearTimeout(this.removeAnchorTimer); ++ this.removeAnchorTimer = undefined; ++ } ++ ++ private clearHoverAnchor() { ++ this.clearRemoveAnchorTimer(); ++ if (this.hoverAnchorPoint?.parent) { ++ this.controlLineBox.remove(this.hoverAnchorPoint); ++ } ++ this.hoverAnchorPoint = undefined; ++ } ++ ++ private scheduleHoverAnchorRemoval() { ++ this.clearRemoveAnchorTimer(); ++ const anchorPoint = this.hoverAnchorPoint; ++ this.removeAnchorTimer = setTimeout(() => { ++ if (this.hoverAnchorPoint === anchorPoint) this.clearHoverAnchor(); ++ }, 80); ++ } ++ ++ private updateHoverAnchor( ++ controlLine: Path, ++ pointer?: { x: number; y: number } ++ ) { ++ const segment = controlLine.data.segment as IPathSegment; ++ const location = ++ pointer && this.isSameSegment(segment, this.selectControlLine) ++ ? getClosestSegmentLocation(this.points, segment, pointer) ++ : { point: getPathSegmentPoint(this.points, segment, 0.5), t: 0.5 }; ++ const size = this.pointRadius * 1.6; ++ if (!this.hoverAnchorPoint) { ++ this.hoverAnchorPoint = new Ellipse({ + ...this.pointStyle, ++ width: size, ++ height: size, ++ offsetX: -size / 2, ++ offsetY: -size / 2, ++ fill: 'white', ++ stroke: '#2193FF', ++ strokeWidth: 2, ++ hitRadius: 4, ++ cursor: 'copy', ++ data: { isAnchor: true }, + }); +- +- this.controlLineBox.add(anchorPoint); +- e.target.data.anchorPoint = anchorPoint; ++ this.controlLineBox.add(this.hoverAnchorPoint); + } ++ this.hoverAnchorPoint.set({ x: location.point.x, y: location.point.y }); ++ this.hoverAnchorPoint.data.segment = segment; ++ this.hoverAnchorPoint.data.t = location.t; ++ } ++ ++ handleControlLineEnter(e: any) { ++ this.clearRemoveAnchorTimer(); ++ if (e.target.data.isAnchor) return; ++ if (!e.target.data.isControlLine) return; ++ ++ const segment = e.target.data.segment as IPathSegment; ++ e.target.state = this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : 'hover'; ++ this.hoverControlLine = e.target; ++ this.updateHoverAnchor(e.target, e.getInnerPoint(this.controlLineBox)); + } + + /** +@@ -318,15 +381,37 @@ export class SVGPathEditor extends InnerEditor { + * @memberof SVGPathEditor + */ + handleControlLineLeave(e: any) { +- if (this.hoverControlLine) { +- this.hoverControlLine.state = undefined; ++ if (e.target.data.isControlLine) { ++ const segment = e.target.data.segment as IPathSegment; ++ e.target.state = this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : undefined; ++ if (this.hoverControlLine === e.target) this.hoverControlLine = undefined; + } ++ this.scheduleHoverAnchorRemoval(); ++ } ++ ++ handleControlLineMove(e: any) { ++ if (!e.target.data.isControlLine) return; ++ this.updateHoverAnchor(e.target, e.getInnerPoint(this.controlLineBox)); ++ } + +- if (e.target.data.anchorPoint) { +- this.controlLineBox.remove(e.target.data.anchorPoint); +- } else if (e.target.data.isAnchor) { +- this.controlLineBox.remove(e.target); ++ handleControlLineTap(e: any) { ++ if (e.target.data.isAnchor) { ++ const segment = e.target.data.segment as IPathSegment; ++ this.points = splitPathSegment(this.points, segment, e.target.data.t); ++ this.selectControlLine = undefined; ++ this.selectPointIdx = segment.insertIndex; ++ this.clearHoverAnchor(); ++ this.reDraw(); ++ return; + } ++ if (!e.target.data.isControlLine) return; ++ ++ this.handleSelectPoint(); ++ this.selectControlLine = e.target.data.segment as IPathSegment; ++ this.updateControl(); ++ this.updateControlLines(); + } + + /** +@@ -335,13 +420,24 @@ export class SVGPathEditor extends InnerEditor { + * @memberof SVGPathEditor + */ + updateControlLines() { ++ this.clearHoverAnchor(); + const controlLines: Path[] = []; +- for (let i = 0; i < this.points.length; i++) { +- const currentPoint = this.points[i]; +- const prevPoint = +- i === 0 ? this.points[this.points.length - 1] : this.points[i - 1]; ++ const addControlLine = ( ++ fromIndex: number, ++ toIndex: number, ++ insertIndex: number, ++ closing = false ++ ) => { ++ const currentPoint = this.points[toIndex]; ++ const prevPoint = this.points[fromIndex]; + const { x, y, x1, y1 } = currentPoint; + const { x: prevX, y: prevY, x2: prevX2, y2: prevY2 } = prevPoint; ++ const segment: IPathSegment = { ++ fromIndex, ++ toIndex, ++ insertIndex, ++ closing, ++ }; + + let path = ``; + if ( +@@ -362,46 +458,45 @@ export class SVGPathEditor extends InnerEditor { + const controlLine = new Path({ + path, + stroke: 'transparent', +- strokeWidth: 2, ++ strokeWidth: 1.5, ++ hitRadius: 7, ++ hitStroke: 'all', ++ cursor: 'pointer', + states: { + hover: { +- stroke: 'red', ++ stroke: '#2193FF', ++ strokeWidth: 2, ++ }, ++ selected: { ++ stroke: '#EF4444', ++ strokeWidth: 2, + }, + }, ++ state: this.isSameSegment(segment, this.selectControlLine) ++ ? 'selected' ++ : undefined, + editable: false, + }); ++ controlLine.data.isControlLine = true; ++ controlLine.data.segment = segment; ++ controlLines.push(controlLine); ++ }; + +- if (path.indexOf('L') > -1) { +- controlLine.data.isStraight = true; // 直线 +- controlLine.data.start = { x: prevX, y: prevY }; +- controlLine.data.end = { x, y }; +- controlLine.data.index = i; ++ let subpathStartIndex = 0; ++ for (let i = 0; i < this.points.length; i++) { ++ const currentPoint = this.points[i]; ++ if (i === 0 || currentPoint.move || currentPoint.type === 'start') { ++ subpathStartIndex = i; ++ } else { ++ addControlLine(i - 1, i, i); ++ } ++ if (currentPoint.closed) { ++ addControlLine(i, subpathStartIndex, i + 1, true); + } +- +- controlLines.push(controlLine); + } + this.controlLineBox.set({ children: controlLines }); + } + +- /** +- * 在指定位置后添加顶点 +- * +- * @param {number} index +- * @param {number} x +- * @param {number} y +- * @memberof SVGPathEditor +- */ +- addPointAfter(index: number, x: number, y: number) { +- const newPoint: IPoint = { x, y }; +- this.points.splice(index, 0, newPoint); +- if (index < this.selectPointIdx) { +- this.selectPointIdx++; +- } +- this.handleSelectPoint(); +- this.selectPointIdx = index; +- this.reDraw(); +- } +- + /** + * 计算初始数据的控制点模式 + * +@@ -856,7 +951,9 @@ export class SVGPathEditor extends InnerEditor { + handlePointDown(e: any) { + const { innerId } = e.target; + const pointIdx = this.pointIdxMap.get(innerId); ++ this.selectControlLine = undefined; + this.handleSelectPoint(pointIdx.index); ++ this.updateControlLines(); + } + + /** +@@ -921,7 +1018,10 @@ export class SVGPathEditor extends InnerEditor { + if (prevSelectPoint) { + prevSelectPoint.state = 'unSelect'; + } +- if (index === undefined) return; ++ if (index === undefined) { ++ this.selectPointIdx = undefined; ++ return; ++ } + this.selectPointIdx = index; + const selectPoint = this.pointsBox.children[index]; + selectPoint.state = 'select'; +diff --git a/src/type.ts b/src/type.ts +index 82e098c25333dc0f6b9fbf1c1d4f4cde6098daff..8d55fe561be3397a294e6def9964219bfe1cd8b8 100644 +--- a/src/type.ts ++++ b/src/type.ts +@@ -19,6 +19,17 @@ export interface IPoint { + mode?: MirrorMode; + // 顶点类型 + type?: PointType; ++ // 是否是一个子路径的起点 ++ move?: boolean; ++ // 是否从当前点闭合到子路径起点 ++ closed?: boolean; ++} ++ ++export interface IPathSegment { ++ fromIndex: number; ++ toIndex: number; ++ insertIndex: number; ++ closing?: boolean; + } + + export type AnyObject = Record; +diff --git a/src/utils.ts b/src/utils.ts +index 32d06923d6df2db0f8f79c89e0ec06f383e265b3..faa9b7f9aeab03b9542e67622705ffadb82f5798 100644 +--- a/src/utils.ts ++++ b/src/utils.ts +@@ -67,6 +67,7 @@ export function pathData2CommandObject( + */ + export function pathData2Point(path: IPathCommandData) { + const pointData: IPoint[] = []; ++ let subpathStartIndex = -1; + for (let i = 0; i < path.length; i++) { + const point: IPoint = {} as IPoint; + +@@ -74,6 +75,8 @@ export function pathData2Point(path: IPathCommandData) { + point.x = path[++i]; + point.y = path[++i]; + point.type = 'start'; ++ point.move = true; ++ subpathStartIndex = pointData.length; + } else if (path[i] === L) { + point.x = path[++i]; + point.y = path[++i]; +@@ -90,29 +93,47 @@ export function pathData2Point(path: IPathCommandData) { + point.x = path[++i]; + point.y = path[++i]; + } else if (path[i] === Z) { +- point.x = pointData[0].x; +- point.y = pointData[0].y; ++ const start = pointData[subpathStartIndex]; ++ const end = pointData[pointData.length - 1]; ++ if (!start || !end) continue; ++ ++ if ( ++ pointData.length - 1 > subpathStartIndex && ++ start.x === end.x && ++ start.y === end.y ++ ) { ++ pointData.pop(); ++ if (end.x1 !== undefined && end.y1 !== undefined) { ++ start.x1 = end.x1; ++ start.y1 = end.y1; ++ } ++ } ++ pointData[pointData.length - 1].closed = true; ++ continue; + } + pointData.push(point); + } + +- const head = pointData[0]; +- const tail = pointData[pointData.length - 1]; +- if (tail.type !== 'end') { +- // 如果起点和终点重合, 则删除一个重复点, 并将他的控制点赋值给前后两个点 +- if (head.x === tail.x && head.y === tail.y) { +- pointData.pop(); +- +- if (tail.x2 !== undefined && tail.x2 !== undefined) { +- pointData[pointData.length - 1].x2 = tail.x2; +- pointData[pointData.length - 1].y2 = tail.y2; +- } +- +- if (tail.x1 !== undefined && tail.y1 !== undefined) { +- pointData[0].x1 = tail.x1; +- pointData[0].y1 = tail.y1; ++ for (let endIndex = pointData.length - 1; endIndex > 0; ) { ++ let startIndex = endIndex; ++ while (startIndex > 0 && !pointData[startIndex].move) startIndex--; ++ const start = pointData[startIndex]; ++ const end = pointData[endIndex]; ++ if ( ++ endIndex > startIndex && ++ !end.closed && ++ start.x === end.x && ++ start.y === end.y ++ ) { ++ pointData.splice(endIndex, 1); ++ const newEnd = pointData[endIndex - 1]; ++ newEnd.closed = true; ++ if (end.x1 !== undefined && end.y1 !== undefined) { ++ start.x1 = end.x1; ++ start.y1 = end.y1; + } + } ++ endIndex = startIndex - 1; + } + + return pointData; +@@ -127,44 +148,44 @@ export function pathData2Point(path: IPathCommandData) { + */ + export function point2PathData(points: IPoint[]) { + const pathData: IPathCommandData = []; ++ if (!points.length) return pathData; + +- const getNext = (index: number) => { +- if (index === points.length - 1) { +- return points[0]; ++ const pushSegment = (prev: IPoint, next: IPoint) => { ++ const { x, y, x1, y1 } = next; ++ if ( ++ prev.x2 === undefined && ++ prev.y2 === undefined && ++ x1 === undefined && ++ y1 === undefined ++ ) { ++ pathData.push(L, x, y); ++ } else if ( ++ prev.x2 !== undefined && ++ prev.y2 !== undefined && ++ x1 !== undefined && ++ y1 !== undefined ++ ) { ++ pathData.push(C, prev.x2, prev.y2, x1, y1, x, y); ++ } else if (prev.x2 !== undefined && prev.y2 !== undefined) { ++ pathData.push(Q, prev.x2, prev.y2, x, y); ++ } else if (x1 !== undefined && y1 !== undefined) { ++ pathData.push(Q, x1, y1, x, y); + } +- return points[index + 1]; + }; + +- pathData.push(M, points[0].x, points[0].y); +- ++ let subpathStart = points[0]; + for (let i = 0; i < points.length; i++) { +- const prev = points[i]; +- const next = getNext(i); +- +- const { type, x, y, x1, y1 } = next; +- +- if (type === 'end') { +- continue; ++ const point = points[i]; ++ if (i === 0 || point.move || point.type === 'start') { ++ pathData.push(M, point.x, point.y); ++ subpathStart = point; + } else { +- if ( +- prev.x2 === undefined && +- prev.y2 === undefined && +- x1 === undefined && +- y1 === undefined +- ) { +- pathData.push(L, x, y); +- } else if ( +- prev.x2 !== undefined && +- prev.y2 !== undefined && +- x1 !== undefined && +- y1 !== undefined +- ) { +- pathData.push(C, prev.x2 || 0, prev.y2 || 0, x1, y1, x, y); +- } else if (prev.x2 !== undefined && prev.y2 !== undefined) { +- pathData.push(Q, prev.x2 || 0, prev.y2 || 0, x, y); +- } else if (x1 !== undefined && y1 !== undefined) { +- pathData.push(Q, x1, y1, x, y); +- } ++ pushSegment(points[i - 1], point); ++ } ++ ++ if (point.closed) { ++ pushSegment(point, subpathStart); ++ pathData.push(Z); + } + } + +diff --git a/types/index.d.ts b/types/index.d.ts +index 2e48d779073af5567503a4379a84c89cfe9e4946..1d84def482b40fd3f9f301861629537445d8b84b 100644 +--- a/types/index.d.ts ++++ b/types/index.d.ts +@@ -1,6 +1,27 @@ + import { IEventTarget, IUI } from '@leafer-ui/interface'; + import { Event } from '@leafer-ui/core'; + ++type PointType = 'start' | 'end'; ++type MirrorMode = 'no-mirror' | 'mirror-angle' | 'mirror-angle-length'; ++interface IPoint { ++ x?: number; ++ y?: number; ++ x1?: number; ++ y1?: number; ++ x2?: number; ++ y2?: number; ++ mode?: MirrorMode; ++ type?: PointType; ++ move?: boolean; ++ closed?: boolean; ++} ++interface IPathSegment { ++ fromIndex: number; ++ toIndex: number; ++ insertIndex: number; ++ closing?: boolean; ++} ++ + interface IPathEditorEvent { + readonly target?: IUI; + readonly value?: IUI | IUI[]; +@@ -14,4 +35,38 @@ declare class PathEditorEvent extends Event { + constructor(type: string, data?: IPathEditorEvent); + } + +-export { PathEditorEvent }; ++interface ICoordinate { ++ x: number; ++ y: number; ++} ++interface IClosestSegmentLocation { ++ point: ICoordinate; ++ t: number; ++} ++declare function getClosestSegmentLocation(points: IPoint[], segment: IPathSegment, target: ICoordinate): IClosestSegmentLocation; ++declare function splitPathSegment(points: IPoint[], segment: IPathSegment, rawT: number): { ++ x?: number; ++ y?: number; ++ x1?: number; ++ y1?: number; ++ x2?: number; ++ y2?: number; ++ mode?: MirrorMode; ++ type?: "start" | "end"; ++ move?: boolean; ++ closed?: boolean; ++}[]; ++declare function deletePathSegment(points: IPoint[], segment: IPathSegment): { ++ x?: number; ++ y?: number; ++ x1?: number; ++ y1?: number; ++ x2?: number; ++ y2?: number; ++ mode?: MirrorMode; ++ type?: "start" | "end"; ++ move?: boolean; ++ closed?: boolean; ++}[]; ++ ++export { type IPathSegment, PathEditorEvent, deletePathSegment, getClosestSegmentLocation, splitPathSegment }; diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b0ec4bd..1d7024f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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'} diff --git a/frontend/public/cursors/pen-tool.svg b/frontend/public/cursors/pen-tool.svg new file mode 100644 index 0000000..c683e3e --- /dev/null +++ b/frontend/public/cursors/pen-tool.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 14397f9..108b786 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index df176b3..c75a47a 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -375,6 +375,9 @@ "pen": "画笔", "penColor": "画笔颜色", "penWidth": "画笔粗细", + "pathPen": "钢笔", + "pathPenColor": "钢笔颜色", + "pathPenWidth": "钢笔粗细", "grid": "网格", "smartArtboard": "智能画板", "shapeTool": "形状", diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 7fe2dcb..b6637f0 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -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(null); @@ -36,11 +38,7 @@ export function App() { }, [isAuthenticated, openLogin, route]); if (!route) { - return ( -
- -
- ); + return ; } if (route.name === "project" && !isAuthenticated) return ; diff --git a/frontend/src/ui/components/BrandMark.tsx b/frontend/src/ui/components/BrandMark.tsx index a309767..2810dfd 100644 --- a/frontend/src/ui/components/BrandMark.tsx +++ b/frontend/src/ui/components/BrandMark.tsx @@ -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 ( ); diff --git a/frontend/src/ui/components/CanvasLoading/index.css b/frontend/src/ui/components/CanvasLoading/index.css new file mode 100644 index 0000000..492bb53 --- /dev/null +++ b/frontend/src/ui/components/CanvasLoading/index.css @@ -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); + } +} diff --git a/frontend/src/ui/components/CanvasLoading/index.tsx b/frontend/src/ui/components/CanvasLoading/index.tsx new file mode 100644 index 0000000..1cbae62 --- /dev/null +++ b/frontend/src/ui/components/CanvasLoading/index.tsx @@ -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 ( +
+
+ +
+
+
+ + + + + +
+
+ +
+
+
+
+ {progress}% +
+ +
+ {currentStep} +
+
+
+ ); +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx new file mode 100644 index 0000000..dcf9fe4 --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx @@ -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; + onCommit: (pathData: EditablePathData) => void; + onClose: () => void; +}) { + const mountRef = useRef(null); + const appRef = useRef(null); + const viewportRef = useRef(viewport); + const latestPathRef = useRef(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) => event.stopPropagation(); + return
; +} + +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; +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/PenTool.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/PenTool.tsx index cb19b4f..44d5c59 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/PenTool.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/PenTool.tsx @@ -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 ( + + ); +} + 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 (
event.stopPropagation()}> onChange({ ...settings, color })} onOpacityChange={() => undefined} > - - + {variant === "path" ? : } onChange({ ...settings, width: clamp(Number(event.target.value), 1, 80) })} /> Px diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx index 88e4b0f..8a4b553 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx @@ -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 ( + + + + ); + } + 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)); } diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar/index.tsx index 8597dd9..e40577b 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar/index.tsx @@ -112,7 +112,7 @@ export function ShapeStyleToolbar({ - {options.kind !== "ellipse" && } + {options.kind !== "ellipse" && options.kind !== "path" && }