Files
apparel-designer/src/hooks/useDesignEditor.js
2026-05-23 03:28:58 -05:00

593 lines
27 KiB
JavaScript

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 '<not-array>';
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]))
: '<none>',
});
}
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,
};
}