@payload-onpage/react 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/OnPageClient.d.ts +18 -0
- package/dist/OnPageClient.d.ts.map +1 -0
- package/dist/OnPageClient.js +51 -0
- package/dist/OnPageClient.js.map +1 -0
- package/dist/annotations.d.ts +55 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +60 -0
- package/dist/annotations.js.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +19 -0
- package/dist/context.js.map +1 -0
- package/dist/dom.d.ts +42 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +95 -0
- package/dist/dom.js.map +1 -0
- package/dist/editor/FieldHost.d.ts +5 -0
- package/dist/editor/FieldHost.d.ts.map +1 -0
- package/dist/editor/FieldHost.js +36 -0
- package/dist/editor/FieldHost.js.map +1 -0
- package/dist/editor/Ghosts.d.ts +6 -0
- package/dist/editor/Ghosts.d.ts.map +1 -0
- package/dist/editor/Ghosts.js +37 -0
- package/dist/editor/Ghosts.js.map +1 -0
- package/dist/editor/OnPageEditor.d.ts +25 -0
- package/dist/editor/OnPageEditor.d.ts.map +1 -0
- package/dist/editor/OnPageEditor.js +211 -0
- package/dist/editor/OnPageEditor.js.map +1 -0
- package/dist/editor/useGhosts.d.ts +24 -0
- package/dist/editor/useGhosts.d.ts.map +1 -0
- package/dist/editor/useGhosts.js +34 -0
- package/dist/editor/useGhosts.js.map +1 -0
- package/dist/editor/usePageEvents.d.ts +3 -0
- package/dist/editor/usePageEvents.d.ts.map +1 -0
- package/dist/editor/usePageEvents.js +164 -0
- package/dist/editor/usePageEvents.js.map +1 -0
- package/dist/editor-kit.d.ts +19 -0
- package/dist/editor-kit.d.ts.map +1 -0
- package/dist/editor-kit.js +17 -0
- package/dist/editor-kit.js.map +1 -0
- package/dist/editors/CheckboxEditor.d.ts +3 -0
- package/dist/editors/CheckboxEditor.d.ts.map +1 -0
- package/dist/editors/CheckboxEditor.js +10 -0
- package/dist/editors/CheckboxEditor.js.map +1 -0
- package/dist/editors/ColorEditor.d.ts +4 -0
- package/dist/editors/ColorEditor.d.ts.map +1 -0
- package/dist/editors/ColorEditor.js +21 -0
- package/dist/editors/ColorEditor.js.map +1 -0
- package/dist/editors/DateEditor.d.ts +3 -0
- package/dist/editors/DateEditor.d.ts.map +1 -0
- package/dist/editors/DateEditor.js +28 -0
- package/dist/editors/DateEditor.js.map +1 -0
- package/dist/editors/InlineText.d.ts +4 -0
- package/dist/editors/InlineText.d.ts.map +1 -0
- package/dist/editors/InlineText.js +77 -0
- package/dist/editors/InlineText.js.map +1 -0
- package/dist/editors/NumberEditor.d.ts +4 -0
- package/dist/editors/NumberEditor.d.ts.map +1 -0
- package/dist/editors/NumberEditor.js +23 -0
- package/dist/editors/NumberEditor.js.map +1 -0
- package/dist/editors/RelationshipEditor.d.ts +3 -0
- package/dist/editors/RelationshipEditor.d.ts.map +1 -0
- package/dist/editors/RelationshipEditor.js +60 -0
- package/dist/editors/RelationshipEditor.js.map +1 -0
- package/dist/editors/SelectEditor.d.ts +4 -0
- package/dist/editors/SelectEditor.d.ts.map +1 -0
- package/dist/editors/SelectEditor.js +36 -0
- package/dist/editors/SelectEditor.js.map +1 -0
- package/dist/editors/UploadEditor.d.ts +3 -0
- package/dist/editors/UploadEditor.d.ts.map +1 -0
- package/dist/editors/UploadEditor.js +48 -0
- package/dist/editors/UploadEditor.js.map +1 -0
- package/dist/editors/index.d.ts +13 -0
- package/dist/editors/index.d.ts.map +1 -0
- package/dist/editors/index.js +23 -0
- package/dist/editors/index.js.map +1 -0
- package/dist/editors/useDebouncedSearch.d.ts +17 -0
- package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
- package/dist/editors/useDebouncedSearch.js +50 -0
- package/dist/editors/useDebouncedSearch.js.map +1 -0
- package/dist/hub.d.ts +14 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +20 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +12 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +83 -0
- package/dist/layout.js.map +1 -0
- package/dist/locators.d.ts +19 -0
- package/dist/locators.d.ts.map +1 -0
- package/dist/locators.js +215 -0
- package/dist/locators.js.map +1 -0
- package/dist/messages.d.ts +100 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +112 -0
- package/dist/messages.js.map +1 -0
- package/dist/registry.d.ts +15 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +29 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderers.d.ts +7 -0
- package/dist/renderers.d.ts.map +1 -0
- package/dist/renderers.js +12 -0
- package/dist/renderers.js.map +1 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +49 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/styles.d.ts +8 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +122 -0
- package/dist/styles.js.map +1 -0
- package/dist/targets.d.ts +18 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +86 -0
- package/dist/targets.js.map +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/BlockPicker.d.ts +3 -0
- package/dist/ui/BlockPicker.d.ts.map +1 -0
- package/dist/ui/BlockPicker.js +27 -0
- package/dist/ui/BlockPicker.js.map +1 -0
- package/dist/ui/Dialog.d.ts +22 -0
- package/dist/ui/Dialog.d.ts.map +1 -0
- package/dist/ui/Dialog.js +83 -0
- package/dist/ui/Dialog.js.map +1 -0
- package/dist/ui/Dock.d.ts +7 -0
- package/dist/ui/Dock.d.ts.map +1 -0
- package/dist/ui/Dock.js +104 -0
- package/dist/ui/Dock.js.map +1 -0
- package/dist/ui/Overlay.d.ts +2 -0
- package/dist/ui/Overlay.d.ts.map +1 -0
- package/dist/ui/Overlay.js +150 -0
- package/dist/ui/Overlay.js.map +1 -0
- package/dist/ui/PostButton.d.ts +10 -0
- package/dist/ui/PostButton.d.ts.map +1 -0
- package/dist/ui/PostButton.js +7 -0
- package/dist/ui/PostButton.js.map +1 -0
- package/dist/ui/PreviewToolbar.d.ts +15 -0
- package/dist/ui/PreviewToolbar.d.ts.map +1 -0
- package/dist/ui/PreviewToolbar.js +45 -0
- package/dist/ui/PreviewToolbar.js.map +1 -0
- package/dist/ui/SettingsPanel.d.ts +11 -0
- package/dist/ui/SettingsPanel.d.ts.map +1 -0
- package/dist/ui/SettingsPanel.js +130 -0
- package/dist/ui/SettingsPanel.js.map +1 -0
- package/dist/useEvent.d.ts +7 -0
- package/dist/useEvent.d.ts.map +1 -0
- package/dist/useEvent.js +15 -0
- package/dist/useEvent.js.map +1 -0
- package/package.json +71 -4
- package/src/OnPageClient.tsx +67 -0
- package/src/annotations.ts +83 -0
- package/src/context.tsx +23 -0
- package/src/dom.ts +118 -0
- package/src/editor/FieldHost.tsx +66 -0
- package/src/editor/Ghosts.tsx +55 -0
- package/src/editor/OnPageEditor.tsx +315 -0
- package/src/editor/useGhosts.ts +64 -0
- package/src/editor/usePageEvents.ts +161 -0
- package/src/editor-kit.ts +27 -0
- package/src/editors/CheckboxEditor.tsx +31 -0
- package/src/editors/ColorEditor.tsx +64 -0
- package/src/editors/DateEditor.tsx +64 -0
- package/src/editors/InlineText.tsx +106 -0
- package/src/editors/NumberEditor.tsx +69 -0
- package/src/editors/RelationshipEditor.tsx +153 -0
- package/src/editors/SelectEditor.tsx +74 -0
- package/src/editors/UploadEditor.tsx +130 -0
- package/src/editors/index.ts +35 -0
- package/src/editors/useDebouncedSearch.ts +54 -0
- package/src/hub.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout.ts +83 -0
- package/src/locators.ts +210 -0
- package/src/messages.ts +120 -0
- package/src/registry.ts +43 -0
- package/src/renderers.ts +15 -0
- package/src/sanitize.ts +52 -0
- package/src/styles.ts +122 -0
- package/src/targets.ts +101 -0
- package/src/types.ts +132 -0
- package/src/ui/BlockPicker.tsx +49 -0
- package/src/ui/Dialog.tsx +159 -0
- package/src/ui/Dock.tsx +240 -0
- package/src/ui/Overlay.tsx +324 -0
- package/src/ui/PostButton.tsx +26 -0
- package/src/ui/PreviewToolbar.tsx +123 -0
- package/src/ui/SettingsPanel.tsx +279 -0
- package/src/useEvent.ts +16 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { useOnPage } from '../context.tsx'
|
|
5
|
+
import { HIDDEN, placeOver } from '../dom.ts'
|
|
6
|
+
import { useLayoutTick } from '../layout.ts'
|
|
7
|
+
import { sanitizeHtml } from '../sanitize.ts'
|
|
8
|
+
import type { Ghost } from './useGhosts.ts'
|
|
9
|
+
|
|
10
|
+
/** Refresh mode: committed content stays on screen until the server render shows it. */
|
|
11
|
+
export function Ghosts({ ghosts }: { ghosts: Ghost[] }) {
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
{ghosts.map((g) => (
|
|
15
|
+
<GhostView key={g.id} ghost={g} />
|
|
16
|
+
))}
|
|
17
|
+
</>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function GhostView({ ghost }: { ghost: Ghost }) {
|
|
22
|
+
const { targets, editing } = useOnPage()
|
|
23
|
+
const box = useRef<HTMLDivElement>(null)
|
|
24
|
+
const hidden = useRef<Element | null>(null)
|
|
25
|
+
const tick = useLayoutTick()
|
|
26
|
+
const reopened = editing?.key === ghost.key && editing.path === ghost.path
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const el = targets.find(ghost.key, ghost.path)?.el ?? null
|
|
30
|
+
if (hidden.current && hidden.current !== el) hidden.current.removeAttribute(HIDDEN)
|
|
31
|
+
hidden.current = el
|
|
32
|
+
const b = box.current
|
|
33
|
+
if (!b) return
|
|
34
|
+
if (!el || reopened) {
|
|
35
|
+
b.style.visibility = 'hidden'
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
el.setAttribute(HIDDEN, 'true')
|
|
39
|
+
placeOver(b, el, { minHeight: true })
|
|
40
|
+
b.style.visibility = 'visible'
|
|
41
|
+
}, [targets, ghost.key, ghost.path, reopened, tick])
|
|
42
|
+
|
|
43
|
+
useEffect(() => () => hidden.current?.removeAttribute(HIDDEN), [])
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
ref={box}
|
|
48
|
+
className={`po-ghost${ghost.className ? ` ${ghost.className}` : ''}`}
|
|
49
|
+
style={ghost.style as React.CSSProperties}
|
|
50
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(ghost.html) }}
|
|
51
|
+
aria-hidden
|
|
52
|
+
data-testid="onpage-ghost"
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The on-page editor. Mount it once (the Next adapter's <OnPage /> does) and
|
|
5
|
+
* it finds editable content through the Locator, opens each document it finds
|
|
6
|
+
* in an EditorSession, and edits through the field-editor registry. Saves go
|
|
7
|
+
* through the DataSource; the Renderer shows them (server refresh by default,
|
|
8
|
+
* instant for documents rendered with <OnPageClient>).
|
|
9
|
+
*/
|
|
10
|
+
import { useEvent } from '../useEvent.ts'
|
|
11
|
+
import {
|
|
12
|
+
EditorSession,
|
|
13
|
+
fieldAtPath,
|
|
14
|
+
isEditablePath,
|
|
15
|
+
refKey,
|
|
16
|
+
type DataSource,
|
|
17
|
+
type DocRef,
|
|
18
|
+
type Locator,
|
|
19
|
+
type MediaLibrary,
|
|
20
|
+
type Renderer,
|
|
21
|
+
type SessionSnapshot,
|
|
22
|
+
} from '@payload-onpage/core'
|
|
23
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
24
|
+
import { createPortal } from 'react-dom'
|
|
25
|
+
import { OnPageContext } from '../context.tsx'
|
|
26
|
+
import { UI } from '../dom.ts'
|
|
27
|
+
import { defaultEditors } from '../editors/index.ts'
|
|
28
|
+
import { hub } from '../hub.ts'
|
|
29
|
+
import { notifyLayout } from '../layout.ts'
|
|
30
|
+
import { annotations, composite, stega } from '../locators.ts'
|
|
31
|
+
import { createTranslate, type Messages } from '../messages.ts'
|
|
32
|
+
import { mergeEditors, resolveEditor } from '../registry.ts'
|
|
33
|
+
import { clientState } from '../renderers.ts'
|
|
34
|
+
import { injectStyles } from '../styles.ts'
|
|
35
|
+
import { useTargets } from '../targets.ts'
|
|
36
|
+
import type {
|
|
37
|
+
EditingState,
|
|
38
|
+
EditorRegistry,
|
|
39
|
+
FieldEditorDef,
|
|
40
|
+
Links,
|
|
41
|
+
OnPageContextValue,
|
|
42
|
+
Panel,
|
|
43
|
+
Selection,
|
|
44
|
+
} from '../types.ts'
|
|
45
|
+
import { BlockPicker } from '../ui/BlockPicker.tsx'
|
|
46
|
+
import { Dock } from '../ui/Dock.tsx'
|
|
47
|
+
import { Overlay } from '../ui/Overlay.tsx'
|
|
48
|
+
import { SettingsPanel } from '../ui/SettingsPanel.tsx'
|
|
49
|
+
import { FieldHost } from './FieldHost.tsx'
|
|
50
|
+
import { Ghosts } from './Ghosts.tsx'
|
|
51
|
+
import { usePageEvents } from './usePageEvents.ts'
|
|
52
|
+
import { useGhosts } from './useGhosts.ts'
|
|
53
|
+
|
|
54
|
+
export interface OnPageEditorProps {
|
|
55
|
+
dataSource: DataSource
|
|
56
|
+
/** Image search/upload (e.g. `payloadUploads()`). Without it, upload fields can't be changed. */
|
|
57
|
+
media?: MediaLibrary
|
|
58
|
+
/** How elements map to fields. Default: composite(annotations(), stega()). */
|
|
59
|
+
locator?: Locator
|
|
60
|
+
/** How saved changes reach the page. Default: clientState() (no server refresh). */
|
|
61
|
+
renderer?: Renderer
|
|
62
|
+
/** Extra or replacement field editors by id; `null` removes a default. */
|
|
63
|
+
editors?: Record<string, FieldEditorDef | null>
|
|
64
|
+
links: Links
|
|
65
|
+
/** Content locale to edit. */
|
|
66
|
+
locale?: string
|
|
67
|
+
/** Autosave debounce, ms. Default: 250 with a refreshing renderer; else the collection's autosave interval, else 800. */
|
|
68
|
+
autosaveDelay?: number
|
|
69
|
+
/** Where to look for editable content. Default: document.body. */
|
|
70
|
+
root?: () => ParentNode | null
|
|
71
|
+
/** Override any UI string (see `defaultMessages`). */
|
|
72
|
+
messages?: Messages
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const EMPTY_SNAP: SessionSnapshot = { docs: {}, canUndo: false, canRedo: false, busy: false }
|
|
76
|
+
const noopSubscribe = () => () => {}
|
|
77
|
+
|
|
78
|
+
export function OnPageEditor(props: OnPageEditorProps) {
|
|
79
|
+
const { dataSource, locale, autosaveDelay, links, media } = props
|
|
80
|
+
const renderer = useMemo(() => props.renderer ?? clientState(), [props.renderer])
|
|
81
|
+
const locator = useMemo(() => props.locator ?? composite(annotations(), stega()), [props.locator])
|
|
82
|
+
const editors: EditorRegistry = useMemo(
|
|
83
|
+
() => mergeEditors(defaultEditors, props.editors),
|
|
84
|
+
[props.editors],
|
|
85
|
+
)
|
|
86
|
+
const t = useMemo(() => createTranslate(props.messages), [props.messages])
|
|
87
|
+
const rootProp = props.root
|
|
88
|
+
const root = useCallback(
|
|
89
|
+
() => (rootProp ? rootProp() : typeof document === 'undefined' ? null : document.body),
|
|
90
|
+
[rootProp],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const [panel, setPanel] = useState<Panel>(null)
|
|
94
|
+
const [editing, setEditing] = useState<EditingState | null>(null)
|
|
95
|
+
const [selected, setSelected] = useState<Selection | null>(null)
|
|
96
|
+
const [renders, setRenders] = useState(0)
|
|
97
|
+
const [flash, setFlash] = useState<{ text: string; bad?: boolean } | null>(null)
|
|
98
|
+
const [clientDocs, setClientDocs] = useState<ReadonlySet<string>>(() => new Set())
|
|
99
|
+
const [session, setSession] = useState<EditorSession | null>(null)
|
|
100
|
+
const snap = useSyncExternalStore(
|
|
101
|
+
session?.subscribe ?? noopSubscribe,
|
|
102
|
+
session?.getSnapshot ?? (() => EMPTY_SNAP),
|
|
103
|
+
() => EMPTY_SNAP,
|
|
104
|
+
)
|
|
105
|
+
const targets = useTargets(locator, root)
|
|
106
|
+
const { ghosts, add: addGhostEntry, rendered: ghostsRendered } = useGhosts(session ? snap : null)
|
|
107
|
+
|
|
108
|
+
const isLive = useCallback(
|
|
109
|
+
(key: string) => renderer.live || clientDocs.has(key),
|
|
110
|
+
[renderer, clientDocs],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
/** Re-render documents that aren't client-rendered, then retire their ghosts. */
|
|
114
|
+
const rerender = useCallback(
|
|
115
|
+
async (refs: DocRef[], seq?: number) => {
|
|
116
|
+
const stale = refs.filter((r) => !isLive(refKey(r)))
|
|
117
|
+
if (!stale.length) return
|
|
118
|
+
setRenders((n) => n + 1)
|
|
119
|
+
try {
|
|
120
|
+
await renderer.afterSave({ refs: stale })
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error('[payload-onpage] render after save failed', e)
|
|
123
|
+
} finally {
|
|
124
|
+
setRenders((n) => n - 1)
|
|
125
|
+
// Give the new DOM a moment to land (and the locator to rescan) before uncovering it.
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
for (const r of stale) ghostsRendered(refKey(r), seq ?? Number.MAX_SAFE_INTEGER)
|
|
128
|
+
}, 250)
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
[isLive, renderer, ghostsRendered],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const handleSaved = useEvent((ref: DocRef, seq: number) => void rerender([ref], seq))
|
|
135
|
+
|
|
136
|
+
// One session per data source and locale; disposed (after flushing) when they change or on unmount.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
// Refresh mode saves quickly (the page only updates after a save); client mode uses the collection's interval.
|
|
139
|
+
const delay = autosaveDelay ?? (renderer.live ? undefined : 250)
|
|
140
|
+
const s = new EditorSession({
|
|
141
|
+
dataSource,
|
|
142
|
+
locale,
|
|
143
|
+
autosaveDelay: delay,
|
|
144
|
+
onSaved: (ref, seq) => handleSaved(ref, seq),
|
|
145
|
+
})
|
|
146
|
+
// The session is an external resource with a lifecycle; it lives in state so StrictMode's
|
|
147
|
+
// mount/unmount/mount gets a fresh one each time.
|
|
148
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
149
|
+
setSession(s)
|
|
150
|
+
return () => {
|
|
151
|
+
void s.flush().finally(() => s.dispose())
|
|
152
|
+
}
|
|
153
|
+
}, [dataSource, locale, autosaveDelay, renderer.live, handleSaved])
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!flash) return
|
|
157
|
+
const id = setTimeout(() => setFlash(null), 4000)
|
|
158
|
+
return () => clearTimeout(id)
|
|
159
|
+
}, [flash])
|
|
160
|
+
|
|
161
|
+
// Documents on this page: everything the locator found, plus client-mode documents.
|
|
162
|
+
const docKeys = useMemo(() => {
|
|
163
|
+
const keys = new Set<string>(clientDocs)
|
|
164
|
+
for (const target of targets.all) keys.add(refKey(target.ref))
|
|
165
|
+
return [...keys]
|
|
166
|
+
}, [targets, clientDocs])
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!session) return
|
|
170
|
+
const seen = new Set<string>()
|
|
171
|
+
for (const target of targets.all) {
|
|
172
|
+
const k = refKey(target.ref)
|
|
173
|
+
if (seen.has(k)) continue
|
|
174
|
+
seen.add(k)
|
|
175
|
+
void session.open(target.ref)
|
|
176
|
+
}
|
|
177
|
+
}, [targets, session])
|
|
178
|
+
|
|
179
|
+
useEffect(() => notifyLayout(), [snap, targets])
|
|
180
|
+
|
|
181
|
+
const notify = useCallback<OnPageContextValue['notify']>(
|
|
182
|
+
(key, vars, bad) => setFlash({ text: t(key, vars), bad }),
|
|
183
|
+
[t],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const registerClientDoc = useCallback((key: string) => {
|
|
187
|
+
setClientDocs((s) => new Set(s).add(key))
|
|
188
|
+
return () =>
|
|
189
|
+
setClientDocs((s) => {
|
|
190
|
+
const next = new Set(s)
|
|
191
|
+
next.delete(key)
|
|
192
|
+
return next
|
|
193
|
+
})
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const addGhost = useCallback<OnPageContextValue['addGhost']>(
|
|
197
|
+
(g) => addGhostEntry(g, session?.get(g.key)?.save.seq ?? 0),
|
|
198
|
+
[addGhostEntry, session],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const edit = useCallback<OnPageContextValue['edit']>(
|
|
202
|
+
(key, path, opts = {}) => {
|
|
203
|
+
const state = session?.get(key)
|
|
204
|
+
if (!state?.schema || !state.doc) {
|
|
205
|
+
if (state?.status === 'error')
|
|
206
|
+
notify('notify.cantEdit', { message: state.error ?? '' }, true)
|
|
207
|
+
else notify('notify.loading')
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
if (!isEditablePath(state.schema, path)) return false
|
|
211
|
+
const hit = fieldAtPath(state.schema.fields, state.doc, path)
|
|
212
|
+
if (!hit) return false
|
|
213
|
+
const editor = resolveEditor(
|
|
214
|
+
editors,
|
|
215
|
+
hit.field,
|
|
216
|
+
opts.kind === 'field' ? undefined : opts.kind,
|
|
217
|
+
)
|
|
218
|
+
if (!editor) return false
|
|
219
|
+
setEditing({
|
|
220
|
+
key,
|
|
221
|
+
path,
|
|
222
|
+
editor,
|
|
223
|
+
point: opts.point,
|
|
224
|
+
el: opts.anchor ?? targets.find(key, path)?.el,
|
|
225
|
+
})
|
|
226
|
+
return true
|
|
227
|
+
},
|
|
228
|
+
[session, editors, targets, notify],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
const ctx = useMemo<OnPageContextValue | null>(
|
|
232
|
+
() =>
|
|
233
|
+
session && {
|
|
234
|
+
session,
|
|
235
|
+
snap,
|
|
236
|
+
renderer,
|
|
237
|
+
media,
|
|
238
|
+
editors,
|
|
239
|
+
targets,
|
|
240
|
+
links,
|
|
241
|
+
t,
|
|
242
|
+
panel,
|
|
243
|
+
setPanel,
|
|
244
|
+
editing,
|
|
245
|
+
setEditing,
|
|
246
|
+
selected,
|
|
247
|
+
setSelected,
|
|
248
|
+
docKeys,
|
|
249
|
+
isLive,
|
|
250
|
+
registerClientDoc,
|
|
251
|
+
rendering: renders > 0,
|
|
252
|
+
edit,
|
|
253
|
+
rerender: (refs) => rerender(refs),
|
|
254
|
+
notify,
|
|
255
|
+
addGhost,
|
|
256
|
+
},
|
|
257
|
+
[
|
|
258
|
+
session,
|
|
259
|
+
snap,
|
|
260
|
+
renderer,
|
|
261
|
+
media,
|
|
262
|
+
editors,
|
|
263
|
+
targets,
|
|
264
|
+
links,
|
|
265
|
+
t,
|
|
266
|
+
panel,
|
|
267
|
+
editing,
|
|
268
|
+
selected,
|
|
269
|
+
docKeys,
|
|
270
|
+
isLive,
|
|
271
|
+
registerClientDoc,
|
|
272
|
+
renders,
|
|
273
|
+
edit,
|
|
274
|
+
rerender,
|
|
275
|
+
notify,
|
|
276
|
+
addGhost,
|
|
277
|
+
],
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
hub.set(ctx)
|
|
282
|
+
}, [ctx])
|
|
283
|
+
useEffect(() => () => hub.set(null), [])
|
|
284
|
+
useEffect(() => injectStyles(), [])
|
|
285
|
+
usePageEvents(ctx, root)
|
|
286
|
+
|
|
287
|
+
const mounted = useSyncExternalStore(
|
|
288
|
+
noopSubscribe,
|
|
289
|
+
() => true,
|
|
290
|
+
() => false,
|
|
291
|
+
)
|
|
292
|
+
if (!mounted || !ctx) return null
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<OnPageContext.Provider value={ctx}>
|
|
296
|
+
{createPortal(
|
|
297
|
+
<div className="po-doc-layer" {...{ [UI]: '' }}>
|
|
298
|
+
<Ghosts ghosts={ghosts} />
|
|
299
|
+
<FieldHost mode="inline" />
|
|
300
|
+
</div>,
|
|
301
|
+
document.body,
|
|
302
|
+
)}
|
|
303
|
+
{createPortal(
|
|
304
|
+
<div className="po-layer" {...{ [UI]: '' }}>
|
|
305
|
+
<Overlay />
|
|
306
|
+
<SettingsPanel />
|
|
307
|
+
<BlockPicker />
|
|
308
|
+
<FieldHost mode="popover" />
|
|
309
|
+
<Dock flash={flash} />
|
|
310
|
+
</div>,
|
|
311
|
+
document.body,
|
|
312
|
+
)}
|
|
313
|
+
</OnPageContext.Provider>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ghosts: in refresh mode, committed content stays on screen (over the hidden
|
|
5
|
+
* original) until the server render that includes it has landed. A ghost
|
|
6
|
+
* records the document's save sequence when it was made; the render after a
|
|
7
|
+
* later save retires it. Failed saves and a TTL retire ghosts too.
|
|
8
|
+
*/
|
|
9
|
+
import type { SessionSnapshot } from '@payload-onpage/core'
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
+
|
|
12
|
+
export interface Ghost {
|
|
13
|
+
id: string
|
|
14
|
+
key: string
|
|
15
|
+
path: string
|
|
16
|
+
html: string
|
|
17
|
+
className?: string
|
|
18
|
+
style?: Partial<CSSStyleDeclaration>
|
|
19
|
+
/** Save sequence of the document when the ghost was made. */
|
|
20
|
+
afterSeq: number
|
|
21
|
+
until: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TTL = 15_000
|
|
25
|
+
|
|
26
|
+
export function useGhosts(snap: SessionSnapshot | null) {
|
|
27
|
+
const [ghosts, setGhosts] = useState<Ghost[]>([])
|
|
28
|
+
|
|
29
|
+
const add = useCallback((g: Omit<Ghost, 'id' | 'until' | 'afterSeq'>, afterSeq: number) => {
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
setGhosts((gs) => [
|
|
32
|
+
...gs.filter((x) => !(x.key === g.key && x.path === g.path)),
|
|
33
|
+
{ ...g, afterSeq, id: `${now}-${Math.random().toString(36).slice(2)}`, until: now + TTL },
|
|
34
|
+
])
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
/** The page re-rendered after save `seq` of document `key`. */
|
|
38
|
+
const rendered = useCallback((key: string, seq: number) => {
|
|
39
|
+
setGhosts((gs) =>
|
|
40
|
+
gs.some((g) => g.key === key && g.afterSeq < seq)
|
|
41
|
+
? gs.filter((g) => !(g.key === key && g.afterSeq < seq))
|
|
42
|
+
: gs,
|
|
43
|
+
)
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
// Expired ghosts.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!ghosts.length) return
|
|
49
|
+
const next = Math.min(...ghosts.map((g) => g.until)) - Date.now()
|
|
50
|
+
const timer = setTimeout(
|
|
51
|
+
() => setGhosts((gs) => gs.filter((g) => g.until > Date.now())),
|
|
52
|
+
Math.max(next, 0) + 10,
|
|
53
|
+
)
|
|
54
|
+
return () => clearTimeout(timer)
|
|
55
|
+
}, [ghosts])
|
|
56
|
+
|
|
57
|
+
// A failed save will never render: don't cover the page for it.
|
|
58
|
+
const visible = ghosts.filter((g) => {
|
|
59
|
+
const status = snap?.docs[g.key]?.save.status
|
|
60
|
+
return status !== 'error' && status !== 'conflict'
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return { ghosts: visible, add, rendered }
|
|
64
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Page-level input for the editor:
|
|
5
|
+
* - click a field to edit it, a row to select it (⌥-click uses the page normally)
|
|
6
|
+
* - keyboard: every target is focusable; Enter edits a field or selects a row,
|
|
7
|
+
* ⌥↑/⌥↓ move the selected row, ⌥⌫ deletes it, Escape clears; ⌘Z/⇧⌘Z undo/redo
|
|
8
|
+
* - a warning before leaving with unsaved changes
|
|
9
|
+
*/
|
|
10
|
+
import { useEvent } from '../useEvent.ts'
|
|
11
|
+
import { moveItem, refKey, removeItem, resolveRow, updateArray } from '@payload-onpage/core'
|
|
12
|
+
import { useEffect } from 'react'
|
|
13
|
+
import { isOurs, isTyping } from '../dom.ts'
|
|
14
|
+
import { isFieldTarget, isRowTarget, targetsAt } from '../targets.ts'
|
|
15
|
+
import type { OnPageContextValue } from '../types.ts'
|
|
16
|
+
|
|
17
|
+
const FOCUSABLE_FLAG = 'data-onpage-focusable'
|
|
18
|
+
|
|
19
|
+
export function usePageEvents(ctx: OnPageContextValue | null, root: () => ParentNode | null): void {
|
|
20
|
+
const onClick = useEvent((e: MouseEvent) => {
|
|
21
|
+
if (!ctx || e.altKey || e.button !== 0) return
|
|
22
|
+
const t = e.target
|
|
23
|
+
if (!(t instanceof Element) || isOurs(t)) return
|
|
24
|
+
const r = root()
|
|
25
|
+
if (r && !r.contains(t)) return
|
|
26
|
+
const { field, rows } = targetsAt(ctx.targets, t)
|
|
27
|
+
const row = rows[0]
|
|
28
|
+
const inControl = !!t.closest('a,button,label,summary,[role="button"]')
|
|
29
|
+
if (row) ctx.setSelected({ key: refKey(row.ref), path: row.path })
|
|
30
|
+
else if (!field) ctx.setSelected(null)
|
|
31
|
+
if (
|
|
32
|
+
field &&
|
|
33
|
+
ctx.edit(refKey(field.ref), field.path, {
|
|
34
|
+
point: { x: e.clientX, y: e.clientY },
|
|
35
|
+
kind: field.kind,
|
|
36
|
+
anchor: field.el,
|
|
37
|
+
})
|
|
38
|
+
) {
|
|
39
|
+
e.preventDefault()
|
|
40
|
+
e.stopPropagation()
|
|
41
|
+
} else if ((field || row) && inControl) {
|
|
42
|
+
e.preventDefault() // don't navigate away from the editor by accident
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const moveSelected = (dir: -1 | 1 | 'delete') => {
|
|
47
|
+
if (!ctx?.selected) return false
|
|
48
|
+
const { key, path } = ctx.selected
|
|
49
|
+
const state = ctx.snap.docs[key]
|
|
50
|
+
const info = state?.schema && state.doc ? resolveRow(state.schema, state.doc, path) : null
|
|
51
|
+
if (!info) return false
|
|
52
|
+
if (dir === 'delete') {
|
|
53
|
+
if (info.length <= (info.listField.minRows ?? 0)) return true
|
|
54
|
+
ctx.session.apply(key, (d) => updateArray(d, info.listPath, (a) => removeItem(a, info.index)))
|
|
55
|
+
ctx.setSelected(null)
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
const to = info.index + dir
|
|
59
|
+
if (to < 0 || to >= info.length) return true
|
|
60
|
+
ctx.session.apply(key, (d) => updateArray(d, info.listPath, (a) => moveItem(a, info.index, to)))
|
|
61
|
+
ctx.setSelected({ key, path: `${info.listPath}.${to}` })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const onKey = useEvent((e: KeyboardEvent) => {
|
|
66
|
+
if (!ctx) return
|
|
67
|
+
const mod = e.metaKey || e.ctrlKey
|
|
68
|
+
if (e.key === 'Escape') {
|
|
69
|
+
ctx.setPanel(null)
|
|
70
|
+
ctx.setSelected(null)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (isTyping(e.target)) return
|
|
74
|
+
if (mod && e.key.toLowerCase() === 'z') {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
if (e.shiftKey) ctx.session.redo()
|
|
77
|
+
else ctx.session.undo()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (mod && e.key.toLowerCase() === 'y') {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
ctx.session.redo()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
e.altKey &&
|
|
87
|
+
(e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Backspace' || e.key === 'Delete')
|
|
88
|
+
) {
|
|
89
|
+
if (moveSelected(e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 'delete'))
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
(e.key === 'Enter' || e.key === 'F2') &&
|
|
95
|
+
e.target instanceof Element &&
|
|
96
|
+
e.target.hasAttribute(FOCUSABLE_FLAG)
|
|
97
|
+
) {
|
|
98
|
+
const own = ctx.targets.at(e.target)
|
|
99
|
+
const field = own.find(isFieldTarget)
|
|
100
|
+
const row = own.find(isRowTarget)
|
|
101
|
+
if (field && ctx.edit(refKey(field.ref), field.path, { kind: field.kind, anchor: field.el }))
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
else if (row) {
|
|
104
|
+
ctx.setSelected({ key: refKey(row.ref), path: row.path })
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const onFocus = useEvent((e: FocusEvent) => {
|
|
111
|
+
if (!ctx || !(e.target instanceof Element) || !e.target.hasAttribute(FOCUSABLE_FLAG)) return
|
|
112
|
+
const row = ctx.targets.at(e.target).find(isRowTarget)
|
|
113
|
+
if (row) ctx.setSelected({ key: refKey(row.ref), path: row.path })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const onUnload = useEvent((e: BeforeUnloadEvent) => {
|
|
117
|
+
if (!ctx?.session.hasPendingSaves) return
|
|
118
|
+
void ctx.session.flush()
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
document.documentElement.setAttribute('data-onpage-editing', '')
|
|
124
|
+
const click = (e: MouseEvent) => onClick(e)
|
|
125
|
+
const key = (e: KeyboardEvent) => onKey(e)
|
|
126
|
+
const focus = (e: FocusEvent) => onFocus(e)
|
|
127
|
+
const unload = (e: BeforeUnloadEvent) => onUnload(e)
|
|
128
|
+
document.addEventListener('click', click, true)
|
|
129
|
+
document.addEventListener('keydown', key)
|
|
130
|
+
document.addEventListener('focusin', focus)
|
|
131
|
+
window.addEventListener('beforeunload', unload)
|
|
132
|
+
return () => {
|
|
133
|
+
document.documentElement.removeAttribute('data-onpage-editing')
|
|
134
|
+
document.removeEventListener('click', click, true)
|
|
135
|
+
document.removeEventListener('keydown', key)
|
|
136
|
+
document.removeEventListener('focusin', focus)
|
|
137
|
+
window.removeEventListener('beforeunload', unload)
|
|
138
|
+
}
|
|
139
|
+
}, [onClick, onFocus, onKey, onUnload])
|
|
140
|
+
|
|
141
|
+
// Make every target reachable by keyboard (restored when the editor goes away).
|
|
142
|
+
const targets = ctx?.targets
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const touched: HTMLElement[] = []
|
|
145
|
+
for (const t of targets?.all ?? []) {
|
|
146
|
+
if (t.kind === 'list' || !(t.el instanceof HTMLElement) || t.el.hasAttribute('tabindex'))
|
|
147
|
+
continue
|
|
148
|
+
t.el.setAttribute('tabindex', '0')
|
|
149
|
+
t.el.setAttribute(FOCUSABLE_FLAG, '')
|
|
150
|
+
if (t.label) t.el.setAttribute('aria-description', t.label)
|
|
151
|
+
touched.push(t.el)
|
|
152
|
+
}
|
|
153
|
+
return () => {
|
|
154
|
+
for (const el of touched) {
|
|
155
|
+
el.removeAttribute('tabindex')
|
|
156
|
+
el.removeAttribute(FOCUSABLE_FLAG)
|
|
157
|
+
el.removeAttribute('aria-description')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}, [targets])
|
|
161
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @payload-onpage/react/editor-kit — building blocks for custom field editors
|
|
3
|
+
* (the default editors and @payload-onpage/lexical use exactly these).
|
|
4
|
+
*
|
|
5
|
+
* function JsonEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
|
|
6
|
+
* return <Dialog variant="popover" anchor={element} title={field.label} onClose={close} t={t}>…</Dialog>
|
|
7
|
+
* }
|
|
8
|
+
*/
|
|
9
|
+
export { Dialog, type DialogProps } from './ui/Dialog.tsx'
|
|
10
|
+
export { useOnPage } from './context.tsx'
|
|
11
|
+
export { useDebouncedSearch } from './editors/useDebouncedSearch.ts'
|
|
12
|
+
export { escapeHtml } from './editors/InlineText.tsx'
|
|
13
|
+
export {
|
|
14
|
+
HIDDEN,
|
|
15
|
+
UI,
|
|
16
|
+
caretAt,
|
|
17
|
+
copyTypography,
|
|
18
|
+
placeCaret,
|
|
19
|
+
placeOver,
|
|
20
|
+
typographyOf,
|
|
21
|
+
popoverPosition,
|
|
22
|
+
} from './dom.ts'
|
|
23
|
+
export { useTrackElement, useLayoutTick, notifyLayout } from './layout.ts'
|
|
24
|
+
export { useEvent } from './useEvent.ts'
|
|
25
|
+
export { sanitizeHtml } from './sanitize.ts'
|
|
26
|
+
export type { EditorRegistry, FieldEditorDef, FieldEditorProps } from './types.ts'
|
|
27
|
+
export type { Translate } from './messages.ts'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { FieldEditorProps } from '../types.ts'
|
|
4
|
+
import { Dialog } from '../ui/Dialog.tsx'
|
|
5
|
+
|
|
6
|
+
export function CheckboxEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
|
|
7
|
+
return (
|
|
8
|
+
<Dialog
|
|
9
|
+
variant="popover"
|
|
10
|
+
anchor={element}
|
|
11
|
+
title={field.label}
|
|
12
|
+
onClose={close}
|
|
13
|
+
t={t}
|
|
14
|
+
testId="onpage-checkbox"
|
|
15
|
+
>
|
|
16
|
+
<label className="po-check">
|
|
17
|
+
<input
|
|
18
|
+
type="checkbox"
|
|
19
|
+
data-autofocus
|
|
20
|
+
checked={Boolean(value ?? field.defaultValue)}
|
|
21
|
+
onChange={(e) => {
|
|
22
|
+
commit(e.target.checked)
|
|
23
|
+
close()
|
|
24
|
+
}}
|
|
25
|
+
data-testid="onpage-checkbox-input"
|
|
26
|
+
/>
|
|
27
|
+
{field.label}
|
|
28
|
+
</label>
|
|
29
|
+
</Dialog>
|
|
30
|
+
)
|
|
31
|
+
}
|