Editor has no path mirror/flip/rotate — no way to reflect a path horizontally, vertically, or rotate around its bounding box center #205

Closed
opened 2026-10-03 23:59:32 +00:00 by Mark · 1 comment
Owner

Finding

grep -n 'mirror\|flip\b' src/pages/Editor.tsx returns 0 hits for path operations (the matches are about ref-mirroring state, not path transforms). grep -n 'rotate\b' src/pages/Editor.tsx also returns 0.

The Editor allows:

  • Adding/moving/deleting nodes
  • Changing node types
  • Changing stitch type and density
  • Changing thread color (per #185 fix)

It does NOT allow:

  • Mirroring a path horizontally or vertically
  • Rotating a path around its center
  • Scaling a path (uniform or per-axis)

A user with a left-facing leaf path who wants a right-facing version has to redraw it or use manual node coordinate math.

src/components/workspace/ImageEditToolbar.tsx has rotate 90°/180°/270° and flip-horizontal for the source image, but the Editor has no equivalent for vector paths.

Where

  • Editor: src/pages/Editor.tsx (no path transform handlers)
  • Right panel: src/components/editor/EditorRightPanel.tsx (no mirror/rotate buttons)
  • Image transform (reference): src/components/workspace/ImageEditToolbar.tsx (rotate/flip UI for raster)
  • No path transform reducer: grep -n 'TRANSFORM_PATH' src/components/editor/ returns 0

Suggested fix shape

Add a "Transform" section to the right panel's "Path Properties" with three buttons:

<div>
  <label>Transform</label>
  <div className="grid grid-cols-3 gap-1">
    <button onClick={() => onTransformPath('flipX')} title="Flip horizontal">
      <FlipHorizontal2 size={12} />
    </button>
    <button onClick={() => onTransformPath('flipY')} title="Flip vertical">
      <FlipVertical2 size={12} />
    </button>
    <button onClick={() => onTransformPath('rotate90')} title="Rotate 90°">
      <RotateCw size={12} />
    </button>
  </div>
</div>

onTransformPath dispatches a TRANSFORM_PATH action that:

  • Computes the path's bounding box center
  • Applies the transform to each node: (x, y) → (2*cx - x, y) for flipX; (x, y) → (x, 2*cy - y) for flipY; rotation matrix for rotate90
  • Pushes history for undo
  • Preserves the controlIn / controlOut Bezier handles (they get the same transform applied)

The control point transform is the tricky part: the handles need to be transformed around the same center, not just the anchor points. The math is well-defined (rotation matrix is linear; reflection is affine); the implementation should be a single helper in geometry.ts to keep it testable.

Severity

Medium. Common operation in any vector editor; users expect it.

Discovered by

Gap analysis (2026-10-02) of editor path operations.

  • #187 (open) — Editor path duplicate. Transform is the same family.
  • #188 (open) — Editor multi-path selection. Multi-transform (apply same transform to all selected) is a natural follow-up.
  • ImageEditToolbar (the workspace-side raster transform) is the reference implementation for the UI shape.
## Finding `grep -n 'mirror\|flip\b' src/pages/Editor.tsx` returns 0 hits for path operations (the matches are about ref-mirroring state, not path transforms). `grep -n 'rotate\b' src/pages/Editor.tsx` also returns 0. The Editor allows: - Adding/moving/deleting nodes - Changing node types - Changing stitch type and density - Changing thread color (per #185 fix) It does NOT allow: - Mirroring a path horizontally or vertically - Rotating a path around its center - Scaling a path (uniform or per-axis) A user with a left-facing leaf path who wants a right-facing version has to redraw it or use manual node coordinate math. `src/components/workspace/ImageEditToolbar.tsx` has rotate 90°/180°/270° and flip-horizontal for the source image, but the Editor has no equivalent for vector paths. ## Where - Editor: `src/pages/Editor.tsx` (no path transform handlers) - Right panel: `src/components/editor/EditorRightPanel.tsx` (no mirror/rotate buttons) - Image transform (reference): `src/components/workspace/ImageEditToolbar.tsx` (rotate/flip UI for raster) - No path transform reducer: `grep -n 'TRANSFORM_PATH' src/components/editor/` returns 0 ## Suggested fix shape Add a "Transform" section to the right panel's "Path Properties" with three buttons: ```tsx <div> <label>Transform</label> <div className="grid grid-cols-3 gap-1"> <button onClick={() => onTransformPath('flipX')} title="Flip horizontal"> <FlipHorizontal2 size={12} /> </button> <button onClick={() => onTransformPath('flipY')} title="Flip vertical"> <FlipVertical2 size={12} /> </button> <button onClick={() => onTransformPath('rotate90')} title="Rotate 90°"> <RotateCw size={12} /> </button> </div> </div> ``` `onTransformPath` dispatches a `TRANSFORM_PATH` action that: - Computes the path's bounding box center - Applies the transform to each node: `(x, y) → (2*cx - x, y)` for flipX; `(x, y) → (x, 2*cy - y)` for flipY; rotation matrix for rotate90 - Pushes history for undo - Preserves the `controlIn` / `controlOut` Bezier handles (they get the same transform applied) The control point transform is the tricky part: the handles need to be transformed around the same center, not just the anchor points. The math is well-defined (rotation matrix is linear; reflection is affine); the implementation should be a single helper in `geometry.ts` to keep it testable. ## Severity Medium. Common operation in any vector editor; users expect it. ## Discovered by Gap analysis (2026-10-02) of editor path operations. ## Related - #187 (open) — Editor path duplicate. Transform is the same family. - #188 (open) — Editor multi-path selection. Multi-transform (apply same transform to all selected) is a natural follow-up. - `ImageEditToolbar` (the workspace-side raster transform) is the reference implementation for the UI shape.
Author
Owner

Shipped in dd4c572 on origin/main. Right-panel Path Properties now has a Transform section with three buttons (Flip-H, Flip-V, Rotate-90° CW). Each transform pivots on the path's bbox center (anchor + control handles), so the mirror reflects in place rather than around the canvas origin. Bézier controlIn/controlOut ride along with their anchors so curvature is preserved.

Multi-select fans out — every path in selectedPathIds gets its own bbox-pivoted transform in a single setPaths + history push. Mirrors the existing handleUpdateSelectedPath* family.

Files changed (+550/-17):

  • src/components/editor/geometry.ts — new helpers: pathBoundingBox (handles included), flipHorizontalNodes, flipVerticalNodes, rotate90Nodes, applyPathTransform. PathTransform union as the op vocabulary.
  • src/pages/Editor.tsx — handleTransformSelectedPath fan-out handler; local pathBoundingBox replaced by the geometry version.
  • src/components/editor/EditorRightPanel.tsx — optional onTransformSelectedPath prop + Transform section with three lucide icons (FlipHorizontal2, FlipVertical2, RotateCw). Draw-path-only, disabled on zero-node paths, hidden when the prop is not wired (back-compat).
  • src/components/editor/geometry.test.ts — 16 new tests pinning the math: reflection formulas, control-handle preservation, rotation invariant (4× = identity within FP epsilon), empty/no-op safety, dispatch via applyPathTransform.
  • src/components/editor/EditorRightPanel.test.tsx — 8 new tests pinning the UI: three buttons wire through with the right op arg, disabled on empty path, hidden when prop omitted (back-compat), draw-path-only, multi-select label reads "Transform (N paths)".

Gates: vitest 953/953 (+24 new), eslint clean, tsc -b clean, vite build clean. Refs #187 (Duplicate) and #188 (Multi-path selection) — this is the third sibling in the same right-panel family.

Shipped in `dd4c572` on `origin/main`. Right-panel Path Properties now has a **Transform** section with three buttons (Flip-H, Flip-V, Rotate-90° CW). Each transform pivots on the path's bbox center (anchor + control handles), so the mirror reflects in place rather than around the canvas origin. Bézier controlIn/controlOut ride along with their anchors so curvature is preserved. **Multi-select fans out** — every path in `selectedPathIds` gets its own bbox-pivoted transform in a single setPaths + history push. Mirrors the existing `handleUpdateSelectedPath*` family. **Files changed** (+550/-17): - `src/components/editor/geometry.ts` — new helpers: `pathBoundingBox` (handles included), `flipHorizontalNodes`, `flipVerticalNodes`, `rotate90Nodes`, `applyPathTransform`. `PathTransform` union as the op vocabulary. - `src/pages/Editor.tsx` — `handleTransformSelectedPath` fan-out handler; local `pathBoundingBox` replaced by the geometry version. - `src/components/editor/EditorRightPanel.tsx` — optional `onTransformSelectedPath` prop + Transform section with three lucide icons (`FlipHorizontal2`, `FlipVertical2`, `RotateCw`). Draw-path-only, disabled on zero-node paths, hidden when the prop is not wired (back-compat). - `src/components/editor/geometry.test.ts` — 16 new tests pinning the math: reflection formulas, control-handle preservation, rotation invariant (4× = identity within FP epsilon), empty/no-op safety, dispatch via `applyPathTransform`. - `src/components/editor/EditorRightPanel.test.tsx` — 8 new tests pinning the UI: three buttons wire through with the right op arg, disabled on empty path, hidden when prop omitted (back-compat), draw-path-only, multi-select label reads "Transform (N paths)". **Gates**: vitest 953/953 (+24 new), eslint clean, `tsc -b` clean, `vite build` clean. Refs #187 (Duplicate) and #188 (Multi-path selection) — this is the third sibling in the same right-panel family.
Mark closed this issue 2026-10-04 02:18:42 +00:00
Sign in to join this conversation.
No description provided.