593 lines
27 KiB
JavaScript
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,
|
|
};
|
|
}
|