@10x-media/undo-redo 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +54 -0
  4. package/dist/client/HistoryDebugOverlay.d.ts +30 -0
  5. package/dist/client/HistoryDebugOverlay.js +201 -0
  6. package/dist/client/HistoryDebugOverlay.js.map +1 -0
  7. package/dist/client/UndoRedoControls.d.ts +18 -0
  8. package/dist/client/UndoRedoControls.js +155 -0
  9. package/dist/client/UndoRedoControls.js.map +1 -0
  10. package/dist/client/formatShortcut.js +113 -0
  11. package/dist/client/formatShortcut.js.map +1 -0
  12. package/dist/client/historyDebugOverlay.css +206 -0
  13. package/dist/client/undoRedoControls.css +58 -0
  14. package/dist/client/useUndoRedo.d.ts +70 -0
  15. package/dist/client/useUndoRedo.js +259 -0
  16. package/dist/client/useUndoRedo.js.map +1 -0
  17. package/dist/exports/client.d.ts +4 -0
  18. package/dist/exports/client.js +5 -0
  19. package/dist/exports/i18n.d.ts +3 -0
  20. package/dist/exports/i18n.js +3 -0
  21. package/dist/exports/types.d.ts +5 -0
  22. package/dist/exports/types.js +1 -0
  23. package/dist/history/historyCore.d.ts +85 -0
  24. package/dist/history/historyCore.js +318 -0
  25. package/dist/history/historyCore.js.map +1 -0
  26. package/dist/history/pathPatterns.d.ts +6 -0
  27. package/dist/history/pathPatterns.js +45 -0
  28. package/dist/history/pathPatterns.js.map +1 -0
  29. package/dist/history/volatileValues.js +51 -0
  30. package/dist/history/volatileValues.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.js +28 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/plugin/options.d.ts +109 -0
  35. package/dist/plugin/options.js +52 -0
  36. package/dist/plugin/options.js.map +1 -0
  37. package/dist/plugin/registerTranslations.js +19 -0
  38. package/dist/plugin/registerTranslations.js.map +1 -0
  39. package/dist/plugin/withUndoRedo.d.ts +34 -0
  40. package/dist/plugin/withUndoRedo.js +72 -0
  41. package/dist/plugin/withUndoRedo.js.map +1 -0
  42. package/dist/schema/fieldConfig.d.ts +36 -0
  43. package/dist/schema/fieldConfig.js +28 -0
  44. package/dist/schema/fieldConfig.js.map +1 -0
  45. package/dist/schema/fieldSchema.d.ts +63 -0
  46. package/dist/schema/fieldSchema.js +122 -0
  47. package/dist/schema/fieldSchema.js.map +1 -0
  48. package/dist/translations/de.js +19 -0
  49. package/dist/translations/de.js.map +1 -0
  50. package/dist/translations/en.js +23 -0
  51. package/dist/translations/en.js.map +1 -0
  52. package/dist/translations/index.d.ts +15 -0
  53. package/dist/translations/index.js +30 -0
  54. package/dist/translations/index.js.map +1 -0
  55. package/dist/translations/keys.d.ts +22 -0
  56. package/dist/translations/keys.js +22 -0
  57. package/dist/translations/keys.js.map +1 -0
  58. package/dist/translations/useTranslation.js +12 -0
  59. package/dist/translations/useTranslation.js.map +1 -0
  60. package/package.json +109 -0
