mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
Draft pen & pencil tool feature plan
docs/PEN_TOOL_FEATURE.md: pen and pencil tools that author shape.kind:'path' layers rather than a new raster type, so drawn paths inherit the existing fill / gradient / stroke / clip / opacity / transform / undo / export / Nostr plumbing for free. Decisions locked with the project owner: - Pencil strokes are raw polylines, no smoothing (early-web look). - Only pen paths are node-editable; pencil paths are not. - A closed pen path auto-fills with a solid accent color on close. - Pen + pencil work in Disc Designer mode too — the overlay attaches to #designerDiscWrap and coordinate capture branches on isDesignerLayer, mirroring startLayerDrag. Plan: a state.editorTool tool-mode system (select / pen / pencil), a left floating tool palette and a right contextual options toolbar matching the zoom-toolbar styling, a pointer-capturing pen overlay, shape.pen.nodes metadata for re-editable pen paths, and a new layer.hidden flag for mask-source shapes. Five phases: A tool-mode + toolbars, B pencil, C pen create, D pen node editing, E visibility flag + mask polish. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
162da4b9e4
commit
ee0052847e
1 file changed
+415
@@ -0,0 +1,415 @@
|
||||
# Pen & Pencil Tools
|
||||
|
||||
## Goals
|
||||
Add freehand and Bézier path drawing directly on the canvas, plus an editor
|
||||
tool-mode system with flanking toolbars to host it.
|
||||
|
||||
### Product goals
|
||||
1. A **Pencil** tool for freehand strokes — markup, scribbles, hand-drawn
|
||||
accents, tracing over placed artwork.
|
||||
2. A **Pen** tool for precise Bézier paths — clean cut lines, custom
|
||||
silhouettes, editable curves.
|
||||
3. Pen/pencil output reuses the existing `shape` layer system, so a drawn
|
||||
path is a first-class vector layer: solid / gradient fill, stroke,
|
||||
opacity, transform, clip, undo, JSON round-trip, Nostr publish, and
|
||||
PDF / JPEG export — all free.
|
||||
4. A canvas tool-mode system with a left tool palette and a right
|
||||
contextual options toolbar, styled to match the existing bottom zoom
|
||||
toolbar.
|
||||
|
||||
### Non-goals (v1)
|
||||
- A raster paint layer (true pixel buffers). Explicitly out of scope — the
|
||||
app's design-as-JSON model and Nostr-friendliness depend on storing
|
||||
instructions, not bitmaps. A `paint` layer type could be a separate future
|
||||
feature; this doc does not pursue it.
|
||||
- Pressure / tilt / velocity-sensitive brushes.
|
||||
- Boolean path operations (union / subtract / intersect).
|
||||
- Moving the existing shape-primitive menu into the left toolbar. The
|
||||
sidebar "+ Shape" menu stays; pen/pencil are additive.
|
||||
|
||||
---
|
||||
|
||||
## Why this fits the repo
|
||||
|
||||
The vector-shapes feature already shipped these, all of which a pen path
|
||||
inherits with zero new renderer code:
|
||||
|
||||
- `shape.kind: 'path'` with a `d` string + `viewBox`, rendered on the editor
|
||||
canvas, all five preview surfaces, and Canvas2D export.
|
||||
- `fill`: `none` / `solid` / `linear-gradient` / `radial-gradient`.
|
||||
- `stroke`: solid, width, dash.
|
||||
- `clip`: `inline-shape` and `layer-ref` (one shape layer masks another).
|
||||
- Layer transform (x/y/w/h/rotate/opacity), aspect-lock, lock toggle,
|
||||
clipboard, and undo/redo.
|
||||
|
||||
So the pen tool is **a path-authoring UI feeding the existing shape model**.
|
||||
The render/save/export side is essentially done. The new work is: the tool
|
||||
mode system, the canvas pointer interaction, and an optional editable-node
|
||||
representation.
|
||||
|
||||
### Current-state facts the plan is built on
|
||||
- Schema version is **5**; this feature bumps it to **6** (additive only).
|
||||
- Layer types today: `image`, `text`, `color`, `shape`, `qr`.
|
||||
- Shape kinds: `rect`, `rounded-rect`, `circle`, `ellipse`, `triangle`,
|
||||
`polygon`, `star`, `line`, `path`, `svg`.
|
||||
- There is **no per-layer visibility flag** — only `locked`. This feature
|
||||
adds `hidden` (needed for mask-source shapes).
|
||||
- `shape.kind: 'path'` `d` data lives in its own `viewBox`; the renderer
|
||||
already maps that viewBox to the layer box via a `<g transform>`. The pen
|
||||
tool exploits this — it can emit `d` in any coordinate space as long as
|
||||
`viewBox` matches.
|
||||
- Layer drag is `startLayerDrag` on a layer wrapper's `pointerdown`. It
|
||||
already does the screen-px → sheet-inch math the pen tool needs.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### Pen/pencil output is a `shape` path layer
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'layer-…',
|
||||
type: 'shape',
|
||||
name: 'Pen path',
|
||||
target: 'cover-trim' | 'canvas' | …,
|
||||
x, y, w, h, rotate, opacity, z, locked,
|
||||
hidden: false, // NEW — see "Layer visibility" below
|
||||
shape: {
|
||||
kind: 'path',
|
||||
d: 'M … C … Z', // canonical render/export path string
|
||||
viewBox: { x, y, w, h }, // the d-string's coordinate space
|
||||
pen: { // NEW — optional editor metadata
|
||||
closed: true, // pen-closed path vs open pencil stroke
|
||||
tool: 'pen' | 'pencil', // which tool authored it
|
||||
nodes: [ // anchors with optional Bézier handles
|
||||
{ x, y, hIn: { x, y } | null, hOut: { x, y } | null },
|
||||
…
|
||||
]
|
||||
}
|
||||
},
|
||||
fill: { type: 'solid', color: '#…' }, // pen-closed default
|
||||
// or fill: { type: 'none' } for pencil strokes
|
||||
stroke: { type: 'solid', color: '#…', width, dash }
|
||||
}
|
||||
```
|
||||
|
||||
- `d` is the **canonical** value — render, export, and old clients use only
|
||||
`d` + `viewBox`. Everything else is editor convenience.
|
||||
- `shape.pen` is optional metadata. It lets the pen tool reconstruct
|
||||
editable anchors later. A path imported via SVG upload won't have it, and
|
||||
that's fine — it just isn't node-editable (consistent with today).
|
||||
- When the user drags an anchor or handle, regenerate `d` from `nodes`.
|
||||
|
||||
### Why store `nodes` separately instead of parsing `d`
|
||||
Parsing an arbitrary SVG path `d` string back into editable Bézier anchors
|
||||
is fiddly and lossy (arcs, relative commands, shorthand). Keeping `nodes` as
|
||||
the source of truth for pen-authored paths — and `d` as the derived render
|
||||
string — sidesteps all of it. SVG-uploaded paths simply have no `nodes` and
|
||||
stay non-node-editable.
|
||||
|
||||
### Layer visibility (`hidden`)
|
||||
New optional boolean `layer.hidden`. When `true`:
|
||||
- All six visual renderers and the Canvas2D export **skip** the layer.
|
||||
- `clip: { kind: 'layer-ref' }` still resolves the hidden layer's geometry —
|
||||
so a pen shape drawn purely as a mask can be hidden while still masking.
|
||||
- The layer list shows an eye toggle.
|
||||
- `hidden` is in the undo snapshot and the JSON payload (additive — old
|
||||
clients ignore it and render the layer, which is acceptable).
|
||||
|
||||
This is small but touches every renderer; it ships as its own phase so it
|
||||
can be verified independently.
|
||||
|
||||
---
|
||||
|
||||
## Tool-mode system + toolbars
|
||||
|
||||
### Editor tool state
|
||||
New `state.editorTool: 'select' | 'pen' | 'pencil'`, default `'select'`.
|
||||
- **select** — today's behavior exactly: click/drag layers, click to
|
||||
select. Nothing changes.
|
||||
- **pen** / **pencil** — canvas pointer events route to the drawing handler;
|
||||
layer drag/select is suppressed while drawing.
|
||||
- `editorTool` is **UI state, not design state** — it is NOT part of the
|
||||
undo snapshot and NOT saved to JSON.
|
||||
- `Esc` always returns to `select` (and cancels an in-progress path).
|
||||
|
||||
### Left toolbar — tool palette
|
||||
A thin vertical strip floating on the **left edge** of the preview area
|
||||
(absolute-positioned inside `previewShell`, same visual language as the
|
||||
sticky bottom zoom toolbar). Visible in template modes **and** Disc
|
||||
Designer mode.
|
||||
|
||||
```
|
||||
┌──┐
|
||||
│ ▸│ Select (V)
|
||||
│ ✎│ Pen (P)
|
||||
│ ✐│ Pencil (B)
|
||||
└──┘
|
||||
```
|
||||
|
||||
Each button sets `state.editorTool`. The active tool is highlighted.
|
||||
Single-key shortcuts (V / P / B) when no input is focused.
|
||||
|
||||
### Right toolbar — contextual tool options
|
||||
A floating panel on the **right edge** of the preview area, **visible only
|
||||
when `editorTool !== 'select'`**. Shows the active tool's configuration:
|
||||
|
||||
- **Pencil options**: stroke color and stroke width. No smoothing control —
|
||||
pencil strokes are intentionally raw polylines. A Done / Cancel pair.
|
||||
- **Pen options**: fill on/off + fill color, stroke color + width,
|
||||
"close path" toggle, and Finish / Cancel. While editing an existing
|
||||
path's nodes: Add point / Delete point / toggle corner-smooth hints.
|
||||
|
||||
Defaults persist across drawings in a session (so you don't re-pick red
|
||||
every stroke) but are NOT design state.
|
||||
|
||||
### Why floating toolbars, not sidebar panels
|
||||
The user specifically wants Figma/Illustrator-style flanking toolbars, and
|
||||
the bottom zoom toolbar already established the floating-over-canvas
|
||||
pattern. The left/right bars reuse that CSS treatment (translucent panel,
|
||||
rounded, subtle shadow). Keeps the sidebar focused on layer/project
|
||||
controls.
|
||||
|
||||
---
|
||||
|
||||
## Pointer interaction
|
||||
|
||||
A dedicated **pen overlay** — an absolutely-positioned, full-bleed SVG layer
|
||||
on top of the active canvas host — captures pointer events while a drawing
|
||||
tool is active. It renders the in-progress path, anchors, and handles, and
|
||||
on finish commits a real shape layer. When `editorTool === 'select'` the
|
||||
overlay has `pointer-events: none` and is invisible.
|
||||
|
||||
The overlay attaches to whichever canvas host is active: `#sheet` in
|
||||
template modes (cover / disc / jewel / customart) or `#designerDiscWrap` in
|
||||
Disc Designer mode. The Disc Designer canvas is circular only as visual
|
||||
framing — paths are still drawn in the disc's square bounding box, so the
|
||||
pen tool works there with no special-casing beyond the coordinate scale.
|
||||
|
||||
All coordinates are captured in **canvas-inch space** by reusing the
|
||||
screen-px → inch math from `startLayerDrag`, which already branches
|
||||
`isDesignerLayer ? discTemplate.discD : sheetW()/sheetH()` and accounts for
|
||||
the current zoom scale. Paths committed in Disc Designer mode get
|
||||
`target: 'designer-disc'`; template-mode paths get the active sub-mode's
|
||||
target.
|
||||
|
||||
### Pencil (freehand)
|
||||
1. `pointerdown` on the overlay → start a stroke, push the first point.
|
||||
2. `pointermove` → append points (minimum-distance filtered so a single
|
||||
stroke doesn't capture thousands of near-identical points).
|
||||
3. `pointerup` → finish:
|
||||
- **No smoothing.** The stroke is a raw polyline through the captured
|
||||
points — straight `L` segments, deliberately a little jaggy (the
|
||||
"early-web" look the project wants). Light distance-decimation only,
|
||||
to keep the `d` string a sane length; no Bézier fitting, no
|
||||
Catmull-Rom, no smoothing slider.
|
||||
- Compute the bounding box → that's the new layer's `x/y/w/h` (inches)
|
||||
and the shape `viewBox`.
|
||||
- Build the `d` string (open path: `M` + `L` segments, no `Z`).
|
||||
- Commit a `shape` layer: `fill: { type: 'none' }`,
|
||||
`stroke: { type: 'solid', … }`, `shape.pen = { closed: false,
|
||||
tool: 'pencil', nodes }`.
|
||||
- One `pushHistory('draw pencil stroke')` snapshot for the whole stroke.
|
||||
4. Tool stays `pencil` so the user can keep drawing strokes.
|
||||
|
||||
### Pen (Bézier)
|
||||
1. `pointerdown` → place an anchor. If it's a quick click → corner anchor
|
||||
(no handles). If the user `pointermove`s before `pointerup` → drag out
|
||||
symmetric Bézier handles (smooth anchor).
|
||||
2. The overlay live-previews the path through the placed anchors plus a
|
||||
"rubber-band" segment to the cursor.
|
||||
3. Finish the path by any of: clicking the first anchor (closes it),
|
||||
pressing `Enter` (open path) or double-click, or `Esc` (cancel).
|
||||
4. On finish:
|
||||
- Build `d` from `nodes` (`M` + `C`/`L` segments, `Z` if closed).
|
||||
- bbox → layer `x/y/w/h` + `viewBox`.
|
||||
- Commit a `shape` layer: closed → `fill: { type: 'solid', … }`;
|
||||
open → `fill: { type: 'none' }` + stroke. `shape.pen = { closed,
|
||||
tool: 'pen', nodes }`.
|
||||
- One `pushHistory('draw pen path')` snapshot.
|
||||
5. Tool stays `pen` for the next path.
|
||||
|
||||
### Editing an existing pen path
|
||||
With the Pen tool active and a `shape` layer that has `shape.pen.nodes`
|
||||
selected, the overlay shows the editable anchors + handles:
|
||||
- Drag an anchor → move it; drag a handle → reshape the curve.
|
||||
- Click an anchor to select; `Delete` removes it.
|
||||
- Click a segment to insert an anchor.
|
||||
- Alt-click an anchor toggles corner ↔ smooth.
|
||||
- Every committed edit regenerates `d` and pushes one undo snapshot.
|
||||
|
||||
SVG-uploaded paths (no `nodes`) are not node-editable — selecting one with
|
||||
the Pen tool shows a hint to that effect.
|
||||
|
||||
---
|
||||
|
||||
## Masking with pen shapes
|
||||
|
||||
Already supported by `clip: { kind: 'layer-ref', layerId }` — any layer can
|
||||
point at a shape layer (including a pen path) and use its geometry as a
|
||||
mask. The pen tool needs **no new mask code**; it just produces shape layers
|
||||
that the existing clip system can reference.
|
||||
|
||||
What this feature adds for a clean mask workflow:
|
||||
1. The `hidden` flag (above), so a pen shape used purely as a mask can be
|
||||
hidden while still masking.
|
||||
2. A convenience action — "Use as clip for selected layer" — in the pen
|
||||
path's controls or a right-click affordance. Optional polish.
|
||||
|
||||
**Known caveat** (carried over from the vector-shapes design): a `layer-ref`
|
||||
clip uses the referenced shape's path geometry normalized to the *clipped
|
||||
layer's* bounding box — it is not yet a Photoshop-style absolute-canvas
|
||||
mask. For v1 that's fine. A future `clip.mode: 'absolute'` could compute the
|
||||
mask layer's transform relative to the clipped layer at render time; out of
|
||||
scope here.
|
||||
|
||||
---
|
||||
|
||||
## Rendering & export
|
||||
|
||||
Essentially free. A committed pen/pencil layer is a `shape.kind: 'path'`
|
||||
layer, so:
|
||||
- `buildShapeSvgInner` already renders path kinds on all six surfaces.
|
||||
- `drawShapeLayerToCanvas` already draws path kinds via `Path2D` for
|
||||
PDF / JPEG export.
|
||||
- Fill / gradient / stroke / clip / opacity / rotate all already apply.
|
||||
|
||||
The only renderer change in the whole feature is the `hidden` skip, which is
|
||||
one early-`continue` per renderer.
|
||||
|
||||
---
|
||||
|
||||
## Phased delivery
|
||||
|
||||
### Phase A — Tool-mode system + toolbars
|
||||
- `state.editorTool` + Esc-to-select.
|
||||
- Left tool palette (Select / Pen / Pencil) floating on the canvas, with
|
||||
V / P / B shortcuts.
|
||||
- Right contextual options toolbar shell, shown only for non-select tools.
|
||||
- The pen overlay element (inert for now).
|
||||
- No drawing yet — just mode plumbing and the UI shell.
|
||||
|
||||
**Ship gate**: switching tools highlights the palette and shows/hides the
|
||||
right toolbar; Esc returns to Select; nothing else regresses.
|
||||
|
||||
### Phase B — Pencil tool
|
||||
- Freehand capture on the overlay; raw polyline, distance-decimation only,
|
||||
no smoothing.
|
||||
- Commit an open-path `shape` layer with stroke, no fill.
|
||||
- Right toolbar: stroke color / width.
|
||||
- One undo snapshot per stroke.
|
||||
|
||||
**Ship gate**: draw a stroke, it becomes a selectable shape layer that
|
||||
round-trips through save/load and exports to PDF.
|
||||
|
||||
### Phase C — Pen tool (create)
|
||||
- Click = corner anchor, click-drag = smooth anchor.
|
||||
- Live rubber-band preview overlay.
|
||||
- Close via first-anchor click; finish via Enter / double-click; cancel
|
||||
via Esc.
|
||||
- Commit a closed (fillable) or open `shape` layer; store `shape.pen.nodes`.
|
||||
- Right toolbar: fill on/off + color, stroke, close-path.
|
||||
|
||||
**Ship gate**: draw a closed Bézier shape, fill it with a gradient using the
|
||||
existing fill controls, export it.
|
||||
|
||||
### Phase D — Pen path editing
|
||||
- Editable anchors/handles overlay for layers with `shape.pen.nodes`.
|
||||
- Move / add / delete anchors; corner ↔ smooth toggle; regenerate `d`.
|
||||
- One undo snapshot per committed edit.
|
||||
|
||||
**Ship gate**: re-open a pen path, drag an anchor, the path updates and
|
||||
re-exports correctly.
|
||||
|
||||
### Phase E — Layer visibility + mask polish
|
||||
- `layer.hidden` flag; eye toggle in the layer list; all renderers + export
|
||||
skip hidden layers; `layer-ref` clips still resolve hidden geometry.
|
||||
- "Use as clip for selected layer" convenience action.
|
||||
|
||||
**Ship gate**: a hidden pen shape masks an image and doesn't render itself;
|
||||
toggling the eye shows it again.
|
||||
|
||||
---
|
||||
|
||||
## Risks & tradeoffs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Tool-mode changes the canvas pointer contract — could regress layer drag/select | `select` mode keeps the exact current code path; pen/pencil only intercept when active. Heavy regression testing on Phase A. |
|
||||
| Coordinate mapping under zoom / scroll | Reuse the proven screen-px → inch math from `startLayerDrag`; capture in inch space throughout. |
|
||||
| Freehand strokes produce huge `d` strings | RDP simplification on `pointerup`; tune epsilon so visual fidelity holds while node count stays modest. |
|
||||
| Pen path `d` ↔ `nodes` drift | `nodes` is the source of truth for pen-authored paths; `d` is always regenerated from `nodes`, never hand-edited. |
|
||||
| `hidden` flag touches all 6 renderers + export | Isolated as Phase E; one-line skip per renderer; explicit acceptance test. |
|
||||
| Old clients ignore `hidden` and render a mask-only shape | Acceptable — the design still reads; a future schemaVersion bump can formalize it. |
|
||||
| Disc Designer uses a different canvas host + coordinate scale | Overlay attaches to `#designerDiscWrap`; coordinate capture branches on `isDesignerLayer` exactly like `startLayerDrag` already does. |
|
||||
| Mask geometry is normalized-to-clipped-layer, not absolute | Documented caveat; `clip.mode: 'absolute'` is future work. |
|
||||
| Two new floating toolbars crowd the canvas on small screens | Collapse/auto-hide on narrow viewports; the right toolbar is already conditional on tool mode. |
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Pencil smoothing** — DECIDED: no smoothing. Pencil strokes are raw
|
||||
polylines (straight segments through captured points). Deliberately a
|
||||
bit jaggy — matches the project's early-web aesthetic. No smoothing
|
||||
slider, no Bézier fitting on freehand strokes.
|
||||
2. **Pencil node-editability** — DECIDED: no. Only pen paths are
|
||||
node-editable. Pencil `shape.pen.nodes` is still stored (for a possible
|
||||
future re-pass) but the editor never shows draggable anchors for a
|
||||
`tool: 'pencil'` path.
|
||||
3. **Closed pen path default fill** — DECIDED: yes, fill it on close with a
|
||||
solid accent color, so a finished closed path is immediately visible.
|
||||
The user can switch to none / gradient via the existing fill controls.
|
||||
4. **Disc Designer support** — DECIDED: yes, pen + pencil work in Disc
|
||||
Designer mode too. The overlay attaches to `#designerDiscWrap`; the
|
||||
circular canvas is just visual framing.
|
||||
5. **Touch / stylus** — capture works via Pointer Events (already used for
|
||||
layer drag), so basic touch drawing is free. Pressure is a non-goal.
|
||||
6. **Schema bump** — set `schemaVersion` to 6 when this lands. Old payloads
|
||||
(v5) still load; `shape.pen` and `hidden` are additive.
|
||||
7. **Where do the shape primitives ultimately live?** Long-term they could
|
||||
move from the sidebar "+ Shape" menu into the left tool palette. Out of
|
||||
scope for v1.
|
||||
|
||||
### Noted for later (out of scope here)
|
||||
The project owner is considering merging the **Custom Art** layout into the
|
||||
**Disc Designer** tab and renaming the tab to just **"Designer"**, with
|
||||
*disc* becoming one of several artboard layout options inside it — so the
|
||||
designer tab becomes a fully-featured surface rather than a disc-only one.
|
||||
The pen tool does not depend on or block this: it produces plain `shape`
|
||||
layers keyed by `target`, so it works regardless of how the tabs are
|
||||
ultimately organized. Flagged here only so the eventual restructure keeps
|
||||
the pen tool's per-mode overlay-host logic in mind.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance summary
|
||||
|
||||
### Phase A
|
||||
- [ ] Left tool palette switches `state.editorTool`; active tool highlighted.
|
||||
- [ ] V / P / B shortcuts work when no input is focused; Esc → Select.
|
||||
- [ ] Right options toolbar appears only for Pen/Pencil.
|
||||
- [ ] Select mode behaves exactly as before — no drag/select regression.
|
||||
|
||||
### Phase B
|
||||
- [ ] Freehand pencil stroke commits an open-path shape layer.
|
||||
- [ ] Stroke round-trips through save/load and Nostr publish/fork.
|
||||
- [ ] Exports correctly to PDF / JPEG.
|
||||
- [ ] One undo entry per stroke.
|
||||
|
||||
### Phase C
|
||||
- [ ] Pen places corner + smooth anchors; live preview is accurate.
|
||||
- [ ] Closing / finishing / cancelling all work.
|
||||
- [ ] Closed path is fillable with the existing solid/gradient controls.
|
||||
- [ ] `shape.pen.nodes` persists through save/load.
|
||||
|
||||
### Phase D
|
||||
- [ ] A pen path re-opens with editable anchors/handles.
|
||||
- [ ] Move / add / delete anchors regenerates `d` and re-exports.
|
||||
- [ ] SVG-uploaded paths show a "not node-editable" hint.
|
||||
|
||||
### Phase E
|
||||
- [ ] `hidden` layers are skipped by every renderer and the export.
|
||||
- [ ] A hidden pen shape still works as a `layer-ref` clip.
|
||||
- [ ] Eye toggle in the layer list flips `hidden`.
|
||||
Reference in new issue
Block a user