Major update for v1 and tests
This commit is contained in:
@@ -1,11 +1,76 @@
|
||||
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([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
// 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([]);
|
||||
@@ -18,31 +83,158 @@ export function useDesignEditor() {
|
||||
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
|
||||
}, []);
|
||||
|
||||
const saveToHistory = useCallback((newElements) => {
|
||||
if (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.stringify(newElements));
|
||||
if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); }
|
||||
else { historyIndexRef.current++; }
|
||||
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) { saveToHistory(pendingChangesRef.current); pendingChangesRef.current = null; }
|
||||
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: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||
setElements((prev) => { const newElements = [...prev, newElement]; saveToHistory(newElements); return newElements; });
|
||||
setSelectedId(newElement.id);
|
||||
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));
|
||||
@@ -51,41 +243,350 @@ export function useDesignEditor() {
|
||||
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();
|
||||
setElements((prev) => { const newElements = prev.filter((el) => el.id !== id); saveToHistory(newElements); return newElements; });
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
}, [selectedId, flushPendingChanges, saveToHistory]);
|
||||
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]);
|
||||
|
||||
const selectElement = useCallback((id) => setSelectedId(id), []);
|
||||
const deselectAll = useCallback(() => setSelectedId(null), []);
|
||||
// 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--;
|
||||
setElements(JSON.parse(historyRef.current[historyIndexRef.current]));
|
||||
setSelectedId(null);
|
||||
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++;
|
||||
setElements(JSON.parse(historyRef.current[historyIndexRef.current]));
|
||||
setSelectedId(null);
|
||||
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(() => {
|
||||
historyRef.current = [JSON.stringify([])];
|
||||
historyIndexRef.current = 0;
|
||||
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, addElement, updateElement, deleteElement, selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user