@@ -0,0 +1,259 @@
1
+ "use client";
2
+ import { createPathMatcher } from "../history/pathPatterns.js";
3
+ import { buildRestoreState, canRedo, canUndo, createHistory, isAtSavedState, markSaved, pushSnapshot } from "../history/historyCore.js";
4
+ import { DEFAULT_SHORTCUTS } from "../plugin/options.js";
5
+ import { buildFieldSchemaMap, collectIgnorePatterns } from "../schema/fieldSchema.js";
6
+ import { createVolatileMatcher } from "../history/volatileValues.js";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { useAllFormFields, useConfig, useDocumentInfo, useForm, useFormInitializing, useFormModified } from "@payloadcms/ui";
9
+ import { useHotkeys } from "react-hotkeys-hook";
10
+ //#region src/client/useUndoRedo.ts
11
+ /** Shared so a document without a resolvable schema keeps a stable identity. */
12
+ const EMPTY_SCHEMA = /* @__PURE__ */ new Map();
13
+ /**
14
+ * Undo/redo for the surrounding Payload form: a client-side history of
15
+ * form-state snapshots, independent of Payload's document versions, with
16
+ * nothing read from or written to the server until the user saves.
17
+ *
18
+ * Backs `<UndoRedoControls />` and is exported so a host can build its own
19
+ * controls instead (pair it with `autoMount: false`). Must be called from
20
+ * inside a document edit form; outside one it finds no fields and stays
21
+ * disabled.
22
+ *
23
+ * One history per call. Two instances on the same form keep two independent
24
+ * stacks and each captures the other's restores as fresh edits, so mount either
25
+ * ours or yours, not both.
26
+ */
27
+ const useUndoRedo = ({ captureDebounce = 400, debug = false, ignoreFieldTypes, ignorePaths, maxHistory = 50, shortcuts } = {}) => {
28
+ const [fields, dispatchFields] = useAllFormFields();
29
+ const { formRef, setModified } = useForm();
30
+ const initializing = useFormInitializing();
31
+ const modified = useFormModified();
32
+ const { getEntityConfig, config } = useConfig();
33
+ const { collectionSlug, globalSlug } = useDocumentInfo();
34
+ /**
35
+ * Ignore patterns from the config, plus the ones derived from the document's
36
+ * own schema: fields the host opted out through `admin.custom`, and every
37
+ * field of an excluded type. The schema is the only place a mounted
38
+ * component can learn field types, since form state omits them unless
39
+ * `buildFormState` was called with `includeSchema`, which the edit view never
40
+ * does.
41
+ */
42
+ const entity = useMemo(() => collectionSlug ? getEntityConfig({ collectionSlug }) : globalSlug ? getEntityConfig({ globalSlug }) : null, [
43
+ collectionSlug,
44
+ getEntityConfig,
45
+ globalSlug
46
+ ]);
47
+ /**
48
+ * Autosave persists every edit continuously, so "differs from what is
49
+ * persisted" is not a state the editor can meaningfully be in: the baseline
50
+ * would move on every autosave, and an undo would immediately trigger one and
51
+ * move it again. Tracking it there produces a saved marker that chases the
52
+ * user around instead of telling them anything, so the whole baseline
53
+ * mechanism is skipped and restores simply report the form as modified.
54
+ */
55
+ const tracksSavedState = useMemo(() => {
56
+ const drafts = entity?.versions?.drafts;
57
+ return !(typeof drafts === "object" && drafts !== null ? drafts.autosave : false);
58
+ }, [entity]);
59
+ const schema = useMemo(() => entity ? buildFieldSchemaMap(entity.fields, { blocksMap: config.blocksMap }) : EMPTY_SCHEMA, [config.blocksMap, entity]);
60
+ const isIgnored = useMemo(() => createPathMatcher([...ignorePaths ?? [], ...collectIgnorePatterns(schema, ignoreFieldTypes)]), [
61
+ ignoreFieldTypes,
62
+ ignorePaths,
63
+ schema
64
+ ]);
65
+ const isVolatile = useMemo(() => createVolatileMatcher(schema), [schema]);
66
+ const historyRef = useRef(null);
67
+ if (historyRef.current === null) historyRef.current = createHistory({
68
+ isIgnored,
69
+ isVolatile,
70
+ maxHistory
71
+ });
72
+ const history = historyRef.current;
73
+ history.options = {
74
+ isIgnored,
75
+ isVolatile,
76
+ maxHistory
77
+ };
78
+ const fieldsRef = useRef(null);
79
+ const [flags, setFlags] = useState({
80
+ redo: false,
81
+ undo: false
82
+ });
83
+ /**
84
+ * Forces consumers to re-read the mutable history object, which is a ref and
85
+ * therefore invisible to React. Only bumped under `debug`, so the default
86
+ * build keeps re-rendering purely on the undo/redo flags.
87
+ */
88
+ const [revision, setRevision] = useState(0);
89
+ fieldsRef.current = fields;
90
+ const bumpRevision = useCallback(() => {
91
+ if (debug) setRevision((n) => n + 1);
92
+ }, [debug]);
93
+ const refreshFlags = useCallback(() => {
94
+ const next = {
95
+ redo: canRedo(history),
96
+ undo: canUndo(history)
97
+ };
98
+ setFlags((prev) => prev.undo === next.undo && prev.redo === next.redo ? prev : next);
99
+ }, [history]);
100
+ useEffect(() => {
101
+ const win = window;
102
+ win.__payloadUndoHistory = history;
103
+ return () => {
104
+ delete win.__payloadUndoHistory;
105
+ };
106
+ }, [history]);
107
+ useEffect(() => {
108
+ if (initializing || !fields || Object.keys(fields).length === 0) return;
109
+ const timer = setTimeout(() => {
110
+ const latest = fieldsRef.current;
111
+ if (!latest) return;
112
+ if (pushSnapshot(history, latest)) bumpRevision();
113
+ refreshFlags();
114
+ }, captureDebounce);
115
+ return () => clearTimeout(timer);
116
+ }, [
117
+ captureDebounce,
118
+ fields,
119
+ history,
120
+ initializing,
121
+ refreshFlags,
122
+ bumpRevision
123
+ ]);
124
+ /**
125
+ * Set when this instance is the one clearing `modified`, which happens when a
126
+ * restore lands back on the saved state.
127
+ *
128
+ * The baseline is already correct in that case, by definition: the restore
129
+ * only reports clean because the entry it landed on matches the baseline.
130
+ * Re-recording it there reads live form state while the server merge that
131
+ * follows REPLACE_STATE is still in flight, so the baseline lands on a state
132
+ * that matches no entry, and moves again once the merge settles.
133
+ */
134
+ const clearedByRestoreRef = useRef(false);
135
+ /**
136
+ * Re-baseline on every point Payload itself considers the form clean: a
137
+ * successful save or autosave, a reset, and the initial load. Reading
138
+ * Payload's own flag rather than watching for a save keeps the baseline
139
+ * correct for all of them without re-deriving what "saved" means.
140
+ *
141
+ * This records a baseline and deliberately does not touch the stack. Pushing
142
+ * here would run the redo-tail truncation in pushSnapshot, and since redoing
143
+ * onto the saved state makes the form clean, it would destroy the very branch
144
+ * redo had just stepped into.
145
+ */
146
+ useEffect(() => {
147
+ if (!tracksSavedState) return;
148
+ if (modified) {
149
+ clearedByRestoreRef.current = false;
150
+ return;
151
+ }
152
+ if (initializing || !fieldsRef.current) return;
153
+ if (clearedByRestoreRef.current) {
154
+ clearedByRestoreRef.current = false;
155
+ return;
156
+ }
157
+ markSaved(history, fieldsRef.current);
158
+ bumpRevision();
159
+ }, [
160
+ bumpRevision,
161
+ history,
162
+ initializing,
163
+ modified,
164
+ tracksSavedState
165
+ ]);
166
+ /**
167
+ * Restore the entry that `resolveTarget` picks. The target is resolved from
168
+ * the index *after* capturing pending edits, not before: capturing can move
169
+ * the index, and a relative step must be relative to where the user is.
170
+ */
171
+ const applyRestore = useCallback((resolveTarget) => {
172
+ const current = fieldsRef.current;
173
+ if (!current) return;
174
+ pushSnapshot(history, current);
175
+ const target = resolveTarget(history.index);
176
+ const entry = history.stack[target];
177
+ if (!entry) {
178
+ refreshFlags();
179
+ bumpRevision();
180
+ return;
181
+ }
182
+ dispatchFields({
183
+ type: "REPLACE_STATE",
184
+ state: buildRestoreState(entry, current, isIgnored),
185
+ optimize: false
186
+ });
187
+ history.index = target;
188
+ const clean = tracksSavedState && isAtSavedState(history);
189
+ clearedByRestoreRef.current = clean;
190
+ setModified(!clean);
191
+ refreshFlags();
192
+ bumpRevision();
193
+ }, [
194
+ bumpRevision,
195
+ dispatchFields,
196
+ history,
197
+ isIgnored,
198
+ refreshFlags,
199
+ setModified,
200
+ tracksSavedState
201
+ ]);
202
+ const restore = useCallback((direction) => applyRestore((index) => index + direction), [applyRestore]);
203
+ const undo = useCallback(() => restore(-1), [restore]);
204
+ const redo = useCallback(() => restore(1), [restore]);
205
+ const jumpTo = useCallback((index) => applyRestore(() => index), [applyRestore]);
206
+ /**
207
+ * Several edit forms can be mounted at once (document drawers). Only the
208
+ * instance belonging to the form the event happened in reacts; for events
209
+ * outside any form, the topmost open drawer wins.
210
+ *
211
+ * The form element comes from Payload's own form context rather than from a
212
+ * DOM ref of our own, so the rule holds wherever a host renders its controls,
213
+ * including outside the element it is scoping.
214
+ *
215
+ * Text-editing surfaces need no check here: react-hotkeys-hook skips form
216
+ * tags and contenteditable by default, which is exactly the rule we want,
217
+ * since native inputs own the browser's text undo and Lexical owns its own.
218
+ */
219
+ const isForAnotherForm = useCallback((e) => {
220
+ const ourForm = formRef?.current;
221
+ const target = e.target instanceof HTMLElement ? e.target : null;
222
+ if (target && ourForm?.contains(target)) return false;
223
+ const targetForm = target?.closest("form");
224
+ if (targetForm) return targetForm !== ourForm;
225
+ const openDrawers = document.querySelectorAll(".drawer--is-open");
226
+ const topDrawer = openDrawers[openDrawers.length - 1];
227
+ return Boolean(topDrawer && ourForm && !topDrawer.contains(ourForm));
228
+ }, [formRef]);
229
+ const chords = useMemo(() => {
230
+ if (shortcuts === false) return null;
231
+ return {
232
+ redo: shortcuts?.redo ?? [...DEFAULT_SHORTCUTS.redo],
233
+ undo: shortcuts?.undo ?? [...DEFAULT_SHORTCUTS.undo]
234
+ };
235
+ }, [shortcuts]);
236
+ const hotkeyOptions = useMemo(() => ({
237
+ enabled: chords !== null,
238
+ ignoreEventWhen: isForAnotherForm,
239
+ preventDefault: true
240
+ }), [chords, isForAnotherForm]);
241
+ useHotkeys(chords?.undo ?? [], undo, hotkeyOptions, [undo]);
242
+ useHotkeys(chords?.redo ?? [], redo, hotkeyOptions, [redo]);
243
+ return {
244
+ canRedo: flags.redo,
245
+ canUndo: flags.undo,
246
+ chords,
247
+ fields,
248
+ history,
249
+ jumpTo,
250
+ redo,
251
+ revision,
252
+ tracksSavedState,
253
+ undo
254
+ };
255
+ };
256
+ //#endregion
257
+ export { useUndoRedo };
258
+
259
+ //# sourceMappingURL=useUndoRedo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useUndoRedo.js","names":[],"sources":["../../src/client/useUndoRedo.ts"],"sourcesContent":["'use client'\nimport {\n\tuseAllFormFields,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseForm,\n\tuseFormInitializing,\n\tuseFormModified,\n} from '@payloadcms/ui'\nimport type { FormState } from 'payload'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\n\nimport {\n\tbuildRestoreState,\n\tcanRedo,\n\tcanUndo,\n\tcreateHistory,\n\tisAtSavedState,\n\tMAX_HISTORY_ENTRIES,\n\tmarkSaved,\n\tpushSnapshot,\n\ttype UndoHistory,\n} from '../history/historyCore'\nimport { createPathMatcher } from '../history/pathPatterns'\nimport { createVolatileMatcher } from '../history/volatileValues'\nimport {\n\ttype ControlsClientProps,\n\tDEFAULT_CAPTURE_DEBOUNCE_MS,\n\tDEFAULT_SHORTCUTS,\n} from '../plugin/options'\nimport {\n\tbuildFieldSchemaMap,\n\tcollectIgnorePatterns,\n\ttype FieldSchemaMap,\n} from '../schema/fieldSchema'\n\n/** Shared so a document without a resolvable schema keeps a stable identity. */\nconst EMPTY_SCHEMA: FieldSchemaMap = new Map()\n\n/**\n * Every setting is optional so the hook stays usable when a host calls it by\n * hand rather than through the plugin, which always passes a fully resolved set.\n */\nexport type UseUndoRedoOptions = Partial<ControlsClientProps>\n\n/** The chords actually bound, or null when keyboard handling is off. */\nexport interface BoundChords {\n\tundo: string[]\n\tredo: string[]\n}\n\nexport interface UseUndoRedoResult {\n\t/** True when there is an earlier entry to step back to. */\n\tcanUndo: boolean\n\t/** True when there is a later entry to step forward to. */\n\tcanRedo: boolean\n\tundo: () => void\n\tredo: () => void\n\t/** Restore the entry at `index` in `history.stack`. Out-of-range indexes no-op. */\n\tjumpTo: (index: number) => void\n\t/** Live form state, as `useAllFormFields` reports it. */\n\tfields: FormState\n\t/**\n\t * The mutable history object backing this instance.\n\t *\n\t * Exposed so a custom control can mount `<HistoryDebugOverlay />` or inspect\n\t * the stack. It is an internal structure, not a stable contract: treat it as\n\t * read-only and expect its shape to change in a minor release.\n\t */\n\thistory: UndoHistory\n\t/**\n\t * Counter bumped on every capture and restore. Only moves when `debug` is on,\n\t * since it exists to pull the mutable history into React's render cycle for\n\t * the inspector and nothing else needs the re-renders.\n\t */\n\trevision: number\n\t/** False under autosave, where the saved baseline is deliberately not tracked. */\n\ttracksSavedState: boolean\n\t/** Resolved keyboard chords, for labelling. Null when `shortcuts` is false. */\n\tchords: BoundChords | null\n}\n\n/**\n * Undo/redo for the surrounding Payload form: a client-side history of\n * form-state snapshots, independent of Payload's document versions, with\n * nothing read from or written to the server until the user saves.\n *\n * Backs `<UndoRedoControls />` and is exported so a host can build its own\n * controls instead (pair it with `autoMount: false`). Must be called from\n * inside a document edit form; outside one it finds no fields and stays\n * disabled.\n *\n * One history per call. Two instances on the same form keep two independent\n * stacks and each captures the other's restores as fresh edits, so mount either\n * ours or yours, not both.\n */\nexport const useUndoRedo = ({\n\tcaptureDebounce = DEFAULT_CAPTURE_DEBOUNCE_MS,\n\tdebug = false,\n\tignoreFieldTypes,\n\tignorePaths,\n\tmaxHistory = MAX_HISTORY_ENTRIES,\n\tshortcuts,\n}: UseUndoRedoOptions = {}): UseUndoRedoResult => {\n\tconst [fields, dispatchFields] = useAllFormFields()\n\tconst { formRef, setModified } = useForm()\n\tconst initializing = useFormInitializing()\n\tconst modified = useFormModified()\n\tconst { getEntityConfig, config } = useConfig()\n\tconst { collectionSlug, globalSlug } = useDocumentInfo()\n\n\t/**\n\t * Ignore patterns from the config, plus the ones derived from the document's\n\t * own schema: fields the host opted out through `admin.custom`, and every\n\t * field of an excluded type. The schema is the only place a mounted\n\t * component can learn field types, since form state omits them unless\n\t * `buildFormState` was called with `includeSchema`, which the edit view never\n\t * does.\n\t */\n\tconst entity = useMemo(\n\t\t() =>\n\t\t\tcollectionSlug\n\t\t\t\t? getEntityConfig({ collectionSlug })\n\t\t\t\t: globalSlug\n\t\t\t\t\t? getEntityConfig({ globalSlug })\n\t\t\t\t\t: null,\n\t\t[collectionSlug, getEntityConfig, globalSlug]\n\t)\n\n\t/**\n\t * Autosave persists every edit continuously, so \"differs from what is\n\t * persisted\" is not a state the editor can meaningfully be in: the baseline\n\t * would move on every autosave, and an undo would immediately trigger one and\n\t * move it again. Tracking it there produces a saved marker that chases the\n\t * user around instead of telling them anything, so the whole baseline\n\t * mechanism is skipped and restores simply report the form as modified.\n\t */\n\tconst tracksSavedState = useMemo(() => {\n\t\tconst drafts = entity?.versions?.drafts\n\t\tconst autosave = typeof drafts === 'object' && drafts !== null ? drafts.autosave : false\n\t\treturn !autosave\n\t}, [entity])\n\n\tconst schema = useMemo(\n\t\t() =>\n\t\t\tentity ? buildFieldSchemaMap(entity.fields, { blocksMap: config.blocksMap }) : EMPTY_SCHEMA,\n\t\t[config.blocksMap, entity]\n\t)\n\n\tconst isIgnored = useMemo(\n\t\t() =>\n\t\t\tcreatePathMatcher([\n\t\t\t\t...(ignorePaths ?? []),\n\t\t\t\t...collectIgnorePatterns(schema, ignoreFieldTypes),\n\t\t\t]),\n\t\t[ignoreFieldTypes, ignorePaths, schema]\n\t)\n\n\tconst isVolatile = useMemo(() => createVolatileMatcher(schema), [schema])\n\n\tconst historyRef = useRef<UndoHistory | null>(null)\n\tif (historyRef.current === null) {\n\t\thistoryRef.current = createHistory({ isIgnored, isVolatile, maxHistory })\n\t}\n\tconst history = historyRef.current\n\t// Kept in sync rather than rebuilt, so changing a setting cannot silently\n\t// drop the entries the editor has already accumulated. Assigned during render\n\t// on purpose: an effect would leave a restore triggered between render and\n\t// commit reading the settings the user has just replaced.\n\thistory.options = { isIgnored, isVolatile, maxHistory }\n\n\tconst fieldsRef = useRef<FormState | null>(null)\n\tconst [flags, setFlags] = useState({ redo: false, undo: false })\n\t/**\n\t * Forces consumers to re-read the mutable history object, which is a ref and\n\t * therefore invisible to React. Only bumped under `debug`, so the default\n\t * build keeps re-rendering purely on the undo/redo flags.\n\t */\n\tconst [revision, setRevision] = useState(0)\n\n\tfieldsRef.current = fields\n\n\tconst bumpRevision = useCallback(() => {\n\t\tif (debug) setRevision((n) => n + 1)\n\t}, [debug])\n\n\tconst refreshFlags = useCallback(() => {\n\t\tconst next = { redo: canRedo(history), undo: canUndo(history) }\n\t\tsetFlags((prev) => (prev.undo === next.undo && prev.redo === next.redo ? prev : next))\n\t}, [history])\n\n\t// Debug/e2e handle: lets tests inspect the history without reaching into React.\n\tuseEffect(() => {\n\t\tconst win = window as Window & { __payloadUndoHistory?: UndoHistory }\n\t\twin.__payloadUndoHistory = history\n\t\treturn () => {\n\t\t\tdelete win.__payloadUndoHistory\n\t\t}\n\t}, [history])\n\n\tuseEffect(() => {\n\t\tif (initializing || !fields || Object.keys(fields).length === 0) return\n\t\tconst timer = setTimeout(() => {\n\t\t\tconst latest = fieldsRef.current\n\t\t\tif (!latest) return\n\t\t\tif (pushSnapshot(history, latest)) bumpRevision()\n\t\t\trefreshFlags()\n\t\t}, captureDebounce)\n\t\treturn () => clearTimeout(timer)\n\t}, [captureDebounce, fields, history, initializing, refreshFlags, bumpRevision])\n\n\t/**\n\t * Set when this instance is the one clearing `modified`, which happens when a\n\t * restore lands back on the saved state.\n\t *\n\t * The baseline is already correct in that case, by definition: the restore\n\t * only reports clean because the entry it landed on matches the baseline.\n\t * Re-recording it there reads live form state while the server merge that\n\t * follows REPLACE_STATE is still in flight, so the baseline lands on a state\n\t * that matches no entry, and moves again once the merge settles.\n\t */\n\tconst clearedByRestoreRef = useRef(false)\n\n\t/**\n\t * Re-baseline on every point Payload itself considers the form clean: a\n\t * successful save or autosave, a reset, and the initial load. Reading\n\t * Payload's own flag rather than watching for a save keeps the baseline\n\t * correct for all of them without re-deriving what \"saved\" means.\n\t *\n\t * This records a baseline and deliberately does not touch the stack. Pushing\n\t * here would run the redo-tail truncation in pushSnapshot, and since redoing\n\t * onto the saved state makes the form clean, it would destroy the very branch\n\t * redo had just stepped into.\n\t */\n\tuseEffect(() => {\n\t\tif (!tracksSavedState) return\n\t\tif (modified) {\n\t\t\tclearedByRestoreRef.current = false\n\t\t\treturn\n\t\t}\n\t\tif (initializing || !fieldsRef.current) return\n\t\tif (clearedByRestoreRef.current) {\n\t\t\tclearedByRestoreRef.current = false\n\t\t\treturn\n\t\t}\n\t\tmarkSaved(history, fieldsRef.current)\n\t\tbumpRevision()\n\t}, [bumpRevision, history, initializing, modified, tracksSavedState])\n\n\t/**\n\t * Restore the entry that `resolveTarget` picks. The target is resolved from\n\t * the index *after* capturing pending edits, not before: capturing can move\n\t * the index, and a relative step must be relative to where the user is.\n\t */\n\tconst applyRestore = useCallback(\n\t\t(resolveTarget: (indexAfterCapture: number) => number) => {\n\t\t\tconst current = fieldsRef.current\n\t\t\tif (!current) return\n\t\t\t// Capture pending (not yet debounced) edits first so undo steps back\n\t\t\t// from what the user actually sees, not from the last capture. Echoes\n\t\t\t// of a previous restore dedupe against the current entry and no-op.\n\t\t\tpushSnapshot(history, current)\n\t\t\tconst target = resolveTarget(history.index)\n\t\t\tconst entry = history.stack[target]\n\t\t\tif (!entry) {\n\t\t\t\trefreshFlags()\n\t\t\t\tbumpRevision()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdispatchFields({\n\t\t\t\ttype: 'REPLACE_STATE',\n\t\t\t\tstate: buildRestoreState(entry, current, isIgnored),\n\t\t\t\toptimize: false,\n\t\t\t})\n\t\t\thistory.index = target\n\t\t\t// Modified drives the \"leave without saving\" prompt and the save\n\t\t\t// button, so it has to answer \"does the form differ from what is\n\t\t\t// persisted\", not \"did something just happen\". Landing back on the\n\t\t\t// saved entry is a return to clean; anywhere else is a real change,\n\t\t\t// and marking it also makes the debounced onChange revalidate the\n\t\t\t// restored state on the server so save and autosave pick it up.\n\t\t\tconst clean = tracksSavedState && isAtSavedState(history)\n\t\t\tclearedByRestoreRef.current = clean\n\t\t\tsetModified(!clean)\n\t\t\trefreshFlags()\n\t\t\tbumpRevision()\n\t\t},\n\t\t[bumpRevision, dispatchFields, history, isIgnored, refreshFlags, setModified, tracksSavedState]\n\t)\n\n\tconst restore = useCallback(\n\t\t(direction: -1 | 1) => applyRestore((index) => index + direction),\n\t\t[applyRestore]\n\t)\n\n\tconst undo = useCallback(() => restore(-1), [restore])\n\tconst redo = useCallback(() => restore(1), [restore])\n\tconst jumpTo = useCallback((index: number) => applyRestore(() => index), [applyRestore])\n\n\t/**\n\t * Several edit forms can be mounted at once (document drawers). Only the\n\t * instance belonging to the form the event happened in reacts; for events\n\t * outside any form, the topmost open drawer wins.\n\t *\n\t * The form element comes from Payload's own form context rather than from a\n\t * DOM ref of our own, so the rule holds wherever a host renders its controls,\n\t * including outside the element it is scoping.\n\t *\n\t * Text-editing surfaces need no check here: react-hotkeys-hook skips form\n\t * tags and contenteditable by default, which is exactly the rule we want,\n\t * since native inputs own the browser's text undo and Lexical owns its own.\n\t */\n\tconst isForAnotherForm = useCallback(\n\t\t(e: KeyboardEvent): boolean => {\n\t\t\tconst ourForm = formRef?.current\n\t\t\tconst target = e.target instanceof HTMLElement ? e.target : null\n\t\t\tif (target && ourForm?.contains(target)) return false\n\t\t\tconst targetForm = target?.closest('form')\n\t\t\tif (targetForm) return targetForm !== ourForm\n\t\t\tconst openDrawers = document.querySelectorAll('.drawer--is-open')\n\t\t\tconst topDrawer = openDrawers[openDrawers.length - 1]\n\t\t\treturn Boolean(topDrawer && ourForm && !topDrawer.contains(ourForm))\n\t\t},\n\t\t[formRef]\n\t)\n\n\tconst chords = useMemo(() => {\n\t\tif (shortcuts === false) return null\n\t\treturn {\n\t\t\tredo: shortcuts?.redo ?? [...DEFAULT_SHORTCUTS.redo],\n\t\t\tundo: shortcuts?.undo ?? [...DEFAULT_SHORTCUTS.undo],\n\t\t}\n\t}, [shortcuts])\n\n\tconst hotkeyOptions = useMemo(\n\t\t() => ({\n\t\t\tenabled: chords !== null,\n\t\t\tignoreEventWhen: isForAnotherForm,\n\t\t\tpreventDefault: true,\n\t\t}),\n\t\t[chords, isForAnotherForm]\n\t)\n\n\tuseHotkeys(chords?.undo ?? [], undo, hotkeyOptions, [undo])\n\n\tuseHotkeys(chords?.redo ?? [], redo, hotkeyOptions, [redo])\n\n\treturn {\n\t\tcanRedo: flags.redo,\n\t\tcanUndo: flags.undo,\n\t\tchords,\n\t\tfields,\n\t\thistory,\n\t\tjumpTo,\n\t\tredo,\n\t\trevision,\n\t\ttracksSavedState,\n\t\tundo,\n\t}\n}\n"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { HistoryDebugOverlay, HistoryDebugOverlayProps } from "../client/HistoryDebugOverlay.js";
2
+ import { UndoRedoControls, UndoRedoControlsProps } from "../client/UndoRedoControls.js";
3
+ import { BoundChords, UseUndoRedoOptions, UseUndoRedoResult, useUndoRedo } from "../client/useUndoRedo.js";
4
+ export { type BoundChords, HistoryDebugOverlay, type HistoryDebugOverlayProps, UndoRedoControls, type UndoRedoControlsProps, type UseUndoRedoOptions, type UseUndoRedoResult, useUndoRedo };
@@ -0,0 +1,5 @@
1
+ "use client";
2
+ import { HistoryDebugOverlay } from "../client/HistoryDebugOverlay.js";
3
+ import { useUndoRedo } from "../client/useUndoRedo.js";
4
+ import { UndoRedoControls } from "../client/UndoRedoControls.js";
5
+ export { HistoryDebugOverlay, UndoRedoControls, useUndoRedo };
@@ -0,0 +1,3 @@
1
+ import { TranslationKey, keys } from "../translations/keys.js";
2
+ import { TranslationsOption, translations } from "../translations/index.js";
3
+ export { type TranslationKey, type TranslationsOption, type TranslationKey as UndoRedoTranslationKeys, keys, translations };
@@ -0,0 +1,3 @@
1
+ import { keys } from "../translations/keys.js";
2
+ import { translations } from "../translations/index.js";
3
+ export { keys, translations };
@@ -0,0 +1,5 @@
1
+ import { ComparableDiff, ComparableField, ComparableState, HistoryEntry, UndoHistory } from "../history/historyCore.js";
2
+ import { ControlsClientProps, DocScope, ResolvedDocOptions, ShortcutKeys, UndoRedoDocOptions, UndoRedoPluginOptions } from "../plugin/options.js";
3
+ import { UndoRedoFieldConfig } from "../schema/fieldConfig.js";
4
+ import { FieldSchemaEntry, FieldSchemaMap, WalkableBlock, WalkableField, WalkableTab } from "../schema/fieldSchema.js";
5
+ export type { ComparableDiff, ComparableField, ComparableState, ControlsClientProps, DocScope, FieldSchemaEntry, FieldSchemaMap, HistoryEntry, ResolvedDocOptions, ShortcutKeys, UndoHistory, UndoRedoDocOptions, UndoRedoFieldConfig, UndoRedoPluginOptions, WalkableBlock, WalkableField, WalkableTab };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { PathMatcher } from "./pathPatterns.js";
2
+ import { FormState } from "payload";
3
+
4
+ //#region src/history/historyCore.d.ts
5
+ interface ComparableField {
6
+ /** Absent for array and blocks fields, whose `value` carries no information (see extractComparableField). */
7
+ value?: unknown;
8
+ /** Row ids in order, which captures array/blocks row additions, deletions and moves. */
9
+ rowIds?: (string | undefined)[];
10
+ }
11
+ /** The slice of form state that counts as a user-visible edit. */
12
+ type ComparableState = Record<string, ComparableField>;
13
+ interface HistoryEntry {
14
+ /**
15
+ * Monotonic id, stable across the stack shifting when the cap evicts the
16
+ * oldest entries. Positional indexes are not stable for that reason, so
17
+ * anything keyed per entry (React keys, debug UI expansion) uses this.
18
+ */
19
+ id: number;
20
+ fields: FormState;
21
+ comparable: ComparableState;
22
+ }
23
+ /**
24
+ * True for a field whose current value the history cannot put back.
25
+ *
26
+ * Payload's JSON field is the case this exists for. While its text does not
27
+ * parse, the field writes the raw editor text into form state as a string, and
28
+ * the editor is then rendered from `JSON.stringify(value)`, so dispatching that
29
+ * string back would show it double-encoded rather than as the text the editor
30
+ * had. No value we could dispatch reproduces broken text, so rather than record
31
+ * a state it cannot honour, a capture carries the last value it can (see
32
+ * pushSnapshot).
33
+ */
34
+ type VolatileMatcher = (path: string, field: FormState[string]) => boolean;
35
+ /**
36
+ * Per-history settings. They live on the history object rather than being
37
+ * threaded through every call so that a snapshot and the restore built from it
38
+ * can never disagree about which paths are in scope.
39
+ */
40
+ interface HistoryOptions {
41
+ /** Paths excluded from capture and from restore. */
42
+ isIgnored: PathMatcher;
43
+ /** Paths whose live value is captured as the previous entry's instead. */
44
+ isVolatile: VolatileMatcher;
45
+ /** Entries kept before the oldest is evicted. */
46
+ maxHistory: number;
47
+ }
48
+ interface UndoHistory {
49
+ stack: HistoryEntry[];
50
+ /** Index of the entry representing the current form state. */
51
+ index: number;
52
+ /**
53
+ * The comparable state of the persisted document, or null before anything has
54
+ * been saved or loaded.
55
+ *
56
+ * This is what makes the "leave without saving" prompt truthful in both
57
+ * directions. Undoing back to the *first* entry is not the same as being
58
+ * unsaved: after a save, the baseline moves to wherever the document was
59
+ * saved from, and stepping back past that point is a real unsaved change even
60
+ * though the form looks like it did on load.
61
+ *
62
+ * Held as a value rather than an index on purpose. An index has to be
63
+ * renumbered when the cap evicts entries and invalidated when a new edit
64
+ * drops the redo tail, and recording one meant pushing a snapshot at save
65
+ * time, which truncated the redo tail through the very branch redo was about
66
+ * to walk into. A value needs none of that bookkeeping and lets the baseline
67
+ * be recorded without touching the stack at all.
68
+ */
69
+ savedComparable: ComparableState | null;
70
+ options: HistoryOptions;
71
+ }
72
+ /** A single path-level change between two comparable states. */
73
+ interface ComparableDiff {
74
+ path: string;
75
+ from: unknown;
76
+ to: unknown;
77
+ /** Set only when the path carries array/blocks rows and those rows changed. */
78
+ fromRowIds?: (string | undefined)[];
79
+ toRowIds?: (string | undefined)[];
80
+ /** Whether the path itself appeared or disappeared between the two states. */
81
+ presence?: 'added' | 'removed';
82
+ }
83
+ //#endregion
84
+ export { ComparableDiff, ComparableField, ComparableState, HistoryEntry, UndoHistory };
85
+ //# sourceMappingURL=historyCore.d.ts.map