import { useState, useCallback, useRef, useEffect } from 'react'; import { makeElementId } from '../utils/makeId'; const MAX_HISTORY = 50; const DEBOUNCE_DELAY_MS = 300; // ──────────────────────────────────────────────────────────────────── // Diagnostic history logging. // // Enable by running this in the browser console, then reload: // localStorage.setItem('paw_debug_history', '1') // Disable with: // localStorage.removeItem('paw_debug_history') // // Every line is prefixed `[history]` so you can filter in DevTools. // Designed specifically for diagnosing the crop+undo "canvas ends up // blank" bug: when undo lands on an unexpected snapshot, this log // reveals which push got missed or deduped, or whether history was // wiped between the action and the undo. // // Each event logs `{ idx, len, source, ...details }` so a single // scroll-through tells you: // • What the index was at each step — if undo walks from N to N-1 // and N-1 is unexpectedly an empty snapshot, the wipe happened // earlier in the log. // • What the history length was — truncations (redo-stack discard) // and the MAX_HISTORY shift are visible. // • Where the call came from — every push includes `source` so you // can tell addElement vs replaceElements vs updateAndCommit vs // the debounced updateElement timer apart. // ──────────────────────────────────────────────────────────────────── const HISTORY_DEBUG = (() => { try { return typeof localStorage !== 'undefined' && localStorage.getItem('paw_debug_history') === '1'; } catch { return false; } })(); // Short summary of an elements array — we don't dump the full JSON in // every log line because it's huge and obscures the signal. We do show // element count and first-three IDs, which together are enough to // distinguish "the image array", "the cropped array", and "empty" // in a glance. function summarizeElements(arr) { if (!Array.isArray(arr)) return ''; if (arr.length === 0) return '[] (empty)'; const ids = arr.slice(0, 3).map((el) => el?.id ?? '?').join(', '); const tail = arr.length > 3 ? `, …+${arr.length - 3}` : ''; return `${arr.length} els [${ids}${tail}]`; } function hlog(event, info) { if (!HISTORY_DEBUG) return; // Using a single console.log call (not console.group) keeps the lines // copyable as a single block when the user wants to share the log. // The leading prefix is intentionally verbose so it survives DevTools // filter-by-text searches even when the rest of the line wraps. // eslint-disable-next-line no-console console.log(`[history] ${event}`, info); } export function useDesignEditor() { const [elements, setElements] = useState([]); // Multi-select state (S3). The authoritative selection is a Set of element // ids. Single-selection callers read `selectedId` (a derived value that's // non-null only when exactly one element is selected) and use it like // before — that path is unchanged. Multi-select callers (LayersPanel) read // `selectedIds` directly. const [selectedIds, setSelectedIds] = useState(() => new Set()); const selectedId = selectedIds.size === 1 ? [...selectedIds][0] : null; const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const historyRef = useRef([]); const historyIndexRef = useRef(-1); const historyTimerRef = useRef(null); const pendingChangesRef = useRef(null); const syncUndoRedo = useCallback(() => { setCanUndo(historyIndexRef.current > 0); setCanRedo(historyIndexRef.current < historyRef.current.length - 1); }, []); // `source` is a label identifying who called us (addElement, // replaceElements, updateAndCommit, flushPendingChanges-timer, etc.). // It's only consulted by the diagnostic logger — production behaviour // is unchanged. Defaults to '?' so callers that don't bother to pass // it still work. const saveToHistory = useCallback((newElements, source = '?') => { const json = JSON.stringify(newElements); const beforeIdx = historyIndexRef.current; const beforeLen = historyRef.current.length; // Dedupe consecutive identical snapshots. The primary motivation is React // StrictMode, which double-invokes the setElements updater in dev to detect // impure updaters; the inner saveToHistory side-effect runs twice with the // same input and used to push two identical entries (so undo had to be // pressed twice to undo one action). This dedupe is also a real correctness // improvement outside StrictMode — calling saveToHistory with no actual // change is a no-op rather than a bloated history slot. if (historyIndexRef.current >= 0 && historyRef.current[historyIndexRef.current] === json) { hlog('saveToHistory DEDUPE', { source, idx: beforeIdx, len: beforeLen, snapshot: summarizeElements(newElements), reason: 'identical to current entry', }); return; } const willTruncate = historyIndexRef.current < historyRef.current.length - 1; if (willTruncate) { historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1); } historyRef.current.push(json); let shifted = false; if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); shifted = true; } else { historyIndexRef.current++; } syncUndoRedo(); hlog('saveToHistory PUSH', { source, idx: `${beforeIdx} → ${historyIndexRef.current}`, len: `${beforeLen} → ${historyRef.current.length}`, snapshot: summarizeElements(newElements), truncated: willTruncate ? `dropped ${beforeLen - 1 - beforeIdx} redo entries` : false, shifted: shifted ? `MAX_HISTORY exceeded; oldest entry dropped` : false, }); }, [syncUndoRedo]); const flushPendingChanges = useCallback(() => { if (pendingChangesRef.current) { hlog('flushPendingChanges', { reason: 'draining pending debounced updateElement', snapshot: summarizeElements(pendingChangesRef.current), }); saveToHistory(pendingChangesRef.current, 'flushPendingChanges'); pendingChangesRef.current = null; } if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; } }, [saveToHistory]); useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []); const makeId = makeElementId; const addElement = useCallback((element) => { flushPendingChanges(); const newElement = { ...element, id: makeId() }; hlog('addElement', { type: element?.type, newId: newElement.id }); setElements((prev) => { const newElements = [...prev, newElement]; saveToHistory(newElements, 'addElement'); return newElements; }); setSelectedIds(new Set([newElement.id])); return newElement.id; }, [flushPendingChanges, saveToHistory]); // Add many elements as a single history entry. // Used for template loading so an undo collapses the whole template back at once, // and so the elements appear atomically rather than racing each other through // setTimeout-based scheduling. // // ID handling: preserves the input element's `id` if present, generates a // fresh one if not. See replaceElements below for the rationale on why we // preserve rather than regenerate — the short version is that regeneration // breaks saveToHistory's dedupe when StrictMode invokes the path twice. const addElements = useCallback((items) => { if (!items || items.length === 0) return []; flushPendingChanges(); const newOnes = items.map((el, i) => ({ ...el, id: el?.id || makeElementId(i), })); setElements((prev) => { const newElements = [...prev, ...newOnes]; saveToHistory(newElements); return newElements; }); setSelectedIds(new Set()); return newOnes.map((e) => e.id); }, [flushPendingChanges, saveToHistory]); // Replace all elements in one history entry. // // Used for two flows: // 1. Template loading (App.jsx restore effect, the urlTemplateValid / // no-saved-state branch). Template constants ship elements WITHOUT // ids — we generate them here. // 2. Session restoration (App.jsx restore effect, the saved-state // branch). Restored elements come from localStorage with their // original ids intact — we preserve them. // // ID handling: preserves the input element's `id` if present, generates a // fresh one with `makeElementId(i)` if not. // // Why preserve rather than always regenerate // ─────────────────────────────────────────────────── // Pre-May 23 2026 this function unconditionally regenerated all ids via // `makeElementId(i)` (which embeds Date.now()). That made the function // non-idempotent: two calls with the same input produced two outputs with // DIFFERENT ids (different millisecond timestamps). // // The visible consequence: React 18 StrictMode artificially double-mounts // in dev, so the session-restoration effect ran twice, both calls to // replaceElements pushed snapshots, and saveToHistory's dedupe couldn't // catch them because their JSON differed (different ids). Result: two // history entries for the same visual state, so undoing a crop required // two Cmd+Z presses in dev to walk past both restoration entries. // // Preserving input ids when present makes the function idempotent for the // session-restoration path — second call produces the SAME JSON, dedupe // catches it, single history entry. Template loading still generates // fresh ids because template constants don't ship with them; that path // doesn't trip the dedupe regression because templates are loaded once // per URL navigation, not by an effect that StrictMode double-runs. // // The fallback to `makeElementId(i)` rather than throwing on missing id // is deliberate: callers shouldn't need to know whether their inputs // have ids, just hand us elements and let us sort it out. const replaceElements = useCallback((items) => { flushPendingChanges(); const next = (items || []).map((el, i) => ({ ...el, id: el?.id || makeElementId(i), })); hlog('replaceElements', { snapshot: summarizeElements(next) }); setElements(next); saveToHistory(next, 'replaceElements'); setSelectedIds(new Set()); return next.map((e) => e.id); }, [flushPendingChanges, saveToHistory]); const updateElement = useCallback((id, attrs) => { setElements((prev) => { const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el)); pendingChangesRef.current = newElements; if (historyTimerRef.current) clearTimeout(historyTimerRef.current); historyTimerRef.current = setTimeout(() => { flushPendingChanges(); }, DEBOUNCE_DELAY_MS); return newElements; }); // Logged AFTER the setElements call so the timer reset is visible // — a rapid burst of updates collapses into a single history push // when the timer finally fires, and this log helps confirm that. hlog('updateElement (debounced)', { id, attrs: Object.keys(attrs || {}) }); }, [flushPendingChanges]); // Immediate-commit variant of updateElement — applies the change AND // pushes the new state to history within the same React commit, no // debounce. Use this for atomic one-shot mutations where the caller // is certain the change is final (crop apply, photo-edit complete, // etc.) and needs the history snapshot to be available right away. // // Why this exists (the bug that motivated it) // ─────────────────────────────────────────────── // The natural pattern `updateElement(...); commitHistory();` doesn't // work for synchronous immediate-commit, because updateElement uses // a debounced path: it queues a setElements updater that sets // pendingChangesRef + schedules a 300ms timer. React doesn't run // the updater synchronously — it runs it during the next commit // phase — so when commitHistory's flushPendingChanges runs in the // same synchronous block, pendingChangesRef is still null and the // flush is a no-op. The actual history push happens 300ms later // via the timer. // // If the user clicks Undo within that 300ms window, undo() walks // historyIndexRef back to the previous snapshot — which is the // PRE-change state. The post-change snapshot was never written, so // the user appears to lose more state than they undid. // // For the crop-apply case specifically, the user could legitimately // click Apply then immediately Undo to undo an accidental crop. With // the debounce-then-flush path, that produced a confusing "undo wiped // everything" outcome because the post-crop snapshot was never // captured. // // This method follows addElement's pattern (saveToHistory inside the // updater) so the history push happens during React's commit, before // any subsequent user event can fire undo(). const updateAndCommit = useCallback((id, attrs) => { hlog('updateAndCommit', { id, attrs: Object.keys(attrs || {}) }); flushPendingChanges(); setElements((prev) => { const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el)); saveToHistory(newElements, 'updateAndCommit'); return newElements; }); }, [flushPendingChanges, saveToHistory]); const deleteElement = useCallback((id) => { flushPendingChanges(); hlog('deleteElement', { id }); setElements((prev) => { const newElements = prev.filter((el) => el.id !== id); saveToHistory(newElements, 'deleteElement'); return newElements; }); setSelectedIds((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); return next; }); }, [flushPendingChanges, saveToHistory]); // Bulk-delete (S3). Wipes every id in `ids` from the elements array as a // single history entry, and clears them from the selection set. No-op if // `ids` is empty. const deleteMany = useCallback((ids) => { if (!ids || ids.length === 0) return; const idSet = new Set(ids); flushPendingChanges(); hlog('deleteMany', { count: ids.length }); setElements((prev) => { const next = prev.filter((el) => !idSet.has(el.id)); saveToHistory(next, 'deleteMany'); return next; }); setSelectedIds((prev) => { let changed = false; const next = new Set(prev); for (const id of ids) { if (next.delete(id)) changed = true; } return changed ? next : prev; }); }, [flushPendingChanges, saveToHistory]); // Duplicate the given element. The copy lands a few pixels offset so it's // visually distinct from the original, and becomes the new selection so the // user can immediately drag/edit it. // // Both the existence check AND the new-id generation happen synchronously // BEFORE setElements is called. The previous version of this function set a // `didDuplicate` flag inside the setElements updater — but in React 18, // updater functions run asynchronously when the update queue is processed, // not at the call site. So `if (didDuplicate)` observed the stale (false) // value, and selection never flipped to the copy. const duplicateElement = useCallback((id) => { flushPendingChanges(); const sourceIdx = elements.findIndex((e) => e.id === id); if (sourceIdx === -1) return null; const newId = makeId(); hlog('duplicateElement', { sourceId: id, newId }); setElements((prev) => { const idx = prev.findIndex((e) => e.id === id); if (idx === -1) return prev; const original = prev[idx]; const copy = { ...original, id: newId, x: (original.x || 0) + 12, y: (original.y || 0) + 12, // Fresh slot relationship: a duplicated element shouldn't claim the // same template slot as its source — that would double-fill the slot. slotId: undefined, }; // Insert immediately above the source so it visually appears on top. const next = [...prev.slice(0, idx + 1), copy, ...prev.slice(idx + 1)]; saveToHistory(next, 'duplicateElement'); return next; }); setSelectedIds(new Set([newId])); return newId; }, [elements, flushPendingChanges, saveToHistory]); // Z-order helpers. Konva's render order matches array order: elements later // in the array paint on top. "Bring forward" moves the element one slot // higher in the array; "send backward" moves it one slot lower. const bringForward = useCallback((id) => { flushPendingChanges(); setElements((prev) => { const idx = prev.findIndex((e) => e.id === id); if (idx === -1 || idx === prev.length - 1) return prev; const next = prev.slice(); [next[idx], next[idx + 1]] = [next[idx + 1], next[idx]]; saveToHistory(next, 'bringForward'); return next; }); }, [flushPendingChanges, saveToHistory]); const sendBackward = useCallback((id) => { flushPendingChanges(); setElements((prev) => { const idx = prev.findIndex((e) => e.id === id); if (idx <= 0) return prev; const next = prev.slice(); [next[idx], next[idx - 1]] = [next[idx - 1], next[idx]]; saveToHistory(next, 'sendBackward'); return next; }); }, [flushPendingChanges, saveToHistory]); // Drag-reorder (S3). Moves the source element to immediately before or // after the target in the elements array, producing a single history // entry. The LayersPanel renders top-down (last-in-array on top), so // "before" in the panel UI means "after" in the array, and vice-versa // — the caller is responsible for that translation. We just take the // semantic position and apply it directly. const reorderElement = useCallback((sourceId, targetId, position = 'after') => { if (sourceId === targetId) return; flushPendingChanges(); setElements((prev) => { const sourceIdx = prev.findIndex((e) => e.id === sourceId); const targetIdx = prev.findIndex((e) => e.id === targetId); if (sourceIdx === -1 || targetIdx === -1) return prev; const next = prev.slice(); const [source] = next.splice(sourceIdx, 1); // After the splice, the target's index may have shifted by one if // it was after the source's original position. Re-find by id. const newTargetIdx = next.findIndex((e) => e.id === targetId); if (newTargetIdx === -1) return prev; const insertAt = position === 'before' ? newTargetIdx : newTargetIdx + 1; next.splice(insertAt, 0, source); saveToHistory(next, 'reorderElement'); return next; }); }, [flushPendingChanges, saveToHistory]); // Selection setters. `selectElement` and `deselectAll` keep the same // single-select semantics as before. `toggleInSelection` is the // multi-select primitive: toggles the id's membership in the set. // `setSelection` replaces the entire set with a fresh one built from // the provided id array — used by canvas marquee drag and the Cmd+A // "select all" keyboard shortcut to commit a many-id selection in a // single state update (vs. clearing + toggling N times, which would // produce N+1 renders and could flicker the Transformer's bound nodes // mid-update). const selectElement = useCallback((id) => { setSelectedIds(id ? new Set([id]) : new Set()); }, []); const deselectAll = useCallback(() => setSelectedIds(new Set()), []); const toggleInSelection = useCallback((id) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const setSelection = useCallback((ids) => { setSelectedIds(new Set(ids || [])); }, []); const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]); // Undo / redo restore the elements snapshot at the new history index AND // try to preserve selection. The previous version unconditionally cleared // selection, which felt punishing — undo a property tweak and you'd lose // your selection too, so refining further required reselecting first. // // We preserve any ids in the current selection that still exist in the // restored snapshot. Selections of already-deleted elements naturally // drop out. const undo = useCallback(() => { if (historyIndexRef.current > 0) { const beforeIdx = historyIndexRef.current; historyIndexRef.current--; const next = JSON.parse(historyRef.current[historyIndexRef.current]); hlog('undo', { idx: `${beforeIdx} → ${historyIndexRef.current}`, len: historyRef.current.length, restored: summarizeElements(next), }); setElements(next); setSelectedIds((prev) => { const liveIds = new Set(next.map((e) => e.id)); const filtered = new Set(); for (const id of prev) if (liveIds.has(id)) filtered.add(id); return filtered.size === prev.size ? prev : filtered; }); syncUndoRedo(); } else { hlog('undo NO-OP', { idx: historyIndexRef.current, len: historyRef.current.length, reason: 'already at history floor', }); } }, [syncUndoRedo]); const redo = useCallback(() => { if (historyIndexRef.current < historyRef.current.length - 1) { const beforeIdx = historyIndexRef.current; historyIndexRef.current++; const next = JSON.parse(historyRef.current[historyIndexRef.current]); hlog('redo', { idx: `${beforeIdx} → ${historyIndexRef.current}`, len: historyRef.current.length, restored: summarizeElements(next), }); setElements(next); setSelectedIds((prev) => { const liveIds = new Set(next.map((e) => e.id)); const filtered = new Set(); for (const id of prev) if (liveIds.has(id)) filtered.add(id); return filtered.size === prev.size ? prev : filtered; }); syncUndoRedo(); } else { hlog('redo NO-OP', { idx: historyIndexRef.current, len: historyRef.current.length, reason: 'no redo entries available', }); } }, [syncUndoRedo]); // Seed an initial empty-canvas entry as the history floor IF AND ONLY // IF history is empty. When session restoration has already pushed // snapshots via replaceElements (URL template, saved localStorage state, // etc.), those entries ARE the correct floor and must not be touched. // // History of this function // ──────────────────────────────────────────────────────────────────────────────────── // Previously this function UNCONDITIONALLY set history to // `[JSON.stringify([])]`, regardless of what was already there. That was // the root cause of the "crop + Cmd+Z = blank canvas" bug for any user // with a restored session: // // 1. App.jsx's persistence effect calls replaceElements(saved.elements) // — saveToHistory pushes the restored 5-element snapshot at idx 0. // 2. React 18 StrictMode artificially unmounts and remounts the // component in development. The persistence effect re-runs. // replaceElements regenerates element IDs (makeElementId uses fresh // timestamps), so the new snapshot's JSON differs from the first // one and saveToHistory's dedupe doesn't catch it. Second push at // idx 1. // 3. App.jsx's setRestorationComplete(true) effect fires once, after // both mounts have settled. initializeHistory ran here and WIPED // both snapshots, leaving history = [[]] at idx 0 — even though // `elements` was still showing the 5 restored items. // 4. User crops a photo. updateAndCommit's saveToHistory pushes the // cropped state at idx 1. History = [[], [cropped]], idx = 1. // 5. User presses Cmd+Z. undo walks idx 1 → 0, sets elements = [], // blanking the canvas. // // The fix is the guard below: skip the seed when history already has // entries. That keeps the restored snapshot(s) intact as the undo floor. // // The fresh-session case (no URL template, no saved state) still works: // history is empty when this fires, so we push the [] floor. The first // user action then sits at idx 1, and Cmd+Z takes the user back to the // empty starting canvas — the expected behavior. // // The StrictMode duplicate snapshot (idx 0 = old-ID set, idx 1 = new-ID // set in the example above) is a separate pre-existing oddity: in dev, // the user's history will contain TWO entries representing the same // visual state with different element IDs. Their first Cmd+Z lands on // the still-uncropped state (visually correct — different IDs but the // same picture), a second Cmd+Z lands on the OTHER uncropped state // (visually identical), and a third is a no-op at the floor. Annoying // in dev but harmless; in production builds StrictMode's double-mount // doesn't run, so there's only one restored snapshot and one Cmd+Z to // undo the crop. Fixing the dev-only duplicate would require making // replaceElements idempotent (deterministic IDs across calls) and is // out of scope for this fix. const initializeHistory = useCallback(() => { const beforeIdx = historyIndexRef.current; const beforeLen = historyRef.current.length; if (historyRef.current.length === 0) { historyRef.current = [JSON.stringify([])]; historyIndexRef.current = 0; hlog('initializeHistory SEED', { reason: 'history was empty; seeding [] as the undo floor', newState: 'history = [[]] (single empty entry), idx = 0', }); } else { hlog('initializeHistory NO-OP', { reason: 'history already has entries; restored snapshots preserved', idx: beforeIdx, len: beforeLen, currentEntry: beforeIdx >= 0 ? summarizeElements(JSON.parse(historyRef.current[beforeIdx])) : '', }); } syncUndoRedo(); }, [syncUndoRedo]); return { elements, selectedId, selectedIds, addElement, addElements, replaceElements, updateElement, updateAndCommit, deleteElement, deleteMany, duplicateElement, bringForward, sendBackward, reorderElement, selectElement, deselectAll, toggleInSelection, setSelection, commitHistory, undo, redo, canUndo, canRedo, initializeHistory, }; }