@prenta/admin 2.0.3 → 2.2.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 +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -6
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/document-locale-switcher.test.d.ts +2 -0
- package/dist/__tests__/components/document-locale-switcher.test.d.ts.map +1 -0
- package/dist/__tests__/components/document-locale-switcher.test.js +78 -0
- package/dist/__tests__/components/document-locale-switcher.test.js.map +1 -0
- package/dist/__tests__/lib/admin-shortcuts.test.d.ts +2 -0
- package/dist/__tests__/lib/admin-shortcuts.test.d.ts.map +1 -0
- package/dist/__tests__/lib/admin-shortcuts.test.js +11 -0
- package/dist/__tests__/lib/admin-shortcuts.test.js.map +1 -0
- package/dist/__tests__/lib/editor-history.test.d.ts +2 -0
- package/dist/__tests__/lib/editor-history.test.d.ts.map +1 -0
- package/dist/__tests__/lib/editor-history.test.js +76 -0
- package/dist/__tests__/lib/editor-history.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +17 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +16 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/components/DocumentLocaleSwitcher.d.ts +15 -0
- package/dist/components/DocumentLocaleSwitcher.d.ts.map +1 -0
- package/dist/components/DocumentLocaleSwitcher.js +66 -0
- package/dist/components/DocumentLocaleSwitcher.js.map +1 -0
- package/dist/hooks/use-editor-history.d.ts +19 -0
- package/dist/hooks/use-editor-history.d.ts.map +1 -0
- package/dist/hooks/use-editor-history.js +94 -0
- package/dist/hooks/use-editor-history.js.map +1 -0
- package/dist/lib/admin-shortcuts.d.ts +6 -0
- package/dist/lib/admin-shortcuts.d.ts.map +1 -0
- package/dist/lib/admin-shortcuts.js +11 -0
- package/dist/lib/admin-shortcuts.js.map +1 -0
- package/dist/lib/editor-history.d.ts +24 -0
- package/dist/lib/editor-history.d.ts.map +1 -0
- package/dist/lib/editor-history.js +61 -0
- package/dist/lib/editor-history.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +9 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +3 -3
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +38 -9
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +45 -11
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +5 -9
- package/src/__tests__/components/document-locale-switcher.test.tsx +111 -0
- package/src/__tests__/lib/admin-shortcuts.test.ts +11 -0
- package/src/__tests__/lib/editor-history.test.ts +99 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +27 -0
- package/src/__tests__/views/post-section-editor.test.tsx +30 -0
- package/src/components/DocumentLocaleSwitcher.tsx +140 -0
- package/src/hooks/use-editor-history.ts +102 -0
- package/src/lib/admin-shortcuts.ts +10 -0
- package/src/lib/editor-history.ts +83 -0
- package/src/views/page-editor/EditorTopBar.tsx +39 -0
- package/src/views/page-editor/PageSectionEditor.tsx +65 -10
- package/src/views/post-editor/PostSectionEditor.tsx +76 -14
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
createEditorHistory,
|
|
6
|
+
isNativeEditingTarget,
|
|
7
|
+
pushEditorHistory,
|
|
8
|
+
redoEditorHistory,
|
|
9
|
+
undoEditorHistory,
|
|
10
|
+
} from '../lib/editor-history.js'
|
|
11
|
+
|
|
12
|
+
function cloneSnapshot<T>(value: T): T {
|
|
13
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Snapshot stack for the section editor. `note` records the document from
|
|
18
|
+
* before a change and is safe to call inside a state updater (it only writes
|
|
19
|
+
* a ref). The stack commits after that render. Rapid notes with the same key
|
|
20
|
+
* collapse into one step.
|
|
21
|
+
*/
|
|
22
|
+
export function useEditorHistory<T>(current: T | null, resetKey: string | null) {
|
|
23
|
+
const historyRef = useRef(createEditorHistory<T>())
|
|
24
|
+
const pendingRef = useRef<Array<{ snapshot: T; key: string; coalesce: boolean }>>([])
|
|
25
|
+
const [, setRevision] = useState(0)
|
|
26
|
+
|
|
27
|
+
const note = useCallback((snapshot: T, key: string, coalesce = false) => {
|
|
28
|
+
pendingRef.current.push({ snapshot: cloneSnapshot(snapshot), key, coalesce })
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
historyRef.current = createEditorHistory()
|
|
33
|
+
pendingRef.current = []
|
|
34
|
+
setRevision((n) => n + 1)
|
|
35
|
+
}, [resetKey])
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (pendingRef.current.length === 0) return
|
|
39
|
+
const now = Date.now()
|
|
40
|
+
let next = historyRef.current
|
|
41
|
+
for (const item of pendingRef.current) {
|
|
42
|
+
next = pushEditorHistory(next, item.snapshot, {
|
|
43
|
+
key: item.key,
|
|
44
|
+
now,
|
|
45
|
+
coalesce: item.coalesce,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
pendingRef.current = []
|
|
49
|
+
historyRef.current = next
|
|
50
|
+
setRevision((n) => n + 1)
|
|
51
|
+
}, [current])
|
|
52
|
+
|
|
53
|
+
const undo = useCallback((): T | null => {
|
|
54
|
+
if (current == null) return null
|
|
55
|
+
const result = undoEditorHistory(historyRef.current, cloneSnapshot(current))
|
|
56
|
+
if (!result) return null
|
|
57
|
+
historyRef.current = result.history
|
|
58
|
+
setRevision((n) => n + 1)
|
|
59
|
+
return result.value
|
|
60
|
+
}, [current])
|
|
61
|
+
|
|
62
|
+
const redo = useCallback((): T | null => {
|
|
63
|
+
if (current == null) return null
|
|
64
|
+
const result = redoEditorHistory(historyRef.current, cloneSnapshot(current))
|
|
65
|
+
if (!result) return null
|
|
66
|
+
historyRef.current = result.history
|
|
67
|
+
setRevision((n) => n + 1)
|
|
68
|
+
return result.value
|
|
69
|
+
}, [current])
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
note,
|
|
73
|
+
undo,
|
|
74
|
+
redo,
|
|
75
|
+
canUndo: historyRef.current.past.length > 0,
|
|
76
|
+
canRedo: historyRef.current.future.length > 0,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function useSectionEditorShortcuts(input: {
|
|
81
|
+
enabled: boolean
|
|
82
|
+
onUndo: () => void
|
|
83
|
+
onRedo: () => void
|
|
84
|
+
}) {
|
|
85
|
+
const { enabled, onUndo, onRedo } = input
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!enabled) return
|
|
88
|
+
function onKey(event: KeyboardEvent) {
|
|
89
|
+
if (isNativeEditingTarget(event.target)) return
|
|
90
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return
|
|
91
|
+
const key = event.key.toLowerCase()
|
|
92
|
+
const redoKey = key === 'y' || (key === 'z' && event.shiftKey)
|
|
93
|
+
const undoKey = key === 'z' && !event.shiftKey
|
|
94
|
+
if (!undoKey && !redoKey) return
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
if (redoKey) onRedo()
|
|
97
|
+
else onUndo()
|
|
98
|
+
}
|
|
99
|
+
document.addEventListener('keydown', onKey)
|
|
100
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
101
|
+
}, [enabled, onUndo, onRedo])
|
|
102
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Shown in the keyboard-shortcuts dialog (⌘ /). */
|
|
2
|
+
export const ADMIN_KEYBOARD_SHORTCUTS: ReadonlyArray<{ keys: string; label: string }> = [
|
|
3
|
+
{ keys: '⌘ K', label: 'Open search' },
|
|
4
|
+
{ keys: '⌘ S', label: 'Save document' },
|
|
5
|
+
{ keys: '⌘ Z', label: 'Undo in the section editor' },
|
|
6
|
+
{ keys: '⌘ ⇧ Z', label: 'Redo in the section editor' },
|
|
7
|
+
{ keys: '⌘ Y', label: 'Redo in the section editor' },
|
|
8
|
+
{ keys: '⌘ /', label: 'Toggle this help' },
|
|
9
|
+
{ keys: 'Esc', label: 'Close panel / modal' },
|
|
10
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** How long edits with the same key collapse into one undo step. */
|
|
2
|
+
const EDITOR_HISTORY_COALESCE_MS = 800
|
|
3
|
+
|
|
4
|
+
/** Cap so a long editing session cannot grow the stack without bound. */
|
|
5
|
+
const EDITOR_HISTORY_LIMIT = 50
|
|
6
|
+
|
|
7
|
+
interface EditorHistory<T> {
|
|
8
|
+
past: T[]
|
|
9
|
+
future: T[]
|
|
10
|
+
lastKey: string | null
|
|
11
|
+
lastAt: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createEditorHistory<T>(): EditorHistory<T> {
|
|
15
|
+
return { past: [], future: [], lastKey: null, lastAt: 0 }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pushEditorHistory<T>(
|
|
19
|
+
history: EditorHistory<T>,
|
|
20
|
+
snapshot: T,
|
|
21
|
+
options: { key: string; now: number; coalesce?: boolean },
|
|
22
|
+
): EditorHistory<T> {
|
|
23
|
+
const coalesce = options.coalesce === true
|
|
24
|
+
if (
|
|
25
|
+
coalesce &&
|
|
26
|
+
history.lastKey === options.key &&
|
|
27
|
+
history.past.length > 0 &&
|
|
28
|
+
options.now - history.lastAt < EDITOR_HISTORY_COALESCE_MS
|
|
29
|
+
) {
|
|
30
|
+
return { ...history, lastAt: options.now }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const past = [...history.past, snapshot]
|
|
34
|
+
return {
|
|
35
|
+
past:
|
|
36
|
+
past.length > EDITOR_HISTORY_LIMIT ? past.slice(past.length - EDITOR_HISTORY_LIMIT) : past,
|
|
37
|
+
future: [],
|
|
38
|
+
lastKey: options.key,
|
|
39
|
+
lastAt: options.now,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function undoEditorHistory<T>(
|
|
44
|
+
history: EditorHistory<T>,
|
|
45
|
+
current: T,
|
|
46
|
+
): { history: EditorHistory<T>; value: T } | null {
|
|
47
|
+
if (history.past.length === 0) return null
|
|
48
|
+
const value = history.past[history.past.length - 1] as T
|
|
49
|
+
return {
|
|
50
|
+
value,
|
|
51
|
+
history: {
|
|
52
|
+
past: history.past.slice(0, -1),
|
|
53
|
+
future: [...history.future, current],
|
|
54
|
+
lastKey: null,
|
|
55
|
+
lastAt: 0,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function redoEditorHistory<T>(
|
|
61
|
+
history: EditorHistory<T>,
|
|
62
|
+
current: T,
|
|
63
|
+
): { history: EditorHistory<T>; value: T } | null {
|
|
64
|
+
if (history.future.length === 0) return null
|
|
65
|
+
const value = history.future[history.future.length - 1] as T
|
|
66
|
+
return {
|
|
67
|
+
value,
|
|
68
|
+
history: {
|
|
69
|
+
past: [...history.past, current],
|
|
70
|
+
future: history.future.slice(0, -1),
|
|
71
|
+
lastKey: null,
|
|
72
|
+
lastAt: 0,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Text fields and rich text keep their own undo. The page stack must not steal it. */
|
|
78
|
+
export function isNativeEditingTarget(target: EventTarget | null): boolean {
|
|
79
|
+
if (!(target instanceof HTMLElement)) return false
|
|
80
|
+
if (target.isContentEditable) return true
|
|
81
|
+
const tag = target.tagName
|
|
82
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
|
83
|
+
}
|
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
ExternalLink,
|
|
10
10
|
History,
|
|
11
11
|
Loader2,
|
|
12
|
+
Redo2,
|
|
12
13
|
Settings2,
|
|
13
14
|
Share2,
|
|
14
15
|
TriangleAlert,
|
|
16
|
+
Undo2,
|
|
15
17
|
} from 'lucide-react'
|
|
16
18
|
import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
|
|
17
19
|
import { BreakpointPicker } from '../../components/BreakpointPicker.js'
|
|
@@ -99,6 +101,13 @@ interface EditorTopBarProps {
|
|
|
99
101
|
/** Band for `seoScore`; omit to derive from the content-analysis scale. */
|
|
100
102
|
seoScoreBand?: 'good' | 'fair' | 'poor'
|
|
101
103
|
onSaveDraft: () => void
|
|
104
|
+
/** Section-editor undo. Omitted outside the page and post editors. */
|
|
105
|
+
onUndo?: () => void
|
|
106
|
+
onRedo?: () => void
|
|
107
|
+
canUndo?: boolean
|
|
108
|
+
canRedo?: boolean
|
|
109
|
+
/** Locale switcher for this document. Hidden by the caller when unused. */
|
|
110
|
+
localeControl?: ReactNode
|
|
102
111
|
onSubmitForReview: () => void
|
|
103
112
|
onApprove: () => void
|
|
104
113
|
onRequestChanges: (note: string) => void
|
|
@@ -149,6 +158,11 @@ export function EditorTopBar({
|
|
|
149
158
|
seoScore,
|
|
150
159
|
seoScoreBand,
|
|
151
160
|
onSaveDraft,
|
|
161
|
+
onUndo,
|
|
162
|
+
onRedo,
|
|
163
|
+
canUndo = false,
|
|
164
|
+
canRedo = false,
|
|
165
|
+
localeControl,
|
|
152
166
|
onSubmitForReview,
|
|
153
167
|
onApprove,
|
|
154
168
|
onRequestChanges,
|
|
@@ -290,6 +304,31 @@ export function EditorTopBar({
|
|
|
290
304
|
</span>
|
|
291
305
|
)}
|
|
292
306
|
<SaveStatus state={saveState} dirty={dirty} />
|
|
307
|
+
{onUndo && onRedo && (
|
|
308
|
+
<span className="ml-1 inline-flex shrink-0 items-center">
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={onUndo}
|
|
312
|
+
disabled={!canUndo}
|
|
313
|
+
aria-label="Undo"
|
|
314
|
+
title="Undo the last edit (Ctrl+Z or ⌘Z). Text fields keep their own undo."
|
|
315
|
+
className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
|
|
316
|
+
>
|
|
317
|
+
<Undo2 className="h-4 w-4" aria-hidden />
|
|
318
|
+
</button>
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={onRedo}
|
|
322
|
+
disabled={!canRedo}
|
|
323
|
+
aria-label="Redo"
|
|
324
|
+
title="Redo (Ctrl+Shift+Z, Ctrl+Y, or ⌘⇧Z)."
|
|
325
|
+
className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
|
|
326
|
+
>
|
|
327
|
+
<Redo2 className="h-4 w-4" aria-hidden />
|
|
328
|
+
</button>
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
{localeControl ? <span className="ml-1 inline-flex shrink-0">{localeControl}</span> : null}
|
|
293
332
|
</div>
|
|
294
333
|
|
|
295
334
|
{/* Right controls */}
|
|
@@ -9,9 +9,11 @@ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js
|
|
|
9
9
|
import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
|
|
10
10
|
import { EditorRail, RailHeader } from '../../components/EditorRail.js'
|
|
11
11
|
import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
|
|
12
|
+
import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
|
|
12
13
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
13
14
|
import { VersionHistoryPanel } from './VersionHistoryPanel.js'
|
|
14
15
|
import { useDebouncedAutosave } from './autosave.js'
|
|
16
|
+
import { useEditorHistory, useSectionEditorShortcuts } from '../../hooks/use-editor-history.js'
|
|
15
17
|
import { ReviewBar } from './ReviewBar.js'
|
|
16
18
|
import {
|
|
17
19
|
fetchPageVersions,
|
|
@@ -215,11 +217,14 @@ export function PageSectionEditor({
|
|
|
215
217
|
* so a save moves the baseline exactly as a publish does. Missing one would
|
|
216
218
|
* leave the guard comparing against content that is no longer on the site.
|
|
217
219
|
*/
|
|
220
|
+
const savedJson = useRef('')
|
|
218
221
|
const acceptPage = useCallback((p: EditorPage) => {
|
|
222
|
+
savedJson.current = JSON.stringify(p)
|
|
219
223
|
setPage(p)
|
|
220
224
|
setBaseline(snapshotBaseline(p.sections, p.status))
|
|
221
225
|
setPublishedPath(p.status === 'PUBLISHED' ? p.path : null)
|
|
222
226
|
}, [])
|
|
227
|
+
const { note, undo, redo, canUndo, canRedo } = useEditorHistory(page, documentId ?? null)
|
|
223
228
|
const [dirty, setDirty] = useState(false)
|
|
224
229
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
225
230
|
const [publishing, setPublishing] = useState(false)
|
|
@@ -486,23 +491,35 @@ export function PageSectionEditor({
|
|
|
486
491
|
|
|
487
492
|
// ─── Section mutations ───────────────────────────────────────────────
|
|
488
493
|
const mutateSections = useCallback(
|
|
489
|
-
(
|
|
490
|
-
|
|
494
|
+
(
|
|
495
|
+
fn: (sections: EditorPage['sections']) => EditorPage['sections'],
|
|
496
|
+
key: string,
|
|
497
|
+
coalesce = false,
|
|
498
|
+
) => {
|
|
499
|
+
setPage((prev) => {
|
|
500
|
+
if (!prev) return prev
|
|
501
|
+
note(prev, key, coalesce)
|
|
502
|
+
return { ...prev, sections: fn(prev.sections) }
|
|
503
|
+
})
|
|
491
504
|
setDirty(true)
|
|
492
505
|
setSaveState('idle')
|
|
493
506
|
},
|
|
494
|
-
[],
|
|
507
|
+
[note],
|
|
495
508
|
)
|
|
496
509
|
|
|
497
510
|
const handleToggleVisibility = useCallback(
|
|
498
|
-
(id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
|
|
511
|
+
(id: string) => mutateSections((s) => toggleSectionVisibility(s, id), `visibility:${id}`),
|
|
499
512
|
[mutateSections],
|
|
500
513
|
)
|
|
501
514
|
|
|
502
515
|
const handleContentChange = useCallback(
|
|
503
516
|
(patch: Record<string, unknown>) => {
|
|
504
517
|
if (!selectedId) return
|
|
505
|
-
mutateSections(
|
|
518
|
+
mutateSections(
|
|
519
|
+
(s) => updateSectionContent(s, selectedId, patch),
|
|
520
|
+
`content:${selectedId}`,
|
|
521
|
+
true,
|
|
522
|
+
)
|
|
506
523
|
},
|
|
507
524
|
[selectedId, mutateSections],
|
|
508
525
|
)
|
|
@@ -517,18 +534,41 @@ export function PageSectionEditor({
|
|
|
517
534
|
const handleMetaChange = useCallback(
|
|
518
535
|
(patch: { name?: string; internalLabel?: string }) => {
|
|
519
536
|
if (!selectedId) return
|
|
520
|
-
mutateSections((s) => updateSectionMeta(s, selectedId, patch))
|
|
537
|
+
mutateSections((s) => updateSectionMeta(s, selectedId, patch), `meta:${selectedId}`, true)
|
|
521
538
|
},
|
|
522
539
|
[selectedId, mutateSections],
|
|
523
540
|
)
|
|
524
541
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
setPage(
|
|
528
|
-
setDirty(
|
|
542
|
+
const restoreEdit = useCallback((next: EditorPage | null) => {
|
|
543
|
+
if (!next) return
|
|
544
|
+
setPage(next)
|
|
545
|
+
setDirty(JSON.stringify(next) !== savedJson.current)
|
|
529
546
|
setSaveState('idle')
|
|
530
547
|
}, [])
|
|
531
548
|
|
|
549
|
+
const handleUndo = useCallback(() => restoreEdit(undo()), [restoreEdit, undo])
|
|
550
|
+
const handleRedo = useCallback(() => restoreEdit(redo()), [restoreEdit, redo])
|
|
551
|
+
|
|
552
|
+
useSectionEditorShortcuts({
|
|
553
|
+
enabled: effectiveCanEdit,
|
|
554
|
+
onUndo: handleUndo,
|
|
555
|
+
onRedo: handleRedo,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
// ─── Page-level settings ─────────────────────────────────────────────
|
|
559
|
+
const handlePageSettings = useCallback(
|
|
560
|
+
(patch: Partial<EditorPage>) => {
|
|
561
|
+
setPage((prev) => {
|
|
562
|
+
if (!prev) return prev
|
|
563
|
+
note(prev, 'page', true)
|
|
564
|
+
return { ...prev, ...patch }
|
|
565
|
+
})
|
|
566
|
+
setDirty(true)
|
|
567
|
+
setSaveState('idle')
|
|
568
|
+
},
|
|
569
|
+
[note],
|
|
570
|
+
)
|
|
571
|
+
|
|
532
572
|
// ─── Save / publish ──────────────────────────────────────────────────
|
|
533
573
|
/**
|
|
534
574
|
* Persist robots directives. They live on the SEO record, not the document
|
|
@@ -950,6 +990,21 @@ export function PageSectionEditor({
|
|
|
950
990
|
typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
|
|
951
991
|
}
|
|
952
992
|
onSaveDraft={handleSaveDraft}
|
|
993
|
+
onUndo={effectiveCanEdit ? handleUndo : undefined}
|
|
994
|
+
onRedo={effectiveCanEdit ? handleRedo : undefined}
|
|
995
|
+
canUndo={canUndo}
|
|
996
|
+
canRedo={canRedo}
|
|
997
|
+
localeControl={
|
|
998
|
+
page.id ? (
|
|
999
|
+
<DocumentLocaleSwitcher
|
|
1000
|
+
collection={PAGES_COLLECTION}
|
|
1001
|
+
documentId={page.id}
|
|
1002
|
+
canCreate={effectiveCanEdit}
|
|
1003
|
+
editorPath={(id) => `/pages/${id}/edit`}
|
|
1004
|
+
onNavigate={guardedNavigate}
|
|
1005
|
+
/>
|
|
1006
|
+
) : null
|
|
1007
|
+
}
|
|
953
1008
|
onSubmitForReview={handleSubmitForReview}
|
|
954
1009
|
onApprove={handleApprove}
|
|
955
1010
|
onRequestChanges={handleRequestChanges}
|
|
@@ -9,9 +9,11 @@ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js
|
|
|
9
9
|
import { EditorRail, RailHeader } from '../../components/EditorRail.js'
|
|
10
10
|
import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
|
|
11
11
|
import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
|
|
12
|
+
import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
|
|
12
13
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
13
14
|
import { VersionHistoryPanel } from '../page-editor/VersionHistoryPanel.js'
|
|
14
15
|
import { useDebouncedAutosave } from '../page-editor/autosave.js'
|
|
16
|
+
import { useEditorHistory, useSectionEditorShortcuts } from '../../hooks/use-editor-history.js'
|
|
15
17
|
import { ReviewBar } from '../page-editor/ReviewBar.js'
|
|
16
18
|
import {
|
|
17
19
|
fetchPageVersions,
|
|
@@ -182,10 +184,13 @@ export function PostSectionEditor({
|
|
|
182
184
|
* as a publish does; missing one would leave the guard comparing against
|
|
183
185
|
* content no longer on the site.
|
|
184
186
|
*/
|
|
187
|
+
const savedJson = useRef('')
|
|
185
188
|
const acceptPost = useCallback((p: EditorPost) => {
|
|
189
|
+
savedJson.current = JSON.stringify(p)
|
|
186
190
|
setPost(p)
|
|
187
191
|
setBaseline(snapshotBaseline(p.sections, p.status))
|
|
188
192
|
}, [])
|
|
193
|
+
const { note, undo, redo, canUndo, canRedo } = useEditorHistory(post, documentId ?? null)
|
|
189
194
|
|
|
190
195
|
const { canEdit } = useMemo(() => deriveCan(session), [session])
|
|
191
196
|
const sessionRole = String(session?.user?.role ?? '')
|
|
@@ -393,23 +398,35 @@ export function PostSectionEditor({
|
|
|
393
398
|
|
|
394
399
|
// ─── Section mutations ───────────────────────────────────────────────
|
|
395
400
|
const mutateSections = useCallback(
|
|
396
|
-
(
|
|
397
|
-
|
|
401
|
+
(
|
|
402
|
+
fn: (sections: EditorPost['sections']) => EditorPost['sections'],
|
|
403
|
+
key: string,
|
|
404
|
+
coalesce = false,
|
|
405
|
+
) => {
|
|
406
|
+
setPost((prev) => {
|
|
407
|
+
if (!prev) return prev
|
|
408
|
+
note(prev, key, coalesce)
|
|
409
|
+
return { ...prev, sections: fn(prev.sections) }
|
|
410
|
+
})
|
|
398
411
|
setDirty(true)
|
|
399
412
|
setSaveState('idle')
|
|
400
413
|
},
|
|
401
|
-
[],
|
|
414
|
+
[note],
|
|
402
415
|
)
|
|
403
416
|
|
|
404
417
|
const handleToggleVisibility = useCallback(
|
|
405
|
-
(id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
|
|
418
|
+
(id: string) => mutateSections((s) => toggleSectionVisibility(s, id), `visibility:${id}`),
|
|
406
419
|
[mutateSections],
|
|
407
420
|
)
|
|
408
421
|
|
|
409
422
|
const handleContentChange = useCallback(
|
|
410
423
|
(patch: Record<string, unknown>) => {
|
|
411
424
|
if (!selectedId) return
|
|
412
|
-
mutateSections(
|
|
425
|
+
mutateSections(
|
|
426
|
+
(s) => updateSectionContent(s, selectedId, patch),
|
|
427
|
+
`content:${selectedId}`,
|
|
428
|
+
true,
|
|
429
|
+
)
|
|
413
430
|
},
|
|
414
431
|
[selectedId, mutateSections],
|
|
415
432
|
)
|
|
@@ -419,22 +436,46 @@ export function PostSectionEditor({
|
|
|
419
436
|
const handleMetaChange = useCallback(
|
|
420
437
|
(patch: { name?: string; internalLabel?: string }) => {
|
|
421
438
|
if (!selectedId) return
|
|
422
|
-
mutateSections((s) => updateSectionMeta(s, selectedId, patch))
|
|
439
|
+
mutateSections((s) => updateSectionMeta(s, selectedId, patch), `meta:${selectedId}`, true)
|
|
423
440
|
},
|
|
424
441
|
[selectedId, mutateSections],
|
|
425
442
|
)
|
|
426
443
|
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
444
|
+
const restoreEdit = useCallback((next: EditorPost | null) => {
|
|
445
|
+
if (!next) return
|
|
446
|
+
setPost(next)
|
|
447
|
+
setDirty(JSON.stringify(next) !== savedJson.current)
|
|
430
448
|
setSaveState('idle')
|
|
431
449
|
}, [])
|
|
432
450
|
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
451
|
+
const handleUndo = useCallback(() => restoreEdit(undo()), [restoreEdit, undo])
|
|
452
|
+
const handleRedo = useCallback(() => restoreEdit(redo()), [restoreEdit, redo])
|
|
453
|
+
|
|
454
|
+
const handleFields = useCallback(
|
|
455
|
+
(patch: Partial<EditorPost>) => {
|
|
456
|
+
setPost((prev) => {
|
|
457
|
+
if (!prev) return prev
|
|
458
|
+
note(prev, 'fields', true)
|
|
459
|
+
return { ...prev, ...patch }
|
|
460
|
+
})
|
|
461
|
+
setDirty(true)
|
|
462
|
+
setSaveState('idle')
|
|
463
|
+
},
|
|
464
|
+
[note],
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
const handlePostBodyChange = useCallback(
|
|
468
|
+
(body: string) => {
|
|
469
|
+
setPost((prev) => {
|
|
470
|
+
if (!prev) return prev
|
|
471
|
+
note(prev, 'body', true)
|
|
472
|
+
return { ...prev, body }
|
|
473
|
+
})
|
|
474
|
+
setDirty(true)
|
|
475
|
+
setSaveState('idle')
|
|
476
|
+
},
|
|
477
|
+
[note],
|
|
478
|
+
)
|
|
438
479
|
|
|
439
480
|
// ─── Save / publish ──────────────────────────────────────────────────
|
|
440
481
|
const latestPost = useRef(post)
|
|
@@ -568,6 +609,12 @@ export function PostSectionEditor({
|
|
|
568
609
|
const effectiveCanEdit = canEdit && !reviewing
|
|
569
610
|
const canRestoreVersion = canEdit
|
|
570
611
|
|
|
612
|
+
useSectionEditorShortcuts({
|
|
613
|
+
enabled: effectiveCanEdit,
|
|
614
|
+
onUndo: handleUndo,
|
|
615
|
+
onRedo: handleRedo,
|
|
616
|
+
})
|
|
617
|
+
|
|
571
618
|
useDebouncedAutosave({
|
|
572
619
|
dirty,
|
|
573
620
|
enabled: Boolean(documentId) && effectiveCanEdit && !publishing,
|
|
@@ -814,6 +861,21 @@ export function PostSectionEditor({
|
|
|
814
861
|
typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
|
|
815
862
|
}
|
|
816
863
|
onSaveDraft={handleSaveDraft}
|
|
864
|
+
onUndo={effectiveCanEdit ? handleUndo : undefined}
|
|
865
|
+
onRedo={effectiveCanEdit ? handleRedo : undefined}
|
|
866
|
+
canUndo={canUndo}
|
|
867
|
+
canRedo={canRedo}
|
|
868
|
+
localeControl={
|
|
869
|
+
post.id ? (
|
|
870
|
+
<DocumentLocaleSwitcher
|
|
871
|
+
collection={postType}
|
|
872
|
+
documentId={post.id}
|
|
873
|
+
canCreate={effectiveCanEdit}
|
|
874
|
+
editorPath={(id) => `/posts/${postType}/${id}/edit`}
|
|
875
|
+
onNavigate={guardedNavigate}
|
|
876
|
+
/>
|
|
877
|
+
) : null
|
|
878
|
+
}
|
|
817
879
|
onSubmitForReview={handleSubmitForReview}
|
|
818
880
|
onApprove={handleApprove}
|
|
819
881
|
onRequestChanges={handleRequestChanges}
|