@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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/OnPageClient.d.ts +18 -0
  4. package/dist/OnPageClient.d.ts.map +1 -0
  5. package/dist/OnPageClient.js +51 -0
  6. package/dist/OnPageClient.js.map +1 -0
  7. package/dist/annotations.d.ts +55 -0
  8. package/dist/annotations.d.ts.map +1 -0
  9. package/dist/annotations.js +60 -0
  10. package/dist/annotations.js.map +1 -0
  11. package/dist/context.d.ts +7 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +19 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/dom.d.ts +42 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +95 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/editor/FieldHost.d.ts +5 -0
  20. package/dist/editor/FieldHost.d.ts.map +1 -0
  21. package/dist/editor/FieldHost.js +36 -0
  22. package/dist/editor/FieldHost.js.map +1 -0
  23. package/dist/editor/Ghosts.d.ts +6 -0
  24. package/dist/editor/Ghosts.d.ts.map +1 -0
  25. package/dist/editor/Ghosts.js +37 -0
  26. package/dist/editor/Ghosts.js.map +1 -0
  27. package/dist/editor/OnPageEditor.d.ts +25 -0
  28. package/dist/editor/OnPageEditor.d.ts.map +1 -0
  29. package/dist/editor/OnPageEditor.js +211 -0
  30. package/dist/editor/OnPageEditor.js.map +1 -0
  31. package/dist/editor/useGhosts.d.ts +24 -0
  32. package/dist/editor/useGhosts.d.ts.map +1 -0
  33. package/dist/editor/useGhosts.js +34 -0
  34. package/dist/editor/useGhosts.js.map +1 -0
  35. package/dist/editor/usePageEvents.d.ts +3 -0
  36. package/dist/editor/usePageEvents.d.ts.map +1 -0
  37. package/dist/editor/usePageEvents.js +164 -0
  38. package/dist/editor/usePageEvents.js.map +1 -0
  39. package/dist/editor-kit.d.ts +19 -0
  40. package/dist/editor-kit.d.ts.map +1 -0
  41. package/dist/editor-kit.js +17 -0
  42. package/dist/editor-kit.js.map +1 -0
  43. package/dist/editors/CheckboxEditor.d.ts +3 -0
  44. package/dist/editors/CheckboxEditor.d.ts.map +1 -0
  45. package/dist/editors/CheckboxEditor.js +10 -0
  46. package/dist/editors/CheckboxEditor.js.map +1 -0
  47. package/dist/editors/ColorEditor.d.ts +4 -0
  48. package/dist/editors/ColorEditor.d.ts.map +1 -0
  49. package/dist/editors/ColorEditor.js +21 -0
  50. package/dist/editors/ColorEditor.js.map +1 -0
  51. package/dist/editors/DateEditor.d.ts +3 -0
  52. package/dist/editors/DateEditor.d.ts.map +1 -0
  53. package/dist/editors/DateEditor.js +28 -0
  54. package/dist/editors/DateEditor.js.map +1 -0
  55. package/dist/editors/InlineText.d.ts +4 -0
  56. package/dist/editors/InlineText.d.ts.map +1 -0
  57. package/dist/editors/InlineText.js +77 -0
  58. package/dist/editors/InlineText.js.map +1 -0
  59. package/dist/editors/NumberEditor.d.ts +4 -0
  60. package/dist/editors/NumberEditor.d.ts.map +1 -0
  61. package/dist/editors/NumberEditor.js +23 -0
  62. package/dist/editors/NumberEditor.js.map +1 -0
  63. package/dist/editors/RelationshipEditor.d.ts +3 -0
  64. package/dist/editors/RelationshipEditor.d.ts.map +1 -0
  65. package/dist/editors/RelationshipEditor.js +60 -0
  66. package/dist/editors/RelationshipEditor.js.map +1 -0
  67. package/dist/editors/SelectEditor.d.ts +4 -0
  68. package/dist/editors/SelectEditor.d.ts.map +1 -0
  69. package/dist/editors/SelectEditor.js +36 -0
  70. package/dist/editors/SelectEditor.js.map +1 -0
  71. package/dist/editors/UploadEditor.d.ts +3 -0
  72. package/dist/editors/UploadEditor.d.ts.map +1 -0
  73. package/dist/editors/UploadEditor.js +48 -0
  74. package/dist/editors/UploadEditor.js.map +1 -0
  75. package/dist/editors/index.d.ts +13 -0
  76. package/dist/editors/index.d.ts.map +1 -0
  77. package/dist/editors/index.js +23 -0
  78. package/dist/editors/index.js.map +1 -0
  79. package/dist/editors/useDebouncedSearch.d.ts +17 -0
  80. package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
  81. package/dist/editors/useDebouncedSearch.js +50 -0
  82. package/dist/editors/useDebouncedSearch.js.map +1 -0
  83. package/dist/hub.d.ts +14 -0
  84. package/dist/hub.d.ts.map +1 -0
  85. package/dist/hub.js +20 -0
  86. package/dist/hub.js.map +1 -0
  87. package/dist/index.d.ts +20 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +19 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/layout.d.ts +12 -0
  92. package/dist/layout.d.ts.map +1 -0
  93. package/dist/layout.js +83 -0
  94. package/dist/layout.js.map +1 -0
  95. package/dist/locators.d.ts +19 -0
  96. package/dist/locators.d.ts.map +1 -0
  97. package/dist/locators.js +215 -0
  98. package/dist/locators.js.map +1 -0
  99. package/dist/messages.d.ts +100 -0
  100. package/dist/messages.d.ts.map +1 -0
  101. package/dist/messages.js +112 -0
  102. package/dist/messages.js.map +1 -0
  103. package/dist/registry.d.ts +15 -0
  104. package/dist/registry.d.ts.map +1 -0
  105. package/dist/registry.js +29 -0
  106. package/dist/registry.js.map +1 -0
  107. package/dist/renderers.d.ts +7 -0
  108. package/dist/renderers.d.ts.map +1 -0
  109. package/dist/renderers.js +12 -0
  110. package/dist/renderers.js.map +1 -0
  111. package/dist/sanitize.d.ts +2 -0
  112. package/dist/sanitize.d.ts.map +1 -0
  113. package/dist/sanitize.js +49 -0
  114. package/dist/sanitize.js.map +1 -0
  115. package/dist/styles.d.ts +8 -0
  116. package/dist/styles.d.ts.map +1 -0
  117. package/dist/styles.js +122 -0
  118. package/dist/styles.js.map +1 -0
  119. package/dist/targets.d.ts +18 -0
  120. package/dist/targets.d.ts.map +1 -0
  121. package/dist/targets.js +86 -0
  122. package/dist/targets.js.map +1 -0
  123. package/dist/types.d.ts +131 -0
  124. package/dist/types.d.ts.map +1 -0
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -0
  127. package/dist/ui/BlockPicker.d.ts +3 -0
  128. package/dist/ui/BlockPicker.d.ts.map +1 -0
  129. package/dist/ui/BlockPicker.js +27 -0
  130. package/dist/ui/BlockPicker.js.map +1 -0
  131. package/dist/ui/Dialog.d.ts +22 -0
  132. package/dist/ui/Dialog.d.ts.map +1 -0
  133. package/dist/ui/Dialog.js +83 -0
  134. package/dist/ui/Dialog.js.map +1 -0
  135. package/dist/ui/Dock.d.ts +7 -0
  136. package/dist/ui/Dock.d.ts.map +1 -0
  137. package/dist/ui/Dock.js +104 -0
  138. package/dist/ui/Dock.js.map +1 -0
  139. package/dist/ui/Overlay.d.ts +2 -0
  140. package/dist/ui/Overlay.d.ts.map +1 -0
  141. package/dist/ui/Overlay.js +150 -0
  142. package/dist/ui/Overlay.js.map +1 -0
  143. package/dist/ui/PostButton.d.ts +10 -0
  144. package/dist/ui/PostButton.d.ts.map +1 -0
  145. package/dist/ui/PostButton.js +7 -0
  146. package/dist/ui/PostButton.js.map +1 -0
  147. package/dist/ui/PreviewToolbar.d.ts +15 -0
  148. package/dist/ui/PreviewToolbar.d.ts.map +1 -0
  149. package/dist/ui/PreviewToolbar.js +45 -0
  150. package/dist/ui/PreviewToolbar.js.map +1 -0
  151. package/dist/ui/SettingsPanel.d.ts +11 -0
  152. package/dist/ui/SettingsPanel.d.ts.map +1 -0
  153. package/dist/ui/SettingsPanel.js +130 -0
  154. package/dist/ui/SettingsPanel.js.map +1 -0
  155. package/dist/useEvent.d.ts +7 -0
  156. package/dist/useEvent.d.ts.map +1 -0
  157. package/dist/useEvent.js +15 -0
  158. package/dist/useEvent.js.map +1 -0
  159. package/package.json +71 -4
  160. package/src/OnPageClient.tsx +67 -0
  161. package/src/annotations.ts +83 -0
  162. package/src/context.tsx +23 -0
  163. package/src/dom.ts +118 -0
  164. package/src/editor/FieldHost.tsx +66 -0
  165. package/src/editor/Ghosts.tsx +55 -0
  166. package/src/editor/OnPageEditor.tsx +315 -0
  167. package/src/editor/useGhosts.ts +64 -0
  168. package/src/editor/usePageEvents.ts +161 -0
  169. package/src/editor-kit.ts +27 -0
  170. package/src/editors/CheckboxEditor.tsx +31 -0
  171. package/src/editors/ColorEditor.tsx +64 -0
  172. package/src/editors/DateEditor.tsx +64 -0
  173. package/src/editors/InlineText.tsx +106 -0
  174. package/src/editors/NumberEditor.tsx +69 -0
  175. package/src/editors/RelationshipEditor.tsx +153 -0
  176. package/src/editors/SelectEditor.tsx +74 -0
  177. package/src/editors/UploadEditor.tsx +130 -0
  178. package/src/editors/index.ts +35 -0
  179. package/src/editors/useDebouncedSearch.ts +54 -0
  180. package/src/hub.ts +32 -0
  181. package/src/index.ts +55 -0
  182. package/src/layout.ts +83 -0
  183. package/src/locators.ts +210 -0
  184. package/src/messages.ts +120 -0
  185. package/src/registry.ts +43 -0
  186. package/src/renderers.ts +15 -0
  187. package/src/sanitize.ts +52 -0
  188. package/src/styles.ts +122 -0
  189. package/src/targets.ts +101 -0
  190. package/src/types.ts +132 -0
  191. package/src/ui/BlockPicker.tsx +49 -0
  192. package/src/ui/Dialog.tsx +159 -0
  193. package/src/ui/Dock.tsx +240 -0
  194. package/src/ui/Overlay.tsx +324 -0
  195. package/src/ui/PostButton.tsx +26 -0
  196. package/src/ui/PreviewToolbar.tsx +123 -0
  197. package/src/ui/SettingsPanel.tsx +279 -0
  198. 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
+ }