@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/client/HistoryDebugOverlay.d.ts +30 -0
- package/dist/client/HistoryDebugOverlay.js +201 -0
- package/dist/client/HistoryDebugOverlay.js.map +1 -0
- package/dist/client/UndoRedoControls.d.ts +18 -0
- package/dist/client/UndoRedoControls.js +155 -0
- package/dist/client/UndoRedoControls.js.map +1 -0
- package/dist/client/formatShortcut.js +113 -0
- package/dist/client/formatShortcut.js.map +1 -0
- package/dist/client/historyDebugOverlay.css +206 -0
- package/dist/client/undoRedoControls.css +58 -0
- package/dist/client/useUndoRedo.d.ts +70 -0
- package/dist/client/useUndoRedo.js +259 -0
- package/dist/client/useUndoRedo.js.map +1 -0
- package/dist/exports/client.d.ts +4 -0
- package/dist/exports/client.js +5 -0
- package/dist/exports/i18n.d.ts +3 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/types.d.ts +5 -0
- package/dist/exports/types.js +1 -0
- package/dist/history/historyCore.d.ts +85 -0
- package/dist/history/historyCore.js +318 -0
- package/dist/history/historyCore.js.map +1 -0
- package/dist/history/pathPatterns.d.ts +6 -0
- package/dist/history/pathPatterns.js +45 -0
- package/dist/history/pathPatterns.js.map +1 -0
- package/dist/history/volatileValues.js +51 -0
- package/dist/history/volatileValues.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin/options.d.ts +109 -0
- package/dist/plugin/options.js +52 -0
- package/dist/plugin/options.js.map +1 -0
- package/dist/plugin/registerTranslations.js +19 -0
- package/dist/plugin/registerTranslations.js.map +1 -0
- package/dist/plugin/withUndoRedo.d.ts +34 -0
- package/dist/plugin/withUndoRedo.js +72 -0
- package/dist/plugin/withUndoRedo.js.map +1 -0
- package/dist/schema/fieldConfig.d.ts +36 -0
- package/dist/schema/fieldConfig.js +28 -0
- package/dist/schema/fieldConfig.js.map +1 -0
- package/dist/schema/fieldSchema.d.ts +63 -0
- package/dist/schema/fieldSchema.js +122 -0
- package/dist/schema/fieldSchema.js.map +1 -0
- package/dist/translations/de.js +19 -0
- package/dist/translations/de.js.map +1 -0
- package/dist/translations/en.js +23 -0
- package/dist/translations/en.js.map +1 -0
- package/dist/translations/index.d.ts +15 -0
- package/dist/translations/index.js +30 -0
- package/dist/translations/index.js.map +1 -0
- package/dist/translations/keys.d.ts +22 -0
- package/dist/translations/keys.js +22 -0
- package/dist/translations/keys.js.map +1 -0
- package/dist/translations/useTranslation.js +12 -0
- package/dist/translations/useTranslation.js.map +1 -0
- package/package.json +109 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @10x-media/undo-redo
|
|
2
|
+
|
|
3
|
+
## 0.1.0-beta.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add client-side undo/redo controls to every collection and global edit view. Form-state snapshots are captured on debounced field changes and restored through the form's `REPLACE_STATE` action, covering text edits and array/blocks row additions, deletions and moves. Keyboard shortcuts are `Ctrl+Z` / `Ctrl+Shift+Z` (and `Ctrl+Y`) outside text-editing surfaces. History is in-memory per editor session and independent of Payload versions and drafts.
|
|
8
|
+
|
|
9
|
+
- Extract the undo/redo logic into a `useUndoRedo` hook, exported from `@10x-media/undo-redo/client`. It owns the history, the capture debounce, the saved-state baseline and the keyboard chords, and returns `canUndo`, `canRedo`, `undo`, `redo`, `jumpTo` plus what the history inspector needs, so a host can pair `autoMount: false` with its own controls instead of restyling ours. `UndoRedoControls` is now presentation over the same hook. Shortcut targeting reads the form element from Payload's form context rather than from the controls' own DOM node, so the rule holds wherever the hook is called.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Stop the history from recording a JSON field while its text does not parse. Payload keeps the raw editor text in form state as a string there, and renders the editor from `JSON.stringify(value)`, so restoring that string showed it escaped inside quotes instead of as the text the editor had. A capture taken while the text is broken now carries the field's last restorable value forward, so entries only ever describe states the form can be put back into: breaking the JSON alone records nothing, undo restores the last value that parsed, and redo never re-breaks it. The debug overlay stops reporting the field as a change that is forever pending.
|
|
14
|
+
|
|
15
|
+
- Stop polymorphic relationship and upload fields from producing history entries nobody asked for. Payload hands react-select's option objects straight to form state for a `relationTo` array, so the value carries `label` and `allowEdit` next to the reference, and those move on their own: a label refreshes when the related document is saved, `allowEdit` appears once permissions resolve, and the server merge after a save replaces the whole option with the bare reference. The last one appended an entry identical to the one before it, costing an extra undo to get past. Comparison now reduces such values to `relationTo` and `value`, which is the same line Payload's own change detection draws.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 10x Media GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# @10x-media/undo-redo
|
|
4
|
+
|
|
5
|
+
Client-side undo/redo for Payload v3 admin forms. Snapshots the document form state as the editor works and steps back and forth through it, independent of Payload's document versions: nothing reaches the server until the editor saves.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@10x-media/undo-redo)
|
|
8
|
+
|
|
9
|
+
Part of the [@10x-media Payload plugins](https://github.com/10x-media/payload-plugins) collection. In beta: published under the `beta` dist-tag until a stable 1.0.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Controls on every edit view**, collections and globals, mounted before the document controls. Placement is overridable.
|
|
14
|
+
- **Keyboard**: `Ctrl/Cmd+Z` and `Ctrl/Cmd+Shift+Z` (plus `Ctrl/Cmd+Y`), skipped inside inputs and Lexical, which own their native undo. Rebindable; tooltips render whatever is bound, in the platform's own notation.
|
|
15
|
+
- **Every field type**, including array and blocks row additions, deletions and moves, restoring deleted rows with their subfield state, and conditionally hidden fields with their whole subtree.
|
|
16
|
+
- **Scoped exclusions**: per collection, per global, per field via `admin.custom`, per field type, or by path pattern (`list.*.readingTime`).
|
|
17
|
+
- **Payload's own fields excluded by default** (`_status`, `createdAt`, `sessions` and the rest of auth), so undo never unpublishes a document or disturbs auth state.
|
|
18
|
+
- **Truthful save state**: the saved document is tracked as a baseline, so undoing back onto it reports the form clean rather than merely unchanged since load.
|
|
19
|
+
- **Debug overlay** listing every entry, the paths it changed, and pending edits, with click-to-restore.
|
|
20
|
+
- **Typed translations** with per-key overrides via `@10x-media/undo-redo/i18n`.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm add @10x-media/undo-redo
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// payload.config.ts
|
|
30
|
+
import { buildConfig } from 'payload'
|
|
31
|
+
import { undoRedo } from '@10x-media/undo-redo'
|
|
32
|
+
|
|
33
|
+
export default buildConfig({
|
|
34
|
+
plugins: [undoRedo({})],
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run `payload generate:importmap`. Undo/redo is on for every collection and global; `collections` and `globals` are opt-*out* maps.
|
|
39
|
+
|
|
40
|
+
## Documentation
|
|
41
|
+
|
|
42
|
+
Full documentation at [docs.10xmedia.de](https://docs.10xmedia.de/undo-redo):
|
|
43
|
+
|
|
44
|
+
- [Overview](https://docs.10xmedia.de/undo-redo)
|
|
45
|
+
- [Quick start](https://docs.10xmedia.de/undo-redo/quick-start)
|
|
46
|
+
- [Configuration](https://docs.10xmedia.de/undo-redo/configuration)
|
|
47
|
+
- [What is tracked](https://docs.10xmedia.de/undo-redo/what-is-tracked)
|
|
48
|
+
- [Keyboard shortcuts](https://docs.10xmedia.de/undo-redo/shortcuts)
|
|
49
|
+
- [Debugging](https://docs.10xmedia.de/undo-redo/debugging)
|
|
50
|
+
- [i18n](https://docs.10xmedia.de/undo-redo/i18n)
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
[MIT](./LICENSE). Copyright 10x Media GmbH.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { UndoHistory } from "../history/historyCore.js";
|
|
2
|
+
import { FormState } from "payload";
|
|
3
|
+
import React from "react";
|
|
4
|
+
//#region src/client/HistoryDebugOverlay.d.ts
|
|
5
|
+
interface HistoryDebugOverlayProps {
|
|
6
|
+
/**
|
|
7
|
+
* The live history object. Read during render rather than held in state: the
|
|
8
|
+
* owning component re-renders this overlay whenever it mutates.
|
|
9
|
+
*/
|
|
10
|
+
history: UndoHistory;
|
|
11
|
+
/** Live form state, used to show edits not yet captured into an entry. */
|
|
12
|
+
fields: FormState | null;
|
|
13
|
+
/** Monotonic counter of captures and restores, shown as a liveness signal. */
|
|
14
|
+
revision: number;
|
|
15
|
+
/** False under autosave, where the saved baseline is deliberately not tracked. */
|
|
16
|
+
tracksSavedState: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
onJump: (index: number) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Developer overlay listing every history entry with the paths it changed,
|
|
22
|
+
* which entry is current, and any pending edits still inside the capture
|
|
23
|
+
* debounce. Rendered only when the plugin runs with `debug: true`. Clicking an
|
|
24
|
+
* entry restores it, which makes stepping through a suspect sequence far
|
|
25
|
+
* cheaper than repeatedly pressing undo.
|
|
26
|
+
*/
|
|
27
|
+
declare const HistoryDebugOverlay: React.FC<HistoryDebugOverlayProps>;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { HistoryDebugOverlay, HistoryDebugOverlayProps };
|
|
30
|
+
//# sourceMappingURL=HistoryDebugOverlay.d.ts.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { deepEqual, diffComparable, isAtSavedState, projectComparable } from "../history/historyCore.js";
|
|
3
|
+
import { keys } from "../translations/keys.js";
|
|
4
|
+
import { useTranslation } from "../translations/useTranslation.js";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
7
|
+
import "./historyDebugOverlay.css";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
//#region src/client/HistoryDebugOverlay.tsx
|
|
10
|
+
const baseClass = "undo-redo-debug";
|
|
11
|
+
/** Longest rendered form of a value before it is elided in the diff table. */
|
|
12
|
+
const MAX_VALUE_CHARS = 120;
|
|
13
|
+
/**
|
|
14
|
+
* One-line rendering of an arbitrary form value. Rich text and other deep
|
|
15
|
+
* objects collapse to a truncated JSON string: the overlay answers "did this
|
|
16
|
+
* path change and roughly to what", not "what is the full document".
|
|
17
|
+
*/
|
|
18
|
+
const formatValue = (value) => {
|
|
19
|
+
if (value === void 0) return "∅";
|
|
20
|
+
if (value === null) return "null";
|
|
21
|
+
if (typeof value === "string") return value === "" ? "''" : value;
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = JSON.stringify(value) ?? String(value);
|
|
25
|
+
} catch {
|
|
26
|
+
text = String(value);
|
|
27
|
+
}
|
|
28
|
+
return text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : text;
|
|
29
|
+
};
|
|
30
|
+
/** Row ids shortened to their last segment so moves stay readable in one line. */
|
|
31
|
+
const formatRowIds = (ids) => ids === void 0 ? "∅" : `[${ids.map((id) => id?.slice(-4) ?? "?").join(", ")}]`;
|
|
32
|
+
const DiffRows = ({ diffs }) => /* @__PURE__ */ jsx("table", {
|
|
33
|
+
className: `${baseClass}__diff`,
|
|
34
|
+
children: /* @__PURE__ */ jsx("tbody", { children: diffs.map((diff) => /* @__PURE__ */ jsxs("tr", { children: [
|
|
35
|
+
/* @__PURE__ */ jsxs("td", {
|
|
36
|
+
className: `${baseClass}__path`,
|
|
37
|
+
children: [diff.path, diff.presence ? /* @__PURE__ */ jsx("span", {
|
|
38
|
+
className: `${baseClass}__presence`,
|
|
39
|
+
children: diff.presence
|
|
40
|
+
}) : null]
|
|
41
|
+
}),
|
|
42
|
+
/* @__PURE__ */ jsx("td", {
|
|
43
|
+
className: `${baseClass}__from`,
|
|
44
|
+
children: diff.fromRowIds || diff.toRowIds ? formatRowIds(diff.fromRowIds) : formatValue(diff.from)
|
|
45
|
+
}),
|
|
46
|
+
/* @__PURE__ */ jsx("td", {
|
|
47
|
+
className: `${baseClass}__arrow`,
|
|
48
|
+
"aria-hidden": "true",
|
|
49
|
+
children: "→"
|
|
50
|
+
}),
|
|
51
|
+
/* @__PURE__ */ jsx("td", {
|
|
52
|
+
className: `${baseClass}__to`,
|
|
53
|
+
children: diff.fromRowIds || diff.toRowIds ? formatRowIds(diff.toRowIds) : formatValue(diff.to)
|
|
54
|
+
})
|
|
55
|
+
] }, diff.path)) })
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Developer overlay listing every history entry with the paths it changed,
|
|
59
|
+
* which entry is current, and any pending edits still inside the capture
|
|
60
|
+
* debounce. Rendered only when the plugin runs with `debug: true`. Clicking an
|
|
61
|
+
* entry restores it, which makes stepping through a suspect sequence far
|
|
62
|
+
* cheaper than repeatedly pressing undo.
|
|
63
|
+
*/
|
|
64
|
+
const HistoryDebugOverlay = ({ history, fields, revision, tracksSavedState, onClose, onJump }) => {
|
|
65
|
+
const { t } = useTranslation();
|
|
66
|
+
/** Entry id, not index: the cap evicts from the front and shifts indexes. */
|
|
67
|
+
const [expanded, setExpanded] = useState(null);
|
|
68
|
+
const [mounted, setMounted] = useState(false);
|
|
69
|
+
useEffect(() => setMounted(true), []);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const onKeyDown = (e) => {
|
|
72
|
+
if (e.key === "Escape") onClose();
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener("keydown", onKeyDown);
|
|
75
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
76
|
+
}, [onClose]);
|
|
77
|
+
if (!mounted) return null;
|
|
78
|
+
const pending = fields ? diffComparable(history.stack[history.index]?.comparable ?? {}, projectComparable(history, fields)) : [];
|
|
79
|
+
const copy = () => {
|
|
80
|
+
const payload = history.stack.map((entry, index) => ({
|
|
81
|
+
index,
|
|
82
|
+
current: index === history.index,
|
|
83
|
+
changed: diffComparable(history.stack[index - 1]?.comparable ?? {}, entry.comparable)
|
|
84
|
+
}));
|
|
85
|
+
navigator.clipboard?.writeText(JSON.stringify(payload, null, 2));
|
|
86
|
+
};
|
|
87
|
+
return createPortal(/* @__PURE__ */ jsxs("div", {
|
|
88
|
+
className: baseClass,
|
|
89
|
+
role: "dialog",
|
|
90
|
+
"aria-label": t(keys.debugTitle),
|
|
91
|
+
children: [/* @__PURE__ */ jsxs("header", {
|
|
92
|
+
className: `${baseClass}__header`,
|
|
93
|
+
children: [
|
|
94
|
+
/* @__PURE__ */ jsx("strong", {
|
|
95
|
+
className: `${baseClass}__title`,
|
|
96
|
+
children: t(keys.debugTitle)
|
|
97
|
+
}),
|
|
98
|
+
/* @__PURE__ */ jsxs("span", {
|
|
99
|
+
className: `${baseClass}__meta`,
|
|
100
|
+
children: [
|
|
101
|
+
history.stack.length === 0 ? 0 : history.index + 1,
|
|
102
|
+
"/",
|
|
103
|
+
history.stack.length,
|
|
104
|
+
" ·",
|
|
105
|
+
" ",
|
|
106
|
+
!tracksSavedState ? "autosave, no baseline" : history.savedComparable ? isAtSavedState(history) ? "clean" : "unsaved" : "no baseline",
|
|
107
|
+
" ",
|
|
108
|
+
"· rev ",
|
|
109
|
+
revision
|
|
110
|
+
]
|
|
111
|
+
}),
|
|
112
|
+
/* @__PURE__ */ jsx("button", {
|
|
113
|
+
className: `${baseClass}__action`,
|
|
114
|
+
onClick: copy,
|
|
115
|
+
type: "button",
|
|
116
|
+
children: t(keys.debugCopy)
|
|
117
|
+
}),
|
|
118
|
+
/* @__PURE__ */ jsx("button", {
|
|
119
|
+
"aria-label": t(keys.debugClose),
|
|
120
|
+
className: `${baseClass}__action`,
|
|
121
|
+
onClick: onClose,
|
|
122
|
+
type: "button",
|
|
123
|
+
children: "✕"
|
|
124
|
+
})
|
|
125
|
+
]
|
|
126
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
127
|
+
className: `${baseClass}__body`,
|
|
128
|
+
children: [
|
|
129
|
+
history.stack.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
130
|
+
className: `${baseClass}__empty`,
|
|
131
|
+
children: t(keys.debugEmpty)
|
|
132
|
+
}) : null,
|
|
133
|
+
history.stack.map((entry, index) => {
|
|
134
|
+
const changed = diffComparable(history.stack[index - 1]?.comparable ?? {}, entry.comparable);
|
|
135
|
+
const isCurrent = index === history.index;
|
|
136
|
+
const isOpen = expanded === entry.id;
|
|
137
|
+
const isSaved = history.savedComparable !== null && deepEqual(entry.comparable, history.savedComparable);
|
|
138
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
139
|
+
className: `${baseClass}__entry${isCurrent ? ` ${baseClass}__entry--current` : ""}`,
|
|
140
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
141
|
+
className: `${baseClass}__entryHead`,
|
|
142
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
143
|
+
"aria-expanded": isOpen,
|
|
144
|
+
className: `${baseClass}__toggle`,
|
|
145
|
+
onClick: () => setExpanded(isOpen ? null : entry.id),
|
|
146
|
+
type: "button",
|
|
147
|
+
children: [
|
|
148
|
+
/* @__PURE__ */ jsx("span", {
|
|
149
|
+
className: `${baseClass}__caret`,
|
|
150
|
+
"aria-hidden": "true",
|
|
151
|
+
children: isOpen ? "▾" : "▸"
|
|
152
|
+
}),
|
|
153
|
+
/* @__PURE__ */ jsxs("span", {
|
|
154
|
+
className: `${baseClass}__index`,
|
|
155
|
+
children: ["#", index]
|
|
156
|
+
}),
|
|
157
|
+
isSaved ? /* @__PURE__ */ jsx("span", {
|
|
158
|
+
className: `${baseClass}__saved`,
|
|
159
|
+
title: "matches the persisted document",
|
|
160
|
+
children: "saved"
|
|
161
|
+
}) : null,
|
|
162
|
+
/* @__PURE__ */ jsx("span", {
|
|
163
|
+
className: `${baseClass}__summary`,
|
|
164
|
+
children: index === 0 ? t(keys.debugOriginal) : changed.map((diff) => diff.path).join(", ") || "(no visible change)"
|
|
165
|
+
}),
|
|
166
|
+
/* @__PURE__ */ jsx("span", {
|
|
167
|
+
className: `${baseClass}__count`,
|
|
168
|
+
children: changed.length
|
|
169
|
+
})
|
|
170
|
+
]
|
|
171
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
172
|
+
className: `${baseClass}__jump`,
|
|
173
|
+
disabled: isCurrent,
|
|
174
|
+
onClick: () => onJump(index),
|
|
175
|
+
type: "button",
|
|
176
|
+
children: "restore"
|
|
177
|
+
})]
|
|
178
|
+
}), isOpen && changed.length > 0 ? /* @__PURE__ */ jsx(DiffRows, { diffs: changed }) : null]
|
|
179
|
+
}, entry.id);
|
|
180
|
+
}),
|
|
181
|
+
pending.length > 0 ? /* @__PURE__ */ jsxs("section", {
|
|
182
|
+
className: `${baseClass}__entry ${baseClass}__entry--pending`,
|
|
183
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
184
|
+
className: `${baseClass}__entryHead`,
|
|
185
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
186
|
+
className: `${baseClass}__summary`,
|
|
187
|
+
children: t(keys.debugPending)
|
|
188
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
189
|
+
className: `${baseClass}__count`,
|
|
190
|
+
children: pending.length
|
|
191
|
+
})]
|
|
192
|
+
}), /* @__PURE__ */ jsx(DiffRows, { diffs: pending })]
|
|
193
|
+
}) : null
|
|
194
|
+
]
|
|
195
|
+
})]
|
|
196
|
+
}), document.body);
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
export { HistoryDebugOverlay };
|
|
200
|
+
|
|
201
|
+
//# sourceMappingURL=HistoryDebugOverlay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"HistoryDebugOverlay.js","names":[],"sources":["../../src/client/HistoryDebugOverlay.tsx"],"sourcesContent":["'use client'\nimport type { FormState } from 'payload'\nimport type React from 'react'\nimport { useEffect, useState } from 'react'\nimport { createPortal } from 'react-dom'\n\nimport {\n\ttype ComparableDiff,\n\tdeepEqual,\n\tdiffComparable,\n\tisAtSavedState,\n\tprojectComparable,\n\ttype UndoHistory,\n} from '../history/historyCore'\nimport { keys } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport './historyDebugOverlay.css'\n\nconst baseClass = 'undo-redo-debug'\n\n/** Longest rendered form of a value before it is elided in the diff table. */\nconst MAX_VALUE_CHARS = 120\n\n/**\n * One-line rendering of an arbitrary form value. Rich text and other deep\n * objects collapse to a truncated JSON string: the overlay answers \"did this\n * path change and roughly to what\", not \"what is the full document\".\n */\nconst formatValue = (value: unknown): string => {\n\tif (value === undefined) return '∅'\n\tif (value === null) return 'null'\n\tif (typeof value === 'string') return value === '' ? \"''\" : value\n\tlet text: string\n\ttry {\n\t\ttext = JSON.stringify(value) ?? String(value)\n\t} catch {\n\t\ttext = String(value)\n\t}\n\treturn text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : text\n}\n\n/** Row ids shortened to their last segment so moves stay readable in one line. */\nconst formatRowIds = (ids: (string | undefined)[] | undefined): string =>\n\tids === undefined ? '∅' : `[${ids.map((id) => id?.slice(-4) ?? '?').join(', ')}]`\n\nconst DiffRows: React.FC<{ diffs: ComparableDiff[] }> = ({ diffs }) => (\n\t<table className={`${baseClass}__diff`}>\n\t\t<tbody>\n\t\t\t{diffs.map((diff) => (\n\t\t\t\t<tr key={diff.path}>\n\t\t\t\t\t<td className={`${baseClass}__path`}>\n\t\t\t\t\t\t{diff.path}\n\t\t\t\t\t\t{diff.presence ? (\n\t\t\t\t\t\t\t<span className={`${baseClass}__presence`}>{diff.presence}</span>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</td>\n\t\t\t\t\t<td className={`${baseClass}__from`}>\n\t\t\t\t\t\t{diff.fromRowIds || diff.toRowIds\n\t\t\t\t\t\t\t? formatRowIds(diff.fromRowIds)\n\t\t\t\t\t\t\t: formatValue(diff.from)}\n\t\t\t\t\t</td>\n\t\t\t\t\t<td className={`${baseClass}__arrow`} aria-hidden=\"true\">\n\t\t\t\t\t\t→\n\t\t\t\t\t</td>\n\t\t\t\t\t<td className={`${baseClass}__to`}>\n\t\t\t\t\t\t{diff.fromRowIds || diff.toRowIds ? formatRowIds(diff.toRowIds) : formatValue(diff.to)}\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t))}\n\t\t</tbody>\n\t</table>\n)\n\nexport interface HistoryDebugOverlayProps {\n\t/**\n\t * The live history object. Read during render rather than held in state: the\n\t * owning component re-renders this overlay whenever it mutates.\n\t */\n\thistory: UndoHistory\n\t/** Live form state, used to show edits not yet captured into an entry. */\n\tfields: FormState | null\n\t/** Monotonic counter of captures and restores, shown as a liveness signal. */\n\trevision: number\n\t/** False under autosave, where the saved baseline is deliberately not tracked. */\n\ttracksSavedState: boolean\n\tonClose: () => void\n\tonJump: (index: number) => void\n}\n\n/**\n * Developer overlay listing every history entry with the paths it changed,\n * which entry is current, and any pending edits still inside the capture\n * debounce. Rendered only when the plugin runs with `debug: true`. Clicking an\n * entry restores it, which makes stepping through a suspect sequence far\n * cheaper than repeatedly pressing undo.\n */\nexport const HistoryDebugOverlay: React.FC<HistoryDebugOverlayProps> = ({\n\thistory,\n\tfields,\n\trevision,\n\ttracksSavedState,\n\tonClose,\n\tonJump,\n}) => {\n\tconst { t } = useTranslation()\n\t/** Entry id, not index: the cap evicts from the front and shifts indexes. */\n\tconst [expanded, setExpanded] = useState<number | null>(null)\n\tconst [mounted, setMounted] = useState(false)\n\n\t// Portals need a DOM target, which does not exist during the server render.\n\tuseEffect(() => setMounted(true), [])\n\n\tuseEffect(() => {\n\t\tconst onKeyDown = (e: KeyboardEvent) => {\n\t\t\tif (e.key === 'Escape') onClose()\n\t\t}\n\t\tdocument.addEventListener('keydown', onKeyDown)\n\t\treturn () => document.removeEventListener('keydown', onKeyDown)\n\t}, [onClose])\n\n\tif (!mounted) return null\n\n\t// Projected through the history's own settings, not extracted raw: otherwise\n\t// a path the host opted out of, or one holding a value no restore could put\n\t// back, shows up here as a change that is forever \"pending\", since it is\n\t// never going to be captured.\n\tconst pending = fields\n\t\t? diffComparable(\n\t\t\t\thistory.stack[history.index]?.comparable ?? {},\n\t\t\t\tprojectComparable(history, fields)\n\t\t\t)\n\t\t: []\n\n\tconst copy = () => {\n\t\tconst payload = history.stack.map((entry, index) => ({\n\t\t\tindex,\n\t\t\tcurrent: index === history.index,\n\t\t\tchanged: diffComparable(history.stack[index - 1]?.comparable ?? {}, entry.comparable),\n\t\t}))\n\t\tvoid navigator.clipboard?.writeText(JSON.stringify(payload, null, 2))\n\t}\n\n\treturn createPortal(\n\t\t<div className={baseClass} role=\"dialog\" aria-label={t(keys.debugTitle)}>\n\t\t\t<header className={`${baseClass}__header`}>\n\t\t\t\t<strong className={`${baseClass}__title`}>{t(keys.debugTitle)}</strong>\n\t\t\t\t<span className={`${baseClass}__meta`}>\n\t\t\t\t\t{history.stack.length === 0 ? 0 : history.index + 1}/{history.stack.length} ·{' '}\n\t\t\t\t\t{!tracksSavedState\n\t\t\t\t\t\t? 'autosave, no baseline'\n\t\t\t\t\t\t: history.savedComparable\n\t\t\t\t\t\t\t? isAtSavedState(history)\n\t\t\t\t\t\t\t\t? 'clean'\n\t\t\t\t\t\t\t\t: 'unsaved'\n\t\t\t\t\t\t\t: 'no baseline'}{' '}\n\t\t\t\t\t· rev {revision}\n\t\t\t\t</span>\n\t\t\t\t<button className={`${baseClass}__action`} onClick={copy} type=\"button\">\n\t\t\t\t\t{t(keys.debugCopy)}\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\taria-label={t(keys.debugClose)}\n\t\t\t\t\tclassName={`${baseClass}__action`}\n\t\t\t\t\tonClick={onClose}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t>\n\t\t\t\t\t✕\n\t\t\t\t</button>\n\t\t\t</header>\n\n\t\t\t<div className={`${baseClass}__body`}>\n\t\t\t\t{history.stack.length === 0 ? (\n\t\t\t\t\t<p className={`${baseClass}__empty`}>{t(keys.debugEmpty)}</p>\n\t\t\t\t) : null}\n\n\t\t\t\t{history.stack.map((entry, index) => {\n\t\t\t\t\tconst changed = diffComparable(\n\t\t\t\t\t\thistory.stack[index - 1]?.comparable ?? {},\n\t\t\t\t\t\tentry.comparable\n\t\t\t\t\t)\n\t\t\t\t\tconst isCurrent = index === history.index\n\t\t\t\t\tconst isOpen = expanded === entry.id\n\t\t\t\t\t// Compared by value, so more than one entry can read as saved,\n\t\t\t\t\t// which is correct: they hold the same form state.\n\t\t\t\t\tconst isSaved =\n\t\t\t\t\t\thistory.savedComparable !== null && deepEqual(entry.comparable, history.savedComparable)\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<section\n\t\t\t\t\t\t\tclassName={`${baseClass}__entry${isCurrent ? ` ${baseClass}__entry--current` : ''}`}\n\t\t\t\t\t\t\tkey={entry.id}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className={`${baseClass}__entryHead`}>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\taria-expanded={isOpen}\n\t\t\t\t\t\t\t\t\tclassName={`${baseClass}__toggle`}\n\t\t\t\t\t\t\t\t\tonClick={() => setExpanded(isOpen ? null : entry.id)}\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span className={`${baseClass}__caret`} aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t\t{isOpen ? '▾' : '▸'}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className={`${baseClass}__index`}>#{index}</span>\n\t\t\t\t\t\t\t\t\t{isSaved ? (\n\t\t\t\t\t\t\t\t\t\t<span className={`${baseClass}__saved`} title=\"matches the persisted document\">\n\t\t\t\t\t\t\t\t\t\t\tsaved\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t<span className={`${baseClass}__summary`}>\n\t\t\t\t\t\t\t\t\t\t{index === 0\n\t\t\t\t\t\t\t\t\t\t\t? t(keys.debugOriginal)\n\t\t\t\t\t\t\t\t\t\t\t: changed.map((diff) => diff.path).join(', ') || '(no visible change)'}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className={`${baseClass}__count`}>{changed.length}</span>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclassName={`${baseClass}__jump`}\n\t\t\t\t\t\t\t\t\tdisabled={isCurrent}\n\t\t\t\t\t\t\t\t\tonClick={() => onJump(index)}\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\trestore\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{isOpen && changed.length > 0 ? <DiffRows diffs={changed} /> : null}\n\t\t\t\t\t\t</section>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t\t{pending.length > 0 ? (\n\t\t\t\t\t<section className={`${baseClass}__entry ${baseClass}__entry--pending`}>\n\t\t\t\t\t\t<div className={`${baseClass}__entryHead`}>\n\t\t\t\t\t\t\t<span className={`${baseClass}__summary`}>{t(keys.debugPending)}</span>\n\t\t\t\t\t\t\t<span className={`${baseClass}__count`}>{pending.length}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<DiffRows diffs={pending} />\n\t\t\t\t\t</section>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t</div>,\n\t\tdocument.body\n\t)\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ControlsClientProps } from "../plugin/options.js";
|
|
2
|
+
import React from "react";
|
|
3
|
+
//#region src/client/UndoRedoControls.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Every setting is optional so the component stays usable when a host mounts it
|
|
6
|
+
* by hand (`autoMount: false`) rather than through the plugin, which always
|
|
7
|
+
* passes a fully resolved set.
|
|
8
|
+
*/
|
|
9
|
+
type UndoRedoControlsProps = Partial<ControlsClientProps>;
|
|
10
|
+
/**
|
|
11
|
+
* Undo/redo buttons for the document edit view. Thin presentation over
|
|
12
|
+
* `useUndoRedo`, which owns the history: a host that wants different markup can
|
|
13
|
+
* call that hook directly instead of restyling this.
|
|
14
|
+
*/
|
|
15
|
+
declare const UndoRedoControls: React.FC<UndoRedoControlsProps>;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { UndoRedoControls, UndoRedoControlsProps };
|
|
18
|
+
//# sourceMappingURL=UndoRedoControls.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { keys } from "../translations/keys.js";
|
|
3
|
+
import { useTranslation as useTranslation$1 } from "../translations/useTranslation.js";
|
|
4
|
+
import { HistoryDebugOverlay } from "./HistoryDebugOverlay.js";
|
|
5
|
+
import { formatShortcut, isMacPlatform } from "./formatShortcut.js";
|
|
6
|
+
import { useUndoRedo } from "./useUndoRedo.js";
|
|
7
|
+
import { useEffect, useMemo, useState } from "react";
|
|
8
|
+
import { Button, useFormProcessing } from "@payloadcms/ui";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import "./undoRedoControls.css";
|
|
11
|
+
//#region src/client/UndoRedoControls.tsx
|
|
12
|
+
const baseClass = "undo-redo-controls";
|
|
13
|
+
/**
|
|
14
|
+
* Resolved after mount rather than during render: the server render has no
|
|
15
|
+
* `navigator`, so deciding there would either hydrate into a mismatch or lock
|
|
16
|
+
* every user to the non-mac notation. The value only feeds tooltip text, which
|
|
17
|
+
* nobody can hover before the first paint.
|
|
18
|
+
*/
|
|
19
|
+
const useIsMac = () => {
|
|
20
|
+
const [isMac, setIsMac] = useState(false);
|
|
21
|
+
useEffect(() => setIsMac(isMacPlatform()), []);
|
|
22
|
+
return isMac;
|
|
23
|
+
};
|
|
24
|
+
const UndoIcon = () => /* @__PURE__ */ jsxs("svg", {
|
|
25
|
+
width: "18",
|
|
26
|
+
height: "18",
|
|
27
|
+
viewBox: "0 0 24 24",
|
|
28
|
+
fill: "none",
|
|
29
|
+
stroke: "currentColor",
|
|
30
|
+
strokeWidth: "2",
|
|
31
|
+
strokeLinecap: "round",
|
|
32
|
+
strokeLinejoin: "round",
|
|
33
|
+
"aria-hidden": "true",
|
|
34
|
+
children: [/* @__PURE__ */ jsx("path", { d: "M3 7v6h6" }), /* @__PURE__ */ jsx("path", { d: "M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" })]
|
|
35
|
+
});
|
|
36
|
+
const RedoIcon = () => /* @__PURE__ */ jsxs("svg", {
|
|
37
|
+
width: "18",
|
|
38
|
+
height: "18",
|
|
39
|
+
viewBox: "0 0 24 24",
|
|
40
|
+
fill: "none",
|
|
41
|
+
stroke: "currentColor",
|
|
42
|
+
strokeWidth: "2",
|
|
43
|
+
strokeLinecap: "round",
|
|
44
|
+
strokeLinejoin: "round",
|
|
45
|
+
"aria-hidden": "true",
|
|
46
|
+
children: [/* @__PURE__ */ jsx("path", { d: "M21 7v6h-6" }), /* @__PURE__ */ jsx("path", { d: "M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13" })]
|
|
47
|
+
});
|
|
48
|
+
const HistoryIcon = () => /* @__PURE__ */ jsxs("svg", {
|
|
49
|
+
width: "18",
|
|
50
|
+
height: "18",
|
|
51
|
+
viewBox: "0 0 24 24",
|
|
52
|
+
fill: "none",
|
|
53
|
+
stroke: "currentColor",
|
|
54
|
+
strokeWidth: "2",
|
|
55
|
+
strokeLinecap: "round",
|
|
56
|
+
strokeLinejoin: "round",
|
|
57
|
+
"aria-hidden": "true",
|
|
58
|
+
children: [
|
|
59
|
+
/* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }),
|
|
60
|
+
/* @__PURE__ */ jsx("path", { d: "M3 3v5h5" }),
|
|
61
|
+
/* @__PURE__ */ jsx("path", { d: "M12 7v5l3 2" })
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* Undo/redo buttons for the document edit view. Thin presentation over
|
|
66
|
+
* `useUndoRedo`, which owns the history: a host that wants different markup can
|
|
67
|
+
* call that hook directly instead of restyling this.
|
|
68
|
+
*/
|
|
69
|
+
const UndoRedoControls = (props) => {
|
|
70
|
+
const { debug = false } = props;
|
|
71
|
+
const { canRedo, canUndo, chords, fields, history, jumpTo, redo, revision, tracksSavedState, undo } = useUndoRedo(props);
|
|
72
|
+
const processing = useFormProcessing();
|
|
73
|
+
const { t } = useTranslation$1();
|
|
74
|
+
const isMac = useIsMac();
|
|
75
|
+
const [overlayOpen, setOverlayOpen] = useState(false);
|
|
76
|
+
/**
|
|
77
|
+
* Button labels, and the same labels with the bound chord appended for the
|
|
78
|
+
* tooltip. Only the first chord is shown: the rest are aliases for the same
|
|
79
|
+
* action, and listing them turns a hint into something to read.
|
|
80
|
+
*
|
|
81
|
+
* The shortcut stays out of the accessible name on purpose, since a screen
|
|
82
|
+
* reader announces `⇧⌘Z` as punctuation rather than as a key combination.
|
|
83
|
+
*/
|
|
84
|
+
const labels = useMemo(() => {
|
|
85
|
+
const withChord = (label, chord) => {
|
|
86
|
+
const hint = chord ? formatShortcut(chord, isMac) : "";
|
|
87
|
+
return hint ? `${label} (${hint})` : label;
|
|
88
|
+
};
|
|
89
|
+
const undoLabel = t(keys.undo);
|
|
90
|
+
const redoLabel = t(keys.redo);
|
|
91
|
+
return {
|
|
92
|
+
redo: redoLabel,
|
|
93
|
+
redoTooltip: withChord(redoLabel, chords?.redo[0]),
|
|
94
|
+
undo: undoLabel,
|
|
95
|
+
undoTooltip: withChord(undoLabel, chords?.undo[0])
|
|
96
|
+
};
|
|
97
|
+
}, [
|
|
98
|
+
chords,
|
|
99
|
+
isMac,
|
|
100
|
+
t
|
|
101
|
+
]);
|
|
102
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
103
|
+
className: baseClass,
|
|
104
|
+
children: [
|
|
105
|
+
/* @__PURE__ */ jsx(Button, {
|
|
106
|
+
buttonStyle: "subtle",
|
|
107
|
+
className: `${baseClass}__button ${baseClass}__undo`,
|
|
108
|
+
disabled: !canUndo || processing,
|
|
109
|
+
extraButtonProps: { "aria-label": labels.undo },
|
|
110
|
+
margin: false,
|
|
111
|
+
onClick: undo,
|
|
112
|
+
tooltip: labels.undoTooltip,
|
|
113
|
+
children: /* @__PURE__ */ jsx(UndoIcon, {})
|
|
114
|
+
}),
|
|
115
|
+
/* @__PURE__ */ jsx(Button, {
|
|
116
|
+
buttonStyle: "subtle",
|
|
117
|
+
className: `${baseClass}__button ${baseClass}__redo`,
|
|
118
|
+
disabled: !canRedo || processing,
|
|
119
|
+
extraButtonProps: { "aria-label": labels.redo },
|
|
120
|
+
margin: false,
|
|
121
|
+
onClick: redo,
|
|
122
|
+
tooltip: labels.redoTooltip,
|
|
123
|
+
children: /* @__PURE__ */ jsx(RedoIcon, {})
|
|
124
|
+
}),
|
|
125
|
+
debug ? /* @__PURE__ */ jsx(Button, {
|
|
126
|
+
buttonStyle: "subtle",
|
|
127
|
+
className: [
|
|
128
|
+
`${baseClass}__button`,
|
|
129
|
+
`${baseClass}__debug`,
|
|
130
|
+
overlayOpen ? `${baseClass}__button--active` : ""
|
|
131
|
+
].filter(Boolean).join(" "),
|
|
132
|
+
extraButtonProps: {
|
|
133
|
+
"aria-expanded": overlayOpen,
|
|
134
|
+
"aria-label": t(keys.debug)
|
|
135
|
+
},
|
|
136
|
+
margin: false,
|
|
137
|
+
onClick: () => setOverlayOpen((open) => !open),
|
|
138
|
+
tooltip: t(keys.debugTooltip),
|
|
139
|
+
children: /* @__PURE__ */ jsx(HistoryIcon, {})
|
|
140
|
+
}) : null,
|
|
141
|
+
debug && overlayOpen ? /* @__PURE__ */ jsx(HistoryDebugOverlay, {
|
|
142
|
+
fields,
|
|
143
|
+
history,
|
|
144
|
+
onClose: () => setOverlayOpen(false),
|
|
145
|
+
onJump: jumpTo,
|
|
146
|
+
revision,
|
|
147
|
+
tracksSavedState
|
|
148
|
+
}) : null
|
|
149
|
+
]
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
//#endregion
|
|
153
|
+
export { UndoRedoControls };
|
|
154
|
+
|
|
155
|
+
//# sourceMappingURL=UndoRedoControls.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UndoRedoControls.js","names":["useTranslation"],"sources":["../../src/client/UndoRedoControls.tsx"],"sourcesContent":["'use client'\nimport { Button, useFormProcessing } from '@payloadcms/ui'\nimport type React from 'react'\nimport { useEffect, useMemo, useState } from 'react'\n\nimport type { ControlsClientProps } from '../plugin/options'\nimport { keys } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport { formatShortcut, isMacPlatform } from './formatShortcut'\nimport { HistoryDebugOverlay } from './HistoryDebugOverlay'\nimport { useUndoRedo } from './useUndoRedo'\nimport './undoRedoControls.css'\n\nconst baseClass = 'undo-redo-controls'\n\n/**\n * Resolved after mount rather than during render: the server render has no\n * `navigator`, so deciding there would either hydrate into a mismatch or lock\n * every user to the non-mac notation. The value only feeds tooltip text, which\n * nobody can hover before the first paint.\n */\nconst useIsMac = (): boolean => {\n\tconst [isMac, setIsMac] = useState(false)\n\tuseEffect(() => setIsMac(isMacPlatform()), [])\n\treturn isMac\n}\n\nconst UndoIcon: React.FC = () => (\n\t<svg\n\t\twidth=\"18\"\n\t\theight=\"18\"\n\t\tviewBox=\"0 0 24 24\"\n\t\tfill=\"none\"\n\t\tstroke=\"currentColor\"\n\t\tstrokeWidth=\"2\"\n\t\tstrokeLinecap=\"round\"\n\t\tstrokeLinejoin=\"round\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<path d=\"M3 7v6h6\" />\n\t\t<path d=\"M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13\" />\n\t</svg>\n)\n\nconst RedoIcon: React.FC = () => (\n\t<svg\n\t\twidth=\"18\"\n\t\theight=\"18\"\n\t\tviewBox=\"0 0 24 24\"\n\t\tfill=\"none\"\n\t\tstroke=\"currentColor\"\n\t\tstrokeWidth=\"2\"\n\t\tstrokeLinecap=\"round\"\n\t\tstrokeLinejoin=\"round\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<path d=\"M21 7v6h-6\" />\n\t\t<path d=\"M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13\" />\n\t</svg>\n)\n\nconst HistoryIcon: React.FC = () => (\n\t<svg\n\t\twidth=\"18\"\n\t\theight=\"18\"\n\t\tviewBox=\"0 0 24 24\"\n\t\tfill=\"none\"\n\t\tstroke=\"currentColor\"\n\t\tstrokeWidth=\"2\"\n\t\tstrokeLinecap=\"round\"\n\t\tstrokeLinejoin=\"round\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<path d=\"M3 12a9 9 0 1 0 3-6.7L3 8\" />\n\t\t<path d=\"M3 3v5h5\" />\n\t\t<path d=\"M12 7v5l3 2\" />\n\t</svg>\n)\n\n/**\n * Every setting is optional so the component stays usable when a host mounts it\n * by hand (`autoMount: false`) rather than through the plugin, which always\n * passes a fully resolved set.\n */\nexport type UndoRedoControlsProps = Partial<ControlsClientProps>\n\n/**\n * Undo/redo buttons for the document edit view. Thin presentation over\n * `useUndoRedo`, which owns the history: a host that wants different markup can\n * call that hook directly instead of restyling this.\n */\nexport const UndoRedoControls: React.FC<UndoRedoControlsProps> = (props) => {\n\tconst { debug = false } = props\n\tconst {\n\t\tcanRedo,\n\t\tcanUndo,\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} = useUndoRedo(props)\n\tconst processing = useFormProcessing()\n\tconst { t } = useTranslation()\n\tconst isMac = useIsMac()\n\tconst [overlayOpen, setOverlayOpen] = useState(false)\n\n\t/**\n\t * Button labels, and the same labels with the bound chord appended for the\n\t * tooltip. Only the first chord is shown: the rest are aliases for the same\n\t * action, and listing them turns a hint into something to read.\n\t *\n\t * The shortcut stays out of the accessible name on purpose, since a screen\n\t * reader announces `⇧⌘Z` as punctuation rather than as a key combination.\n\t */\n\tconst labels = useMemo(() => {\n\t\tconst withChord = (label: string, chord: string | undefined) => {\n\t\t\tconst hint = chord ? formatShortcut(chord, isMac) : ''\n\t\t\treturn hint ? `${label} (${hint})` : label\n\t\t}\n\t\tconst undoLabel = t(keys.undo)\n\t\tconst redoLabel = t(keys.redo)\n\t\treturn {\n\t\t\tredo: redoLabel,\n\t\t\tredoTooltip: withChord(redoLabel, chords?.redo[0]),\n\t\t\tundo: undoLabel,\n\t\t\tundoTooltip: withChord(undoLabel, chords?.undo[0]),\n\t\t}\n\t}, [chords, isMac, t])\n\n\treturn (\n\t\t<div className={baseClass}>\n\t\t\t<Button\n\t\t\t\tbuttonStyle=\"subtle\"\n\t\t\t\tclassName={`${baseClass}__button ${baseClass}__undo`}\n\t\t\t\tdisabled={!canUndo || processing}\n\t\t\t\t// Button's own `aria-label` prop is also written to `title`, and the\n\t\t\t\t// native title tooltip then covers Payload's. Going through\n\t\t\t\t// `extraButtonProps`, which is spread last, sets the accessible name\n\t\t\t\t// without the title.\n\t\t\t\textraButtonProps={{ 'aria-label': labels.undo }}\n\t\t\t\tmargin={false}\n\t\t\t\tonClick={undo}\n\t\t\t\ttooltip={labels.undoTooltip}\n\t\t\t>\n\t\t\t\t<UndoIcon />\n\t\t\t</Button>\n\t\t\t<Button\n\t\t\t\tbuttonStyle=\"subtle\"\n\t\t\t\tclassName={`${baseClass}__button ${baseClass}__redo`}\n\t\t\t\tdisabled={!canRedo || processing}\n\t\t\t\textraButtonProps={{ 'aria-label': labels.redo }}\n\t\t\t\tmargin={false}\n\t\t\t\tonClick={redo}\n\t\t\t\ttooltip={labels.redoTooltip}\n\t\t\t>\n\t\t\t\t<RedoIcon />\n\t\t\t</Button>\n\t\t\t{debug ? (\n\t\t\t\t<Button\n\t\t\t\t\tbuttonStyle=\"subtle\"\n\t\t\t\t\tclassName={[\n\t\t\t\t\t\t`${baseClass}__button`,\n\t\t\t\t\t\t`${baseClass}__debug`,\n\t\t\t\t\t\toverlayOpen ? `${baseClass}__button--active` : '',\n\t\t\t\t\t]\n\t\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t\t.join(' ')}\n\t\t\t\t\textraButtonProps={{ 'aria-expanded': overlayOpen, 'aria-label': t(keys.debug) }}\n\t\t\t\t\tmargin={false}\n\t\t\t\t\tonClick={() => setOverlayOpen((open) => !open)}\n\t\t\t\t\ttooltip={t(keys.debugTooltip)}\n\t\t\t\t>\n\t\t\t\t\t<HistoryIcon />\n\t\t\t\t</Button>\n\t\t\t) : null}\n\t\t\t{debug && overlayOpen ? (\n\t\t\t\t<HistoryDebugOverlay\n\t\t\t\t\tfields={fields}\n\t\t\t\t\thistory={history}\n\t\t\t\t\tonClose={() => setOverlayOpen(false)}\n\t\t\t\t\tonJump={jumpTo}\n\t\t\t\t\trevision={revision}\n\t\t\t\t\ttracksSavedState={tracksSavedState}\n\t\t\t\t/>\n\t\t\t) : null}\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
|