From 412f932b9f7bdd0d0bd4458b699c7c79fb728dc1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 19 May 2026 15:12:39 -0400 Subject: [PATCH] Harden undo/redo feature plan for current scope Rewrite docs/UNDO_REDO_FEATURE.md to match what the codebase actually contains today. Explicit snapshotted-vs-not field list covering savedMetaByMode, customArt canvas, editing-in-place context, the full layer schema with shape/fill/stroke/clip sub-objects, and discMeta / spine labels. Trigger list organized by category (layer lifecycle, geometry, paint, SVG per-element, clip, mode/structural, artwork loads, metadata, custom-art canvas) with debounce windows per field type. Switches storage from localStorage to memory-only for v1 to avoid quota issues with our richer state. Adds an _isRestoring guard, focused-input bypass for the keyboard shortcut, and 11 acceptance criteria covering cross-mode meta swap, edit-in-place, clipboard interaction, and the stack cap. Four implementation slices. Co-Authored-By: Claude Opus 4.7 --- docs/UNDO_REDO_FEATURE.md | 315 +++++++++++++++++++++++++++++--------- 1 file changed, 240 insertions(+), 75 deletions(-) diff --git a/docs/UNDO_REDO_FEATURE.md b/docs/UNDO_REDO_FEATURE.md index 1d09f01..affb385 100644 --- a/docs/UNDO_REDO_FEATURE.md +++ b/docs/UNDO_REDO_FEATURE.md @@ -1,89 +1,254 @@ -# Undo/Redo Feature Plan +# Undo / Redo Feature Plan (v2) -## Overview -Add undo/redo functionality to allow users to fix mistakes and reverse unintended changes. State snapshots will be saved to browser localStorage. +## Goals +Give every editor surface a familiar undo/redo flow so users can fix mistakes without leaving the editor. -## Complexity -**Moderate** - Estimated 1-2 hours to implement +### Product goals +1. `Ctrl+Z` / `Ctrl+Shift+Z` (and the equivalent macOS keys) feel natural everywhere in the editor. +2. Reverse any user-driven edit to **local design state** — adding / deleting layers, dragging, resizing, color changes, mode switches, artwork loads, metadata edits. +3. Do NOT undo network actions (publish, block, follow, tip), identity changes, or community-modal state — those are out-of-scope and irreversible. -## Key Components +### Non-goals +- Persisting the undo stack to localStorage (see "Storage strategy" — memory-only in v1). +- Selective undo of a specific past action. +- Time-travel / branched history. +- Multi-tab synced undo. +- Sub-action granularity inside text editing (text input already has the browser's native undo when focused). -### 1. Undo/Redo Stack -- Maintain two stacks: `undoStack` and `redoStack` -- Each stack stores snapshots of the entire `state` object -- Limit stack size to 20-50 snapshots to avoid filling localStorage (~2-5MB usage) +--- -### 2. State Snapshot Capture -Save snapshots at major action points (not on every keystroke): -- Project import/load -- Add/delete/modify layers -- Design image URL changes -- Design property edits (fit, zoom, x, y, rotate) -- Metadata edits (title, category, imdbId) -- Add/remove disc designs in template slots +## What snapshot includes -### 3. Storage Strategy -- Serialize state snapshots using `JSON.stringify()` -- Store in localStorage key like `undo-stack` and `redo-stack` -- Include timestamp with each snapshot for debugging -- Implement size checks to prevent localStorage overflow +A snapshot is a deep-cloned subset of `state`. The fields are split into editor-state (snapshotted) and ambient/community state (not snapshotted). -### 4. Core Functions Needed -```javascript -function saveUndoSnapshot(label) - // Save current state to undo stack, clear redo stack - -function undo() - // Restore previous state from undo stack, move current to redo stack - -function redo() - // Restore state from redo stack, move current to undo stack - -function updateUndoRedoUI() - // Enable/disable undo/redo buttons based on stack availability +### Snapshotted (editor state) +- `appMode` (`'template' | 'designer'`) +- `templateMode` (`'cover' | 'disc' | 'jewel' | 'customart'`) +- `savedMetaByMode` (the parked metadata for the other app mode) +- `meta` (title, identifiers, customTag, category, language, omdbApiKey) +- `preset`, `panelW`, `trimH`, `spineW`, `bleed`, `safe`, `artworkMode` (cover-mode dimensions + wrap mode) +- `slots`, `combined` (cover artwork) +- `discs`, `discMeta`, `discSpineLabels` (disc-sheet artwork + per-disc metadata + spine labels) +- `jewelInserts`, `jewelSpineLabels` (jewel artwork + spine labels) +- `customArt` (canvas width, height, background, preset) +- `discDesign` (single-disc-designer state) +- `layers` (the entire layer array, including per-layer fill / stroke / clip / shape data) +- `selectedLayerId` (so undoing restores the active selection) +- `editingDTag`, `editingKind`, `editingEventId`, `editingTitle` (edit-in-place context — yes, snapshot, so cancelling out of edit mode and undoing brings the context back) +- `showGuides`, `showBleed`, `showCrops`, `printGuides`, `printCrops`, `zoom` (display toggles — debatable, but cheap to include so users can undo "I turned off crops") + +### NOT snapshotted (ambient / community / cache) +- `community.*` (signed-in identity, follows, mute list, profile cache, relay list, etc.) +- `_feedCache`, `_zapCache`, `_profilePages`, `_blockedAuthors`, `_brokenImages` set +- `_layerClipboard` (clipboard is its own session ring buffer) +- `_publishConfirmTarget`, `_shapePathTarget`, `_tipTarget`, `_closeProjectTarget` (transient modal state) +- `_modalZTop`, `_aspectLocked` (UI preferences) +- `_discImportTargetSlot`, `_prevTemplateMode` (transient bookkeeping) +- `darkMode` (a user preference, not a design choice) + +### Cloning strategy +Use `structuredClone(snapshot)` for both push and restore. Falls back to `JSON.parse(JSON.stringify(...))` if `structuredClone` is somehow unavailable. Both are fast for our state size (~30–500 KB per snapshot depending on layer count). No prototype chain to preserve, no Maps/Sets to lose. + +--- + +## When to push a snapshot + +Pushes happen on **coarse, completed edits**, not on every keystroke or slider tick. The list: + +### Layer lifecycle +- `addImageLayer` / `addTextLayer` / `addColorLayer` / `addShapeLayer` succeed → push. +- Delete layer → push (before deletion so undo restores the layer). +- Duplicate layer → push. +- Paste / Paste all from the clipboard → push (after the paste lands). +- Cut → push (before removing, so undo restores it). + +### Layer geometry / transforms +- Drag end (`pointerup` on a layer wrapper after a drag) → push **once** per drag, not per pointer-move. +- W / H / X / Y / Rotate / Opacity number inputs → debounced 400 ms after last `input` event. +- Aspect-lock toggle does NOT trigger a snapshot (it's a UI preference). + +### Layer paint properties +- Color picker `change` event (the commit, not the live `input` drag) → push. +- Linear / radial gradient angle / center / radius sliders → debounced 400 ms. +- Stroke type, width, dash → debounced 300 ms for width; immediate for type / dash dropdowns. +- Shape kind-specific params (corner radius, sides, star points + inner ratio, line stroke width) → debounced 400 ms. + +### SVG layer (`type:'svg'`) +- Per-element fill / stroke color picker `change` → push per element. +- Per-element stroke-width input → debounced 400 ms. +- Replace SVG (upload) → push immediately after the new SVG is parsed and applied. + +### Clip / mask +- Clip kind dropdown change → push. +- Inline-shape clip param inputs → debounced 400 ms. +- Layer-ref clip target change → push. + +### Mode / structural +- App mode flip (`switchAppMode('template' | 'designer')`) → push **before** the swap so undo returns to the previous mode + its parked metadata. +- Template sub-mode flip (cover/disc/jewel/customart) → push before the change. +- Close project / Clear images → push **before** the wipe so undo restores everything. +- Load project from file → push before the load. +- Fork (load row payload into editor) → push before the load. +- Edit-in-place enter / Cancel edit → push (the editing context is part of state). + +### Artwork loads +- Slot artwork URL committed (cover front/spine/back, disc top/bottom, jewel front/tray, combined wrap, disc-designer image) → push. + +### Metadata +- Title / IMDb / UPC / TheTVDB / MusicBrainz / Custom tag inputs → debounced 600 ms. +- Category / language `change` → push. +- Spine label inputs → debounced 600 ms. + +### Custom Art canvas +- Canvas size preset change → push. +- Custom width / height inputs → debounced 400 ms. +- Background color picker `change` → push. + +### NOT a snapshot trigger +- Selection (`selectedLayerId` only) — selection is reflected in snapshots but selection-only changes don't push. +- Layer reorder (Move forward / Move back) — debatable; v1 says **yes** push. +- Modal opens / closes. +- Community-feed tab switches, scrolling, filter changes. +- Hover, zoom, pan. +- Layer lock toggle (debatable — push to be safe). + +--- + +## How undo / redo behave + +``` +pushHistory(label) + ├── deep-clone the snapshotted slice of state + ├── undoStack.push({ label, snapshot, at: Date.now() }) + ├── trim undoStack to UNDO_MAX entries + └── clear redoStack + +undo() + └── if undoStack.length > 0: + current = current snapshotted slice + redoStack.push({ label: 'redo→' + …, snapshot: current }) + restore from undoStack.pop() + rerender + sync inputs + paint banners + +redo() + └── mirror operation ``` -### 5. UI Changes -- Add "Undo" button in toolbar/header -- Add "Redo" button next to undo -- Show disabled state when no undo/redo available -- Optional: Show keyboard shortcuts (Ctrl+Z, Ctrl+Shift+Z) -- Optional: Show snapshot label on hover +`UNDO_MAX = 50` for v1. In-memory only — see Storage below. -### 6. Integration Points -Modify existing functions to call `saveUndoSnapshot()` after: -- `importDiscDesignFromPayload()` - after design imported -- `addLayer()` - after layer created -- Layer deletion handlers -- Design image URL updates -- Metadata input change handlers -- Template preset changes +### Restoring a snapshot +Restoration replaces the snapshotted fields on `state` and re-runs the sync/render pipeline: +1. `Object.assign(state, snapshot)` (or per-field assignment for the snapshotted set). +2. Re-derive any computed-after-restore state (we don't have much — `normalizeDiscState`, `normalizeJewelState`, `paintEditingBanner`, `applyTargetMode` on the active target, etc.). +3. Call `syncInputs()` + `syncAllSlotInputs()` + `syncAllDiscInputs()` + `syncAllJewelInputs()` + `syncCombinedInputs()` + `syncDesignerInputs()` + `syncModeTabsUI()`. +4. Call `render()`. -## Performance Considerations -- Serializing/deserializing state is fast (~1-5ms) -- No performance impact on normal operations -- localStorage access is synchronous and fast -- Consider debouncing rapid metadata edits +If the snapshot's `appMode` differs from the current `appMode`, the mode tabs UI flips along with the rest. -## Limitations -- Browser localStorage only (not synced across devices/browsers) -- Limited to current session if user closes browser -- Large image URLs could impact stack size -- No undo for external Nostr publishes (data already on network) +--- -## Future Enhancements -- IndexedDB for larger storage capacity -- Undo history UI showing snapshots -- Selective undo (undo specific action) -- Cloud sync of undo history -- Keyboard shortcuts configuration +## Keyboard shortcuts -## Testing Checklist -- [ ] Undo works after layer add -- [ ] Undo works after image URL change -- [ ] Undo works after metadata edit -- [ ] Redo restores undone actions -- [ ] Stack limits prevent localStorage overflow -- [ ] Buttons disable when stacks empty -- [ ] Page refresh preserves undo stack (if kept in localStorage) -- [ ] Works across template/designer mode switches +- `Ctrl+Z` / `Cmd+Z` → undo. +- `Ctrl+Shift+Z` / `Cmd+Shift+Z` → redo. Also accept `Ctrl+Y` (Windows muscle memory). +- **Bypass when an input is focused**: if `document.activeElement` is an ``, `