@payload-onpage/react 0.2.0 → 0.3.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 (217) hide show
  1. package/dist/OnPageClient.d.ts.map +1 -1
  2. package/dist/OnPageClient.js +9 -5
  3. package/dist/OnPageClient.js.map +1 -1
  4. package/dist/annotations.d.ts +1 -1
  5. package/dist/annotations.d.ts.map +1 -1
  6. package/dist/context.d.ts +19 -5
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +31 -7
  9. package/dist/context.js.map +1 -1
  10. package/dist/dom.d.ts +16 -12
  11. package/dist/dom.d.ts.map +1 -1
  12. package/dist/dom.js +16 -16
  13. package/dist/dom.js.map +1 -1
  14. package/dist/editor/FieldHost.d.ts.map +1 -1
  15. package/dist/editor/FieldHost.js +18 -11
  16. package/dist/editor/FieldHost.js.map +1 -1
  17. package/dist/editor/OnPageEditor.d.ts +15 -8
  18. package/dist/editor/OnPageEditor.d.ts.map +1 -1
  19. package/dist/editor/OnPageEditor.js +73 -103
  20. package/dist/editor/OnPageEditor.js.map +1 -1
  21. package/dist/editor/editTarget.d.ts +28 -0
  22. package/dist/editor/editTarget.d.ts.map +1 -0
  23. package/dist/editor/editTarget.js +20 -0
  24. package/dist/editor/editTarget.js.map +1 -0
  25. package/dist/editor/useClientDocs.d.ts +4 -0
  26. package/dist/editor/useClientDocs.d.ts.map +1 -0
  27. package/dist/editor/useClientDocs.js +16 -0
  28. package/dist/editor/useClientDocs.js.map +1 -0
  29. package/dist/editor/useEditorSession.d.ts +13 -0
  30. package/dist/editor/useEditorSession.d.ts.map +1 -0
  31. package/dist/editor/useEditorSession.js +30 -0
  32. package/dist/editor/useEditorSession.js.map +1 -0
  33. package/dist/editor/useFlash.d.ts +4 -0
  34. package/dist/editor/useFlash.d.ts.map +1 -0
  35. package/dist/editor/useFlash.js +16 -0
  36. package/dist/editor/useFlash.js.map +1 -0
  37. package/dist/editor/useGhosts.d.ts +5 -7
  38. package/dist/editor/useGhosts.d.ts.map +1 -1
  39. package/dist/editor/useGhosts.js.map +1 -1
  40. package/dist/editor/usePageEvents.d.ts.map +1 -1
  41. package/dist/editor/usePageEvents.js +16 -16
  42. package/dist/editor/usePageEvents.js.map +1 -1
  43. package/dist/editor-kit.d.ts +10 -6
  44. package/dist/editor-kit.d.ts.map +1 -1
  45. package/dist/editor-kit.js +8 -5
  46. package/dist/editor-kit.js.map +1 -1
  47. package/dist/editors/CheckboxEditor.d.ts +6 -1
  48. package/dist/editors/CheckboxEditor.d.ts.map +1 -1
  49. package/dist/editors/CheckboxEditor.js +6 -3
  50. package/dist/editors/CheckboxEditor.js.map +1 -1
  51. package/dist/editors/ColorEditor.d.ts +3 -1
  52. package/dist/editors/ColorEditor.d.ts.map +1 -1
  53. package/dist/editors/ColorEditor.js +9 -15
  54. package/dist/editors/ColorEditor.js.map +1 -1
  55. package/dist/editors/DateEditor.d.ts +3 -1
  56. package/dist/editors/DateEditor.d.ts.map +1 -1
  57. package/dist/editors/DateEditor.js +10 -12
  58. package/dist/editors/DateEditor.js.map +1 -1
  59. package/dist/editors/InlineText.d.ts +2 -2
  60. package/dist/editors/InlineText.d.ts.map +1 -1
  61. package/dist/editors/InlineText.js +2 -3
  62. package/dist/editors/InlineText.js.map +1 -1
  63. package/dist/editors/NumberEditor.d.ts +3 -1
  64. package/dist/editors/NumberEditor.d.ts.map +1 -1
  65. package/dist/editors/NumberEditor.js +9 -17
  66. package/dist/editors/NumberEditor.js.map +1 -1
  67. package/dist/editors/RelationshipEditor.d.ts +7 -1
  68. package/dist/editors/RelationshipEditor.d.ts.map +1 -1
  69. package/dist/editors/RelationshipEditor.js +10 -18
  70. package/dist/editors/RelationshipEditor.js.map +1 -1
  71. package/dist/editors/SelectEditor.d.ts +4 -1
  72. package/dist/editors/SelectEditor.d.ts.map +1 -1
  73. package/dist/editors/SelectEditor.js +8 -13
  74. package/dist/editors/SelectEditor.js.map +1 -1
  75. package/dist/editors/UploadEditor.d.ts +7 -1
  76. package/dist/editors/UploadEditor.d.ts.map +1 -1
  77. package/dist/editors/UploadEditor.js +5 -5
  78. package/dist/editors/UploadEditor.js.map +1 -1
  79. package/dist/editors/index.d.ts +0 -1
  80. package/dist/editors/index.d.ts.map +1 -1
  81. package/dist/editors/index.js +56 -16
  82. package/dist/editors/index.js.map +1 -1
  83. package/dist/editors/kit.d.ts +39 -0
  84. package/dist/editors/kit.d.ts.map +1 -0
  85. package/dist/editors/kit.js +44 -0
  86. package/dist/editors/kit.js.map +1 -0
  87. package/dist/editors/useDebouncedSearch.d.ts +10 -6
  88. package/dist/editors/useDebouncedSearch.d.ts.map +1 -1
  89. package/dist/editors/useDebouncedSearch.js +11 -7
  90. package/dist/editors/useDebouncedSearch.js.map +1 -1
  91. package/dist/geometry.d.ts +49 -0
  92. package/dist/geometry.d.ts.map +1 -0
  93. package/dist/geometry.js +48 -0
  94. package/dist/geometry.js.map +1 -0
  95. package/dist/hub.d.ts +4 -4
  96. package/dist/hub.d.ts.map +1 -1
  97. package/dist/hub.js.map +1 -1
  98. package/dist/index.d.ts +3 -3
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +2 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/layout.d.ts.map +1 -1
  103. package/dist/layout.js +4 -2
  104. package/dist/layout.js.map +1 -1
  105. package/dist/locators.d.ts +0 -2
  106. package/dist/locators.d.ts.map +1 -1
  107. package/dist/locators.js +10 -16
  108. package/dist/locators.js.map +1 -1
  109. package/dist/messages.d.ts +15 -2
  110. package/dist/messages.d.ts.map +1 -1
  111. package/dist/messages.js +5 -0
  112. package/dist/messages.js.map +1 -1
  113. package/dist/registry.d.ts +21 -2
  114. package/dist/registry.d.ts.map +1 -1
  115. package/dist/registry.js +31 -6
  116. package/dist/registry.js.map +1 -1
  117. package/dist/rowOps.d.ts +20 -0
  118. package/dist/rowOps.d.ts.map +1 -0
  119. package/dist/rowOps.js +48 -0
  120. package/dist/rowOps.js.map +1 -0
  121. package/dist/sanitize.d.ts +2 -0
  122. package/dist/sanitize.d.ts.map +1 -1
  123. package/dist/sanitize.js +3 -0
  124. package/dist/sanitize.js.map +1 -1
  125. package/dist/settings.d.ts +10 -0
  126. package/dist/settings.d.ts.map +1 -0
  127. package/dist/settings.js +30 -0
  128. package/dist/settings.js.map +1 -0
  129. package/dist/styles.d.ts +0 -4
  130. package/dist/styles.d.ts.map +1 -1
  131. package/dist/styles.js +2 -1
  132. package/dist/styles.js.map +1 -1
  133. package/dist/targets.d.ts +1 -1
  134. package/dist/targets.d.ts.map +1 -1
  135. package/dist/types.d.ts +71 -46
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/ui/BlockPicker.d.ts.map +1 -1
  138. package/dist/ui/BlockPicker.js +6 -5
  139. package/dist/ui/BlockPicker.js.map +1 -1
  140. package/dist/ui/Dialog.d.ts +0 -5
  141. package/dist/ui/Dialog.d.ts.map +1 -1
  142. package/dist/ui/Dialog.js +10 -6
  143. package/dist/ui/Dialog.js.map +1 -1
  144. package/dist/ui/Dock.d.ts +1 -6
  145. package/dist/ui/Dock.d.ts.map +1 -1
  146. package/dist/ui/Dock.js +46 -28
  147. package/dist/ui/Dock.js.map +1 -1
  148. package/dist/ui/Overlay.d.ts.map +1 -1
  149. package/dist/ui/Overlay.js +39 -37
  150. package/dist/ui/Overlay.js.map +1 -1
  151. package/dist/ui/PreviewToolbar.d.ts +3 -3
  152. package/dist/ui/PreviewToolbar.d.ts.map +1 -1
  153. package/dist/ui/PreviewToolbar.js +4 -6
  154. package/dist/ui/PreviewToolbar.js.map +1 -1
  155. package/dist/ui/SettingsPanel.d.ts +0 -9
  156. package/dist/ui/SettingsPanel.d.ts.map +1 -1
  157. package/dist/ui/SettingsPanel.js +36 -41
  158. package/dist/ui/SettingsPanel.js.map +1 -1
  159. package/dist/ui/ToolbarButton.d.ts +12 -0
  160. package/dist/ui/ToolbarButton.d.ts.map +1 -0
  161. package/dist/ui/ToolbarButton.js +7 -0
  162. package/dist/ui/ToolbarButton.js.map +1 -0
  163. package/dist/useEvent.d.ts +1 -1
  164. package/dist/useEvent.d.ts.map +1 -1
  165. package/dist/useEvent.js +3 -1
  166. package/dist/useEvent.js.map +1 -1
  167. package/dist/useIsClient.d.ts +5 -0
  168. package/dist/useIsClient.d.ts.map +1 -0
  169. package/dist/useIsClient.js +8 -0
  170. package/dist/useIsClient.js.map +1 -0
  171. package/package.json +2 -2
  172. package/src/OnPageClient.tsx +9 -5
  173. package/src/annotations.ts +1 -1
  174. package/src/context.tsx +53 -10
  175. package/src/dom.ts +17 -18
  176. package/src/editor/FieldHost.tsx +26 -11
  177. package/src/editor/OnPageEditor.tsx +103 -160
  178. package/src/editor/editTarget.ts +41 -0
  179. package/src/editor/useClientDocs.ts +19 -0
  180. package/src/editor/useEditorSession.ts +36 -0
  181. package/src/editor/useFlash.ts +18 -0
  182. package/src/editor/useGhosts.ts +5 -7
  183. package/src/editor/usePageEvents.ts +13 -14
  184. package/src/editor-kit.ts +10 -6
  185. package/src/editors/CheckboxEditor.tsx +11 -12
  186. package/src/editors/ColorEditor.tsx +15 -35
  187. package/src/editors/DateEditor.tsx +18 -37
  188. package/src/editors/InlineText.tsx +4 -5
  189. package/src/editors/NumberEditor.tsx +15 -36
  190. package/src/editors/RelationshipEditor.tsx +18 -36
  191. package/src/editors/SelectEditor.tsx +14 -22
  192. package/src/editors/UploadEditor.tsx +6 -6
  193. package/src/editors/index.ts +56 -16
  194. package/src/editors/kit.tsx +97 -0
  195. package/src/editors/useDebouncedSearch.ts +18 -10
  196. package/src/geometry.ts +62 -0
  197. package/src/hub.ts +5 -5
  198. package/src/index.ts +5 -2
  199. package/src/layout.ts +5 -2
  200. package/src/locators.ts +11 -14
  201. package/src/messages.ts +26 -5
  202. package/src/registry.ts +42 -8
  203. package/src/rowOps.ts +64 -0
  204. package/src/sanitize.ts +6 -0
  205. package/src/settings.ts +49 -0
  206. package/src/styles.ts +2 -1
  207. package/src/targets.ts +1 -1
  208. package/src/types.ts +78 -45
  209. package/src/ui/BlockPicker.tsx +6 -7
  210. package/src/ui/Dialog.tsx +9 -5
  211. package/src/ui/Dock.tsx +67 -50
  212. package/src/ui/Overlay.tsx +75 -102
  213. package/src/ui/PreviewToolbar.tsx +7 -15
  214. package/src/ui/SettingsPanel.tsx +84 -81
  215. package/src/ui/ToolbarButton.tsx +36 -0
  216. package/src/useEvent.ts +4 -2
  217. package/src/useIsClient.ts +15 -0
@@ -12,10 +12,11 @@
12
12
  * invisible markers (turn off with `autoMap={false}`).
13
13
  */
14
14
  import { useEvent } from './useEvent.ts'
15
+ import { toRef } from './annotations.ts'
15
16
  import { encodeDocument, refKey, type DocRef } from '@payload-onpage/core'
16
17
  import { useEffect, useMemo, type ReactNode } from 'react'
17
18
  import { editable, editableOff, type Editable } from './annotations.ts'
18
- import { useOnPageOptional } from './context.tsx'
19
+ import { useOnPageOptional, useSessionSnapshot } from './context.tsx'
19
20
 
20
21
  export type OnPageClientProps<T> = {
21
22
  doc: T
@@ -33,9 +34,9 @@ export function OnPageClient<T extends { id?: string | number } | null | undefin
33
34
  const ref = useMemo<DocRef | null>(
34
35
  () =>
35
36
  globalSlug
36
- ? { kind: 'global', slug: globalSlug }
37
- : collection && id != null
38
- ? { kind: 'collection', slug: collection, id }
37
+ ? toRef({ global: globalSlug })
38
+ : collection
39
+ ? toRef({ collection, doc: { id } })
39
40
  : null,
40
41
  [globalSlug, collection, id],
41
42
  )
@@ -51,7 +52,8 @@ export function OnPageClient<T extends { id?: string | number } | null | undefin
51
52
  })
52
53
  useEffect(() => (enabled && ref ? join(ref) : undefined), [enabled, ref, join])
53
54
 
54
- const state = key ? ctx?.snap.docs[key] : undefined
55
+ const snap = useSessionSnapshot(ctx?.session)
56
+ const state = key ? snap.docs[key] : undefined
55
57
  const stateDoc = state?.doc
56
58
  const fields = state?.schema?.fields
57
59
  const autoMap = props.autoMap !== false
@@ -63,5 +65,7 @@ export function OnPageClient<T extends { id?: string | number } | null | undefin
63
65
  () => (enabled && ref ? editable(ref, { enabled: true }) : editableOff),
64
66
  [enabled, ref],
65
67
  )
68
+ // The session's copy started as this `doc` (same document, edited, held as plain JSON),
69
+ // so it is handed back as the page's own type.
66
70
  return <>{render(current as T, e)}</>
67
71
  }
@@ -51,7 +51,7 @@ export const editableOff: Editable = {
51
51
 
52
52
  export type EditableTarget =
53
53
  | DocRef
54
- | { collection: string; doc: { id?: string | number } | null | undefined }
54
+ | { collection: string; doc: { id?: string | number | undefined } | null | undefined }
55
55
  | { global: string }
56
56
 
57
57
  export function toRef(t: EditableTarget): DocRef | null {
package/src/context.tsx CHANGED
@@ -1,23 +1,66 @@
1
1
  'use client'
2
2
 
3
- import { createContext, useContext, useSyncExternalStore } from 'react'
3
+ import { createContext, useContext, useSyncExternalStore, type ReactNode } from 'react'
4
+ import type { EditorSession, SessionSnapshot } from '@payload-onpage/core'
4
5
  import { hub } from './hub.ts'
5
- import type { OnPageContextValue } from './types.ts'
6
+ import type { OnPageApi, OnPageContextValue, OnPageState } from './types.ts'
6
7
 
7
- export const OnPageContext = createContext<OnPageContextValue | null>(null)
8
+ const ApiContext = createContext<OnPageApi | null>(null)
9
+ const StateContext = createContext<OnPageState | null>(null)
8
10
 
9
- /** The editor, for code rendered inside it (field editors, panels). */
11
+ /** Provides the editor to everything it renders (two contexts: the stable API and the changing state). */
12
+ export function OnPageProvider({
13
+ api,
14
+ state,
15
+ children,
16
+ }: {
17
+ api: OnPageApi
18
+ state: OnPageState
19
+ children: ReactNode
20
+ }) {
21
+ return (
22
+ <ApiContext.Provider value={api}>
23
+ <StateContext.Provider value={state}>{children}</StateContext.Provider>
24
+ </ApiContext.Provider>
25
+ )
26
+ }
27
+
28
+ /** The editor, for code rendered inside it (field editors, panels): its API and state. */
10
29
  export function useOnPage(): OnPageContextValue {
11
- const ctx = useContext(OnPageContext)
12
- if (!ctx) throw new Error('useOnPage must be used inside <OnPageEditor>')
13
- return ctx
30
+ const api = useContext(ApiContext)
31
+ const state = useContext(StateContext)
32
+ if (!api || !state) throw new Error('useOnPage must be used inside <OnPageEditor>')
33
+ return { ...api, ...state }
34
+ }
35
+
36
+ /** Only the editor's API (session, actions): re-renders when the editor is reconfigured, not on every edit. */
37
+ export function useOnPageApi(): OnPageApi {
38
+ const api = useContext(ApiContext)
39
+ if (!api) throw new Error('useOnPageApi must be used inside <OnPageEditor>')
40
+ return api
14
41
  }
15
42
 
16
43
  const getNull = () => null
17
44
 
18
- /** The editor, from inside it or anywhere on the page; null when no editor is mounted (public pages). */
19
- export function useOnPageOptional(): OnPageContextValue | null {
20
- const inside = useContext(OnPageContext)
45
+ /**
46
+ * The editor's API from inside it or anywhere on the page; null when no editor
47
+ * is mounted (public pages). Read documents with `useSessionSnapshot(api?.session)`.
48
+ */
49
+ export function useOnPageOptional(): OnPageApi | null {
50
+ const inside = useContext(ApiContext)
21
51
  const outside = useSyncExternalStore(hub.subscribe, hub.get, getNull)
22
52
  return inside ?? outside
23
53
  }
54
+
55
+ const EMPTY: SessionSnapshot = { docs: {}, canUndo: false, canRedo: false, busy: false }
56
+ const noopSubscribe = () => () => {}
57
+ const getEmpty = () => EMPTY
58
+
59
+ /** A session's documents, re-rendering when they change (an empty snapshot without a session). */
60
+ export function useSessionSnapshot(session: EditorSession | null | undefined): SessionSnapshot {
61
+ return useSyncExternalStore(
62
+ session?.subscribe ?? noopSubscribe,
63
+ session?.getSnapshot ?? getEmpty,
64
+ getEmpty,
65
+ )
66
+ }
package/src/dom.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  /** DOM helpers shared by the editor UI (browser-only, no React). */
2
2
 
3
- /** Attribute that hides a page element while an editor or ghost stands in for it. */
4
- export const HIDDEN = 'data-onpage-hidden'
5
- /** Marks the editor's own UI, which locators and click handling ignore. */
6
- export const UI = 'data-onpage-ui'
3
+ /** Every DOM attribute the editor sets (annotations, which sites write, are `ATTR`). */
4
+ export const DOM_ATTR = {
5
+ /** Hides a page element while an editor or ghost stands in for it. */
6
+ hidden: 'data-onpage-hidden',
7
+ /** Marks the editor's own UI, which locators and click handling ignore. */
8
+ ui: 'data-onpage-ui',
9
+ /** A page target the editor made keyboard-focusable. */
10
+ focusable: 'data-onpage-focusable',
11
+ /** On <html> while editing. */
12
+ editing: 'data-onpage-editing',
13
+ /** The editor's injected stylesheet. */
14
+ styles: 'data-onpage-styles',
15
+ } as const
16
+
17
+ export const HIDDEN = DOM_ATTR.hidden
18
+ export const UI = DOM_ATTR.ui
7
19
 
8
20
  /** Computed styles copied onto an inline editor so it looks like the element it replaces. */
9
21
  export const TYPOGRAPHY = [
@@ -94,20 +106,7 @@ export function placeCaret(el: HTMLElement, point?: { x: number; y: number }): v
94
106
  sel.addRange(range)
95
107
  }
96
108
 
97
- /** Position a fixed popover below (or above) an element, kept inside the viewport. */
98
- export function popoverPosition(
99
- el: Element | null,
100
- size = { width: 300, height: 320 },
101
- ): { top: number; left: number } {
102
- const vw = window.innerWidth
103
- const vh = window.innerHeight
104
- if (!el) return { top: 80, left: Math.max(8, vw / 2 - size.width / 2) }
105
- const r = el.getBoundingClientRect()
106
- const below = r.bottom + 8
107
- const top = below + size.height < vh ? below : Math.max(8, r.top - 8 - size.height)
108
- const left = Math.min(Math.max(8, r.left), vw - size.width - 8)
109
- return { top, left }
110
- }
109
+ export { popoverPosition } from './geometry.ts'
111
110
 
112
111
  const FOCUSABLE =
113
112
  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
@@ -1,19 +1,34 @@
1
1
  'use client'
2
2
 
3
- import { fieldAtPath, getAt } from '@payload-onpage/core'
3
+ import { fieldAtPath, getAt, isReady, type ReadyDocumentState } from '@payload-onpage/core'
4
4
  import { Suspense, useCallback } from 'react'
5
5
  import { useOnPage } from '../context.tsx'
6
- import type { FieldEditorProps } from '../types.ts'
6
+ import { canEdit } from '../registry.ts'
7
+ import type { EditingState, FieldEditorDef, FieldEditorProps } from '../types.ts'
7
8
 
8
9
  /** Renders the open field editor, if it belongs to this layer. */
9
10
  export function FieldHost({ mode }: { mode: 'inline' | 'popover' }) {
10
- const { editing, editors, session, snap, targets, setEditing, addGhost, isLive, t } = useOnPage()
11
- const def = editing ? editors[editing.editor] : undefined
12
- const state = editing ? snap.docs[editing.key] : undefined
13
- const key = editing?.key ?? ''
14
- const path = editing?.path ?? ''
11
+ const { editing, editors, snap } = useOnPage()
12
+ if (!editing) return null
13
+ const def = editors[editing.editor]
14
+ const state = snap.docs[editing.key]
15
+ if (!def || def.mode !== mode || !isReady(state)) return null
16
+ return <OpenEditor editing={editing} def={def} state={state} />
17
+ }
18
+
19
+ /** The editor for one field of a loaded document. */
20
+ function OpenEditor({
21
+ editing,
22
+ def,
23
+ state,
24
+ }: {
25
+ editing: EditingState
26
+ def: FieldEditorDef
27
+ state: ReadyDocumentState
28
+ }) {
29
+ const { session, targets, setEditing, addGhost, isLive, t } = useOnPage()
30
+ const { key, path, el: clicked } = editing
15
31
  const live = isLive(key)
16
- const clicked = editing?.el
17
32
 
18
33
  const element = useCallback(
19
34
  () => (clicked?.isConnected ? clicked : (targets.find(key, path)?.el ?? null)),
@@ -39,10 +54,10 @@ export function FieldHost({ mode }: { mode: 'inline' | 'popover' }) {
39
54
  [addGhost, key, path],
40
55
  )
41
56
 
42
- if (!editing || !def || def.mode !== mode || !state?.schema || !state.doc) return null
43
57
  const field = fieldAtPath(state.schema.fields, state.doc, path)?.field
44
- if (!field) return null
58
+ if (!field || !canEdit(def, field)) return null
45
59
  const Editor = def.component
60
+ const raw = getAt(state.doc, path)
46
61
  // Editors may be lazy (the rich-text editor is): render nothing until they've loaded.
47
62
  return (
48
63
  <Suspense fallback={null}>
@@ -52,7 +67,7 @@ export function FieldHost({ mode }: { mode: 'inline' | 'popover' }) {
52
67
  path={path}
53
68
  field={field}
54
69
  element={element}
55
- value={getAt(state.doc, path)}
70
+ value={def.normalize ? def.normalize(raw, field) : raw}
56
71
  commit={commit}
57
72
  preview={preview}
58
73
  close={close}
@@ -7,38 +7,33 @@
7
7
  * through the DataSource; the Renderer shows them (server refresh by default,
8
8
  * instant for documents rendered with <OnPageClient>).
9
9
  */
10
- import { useEvent } from '../useEvent.ts'
11
10
  import {
12
- EditorSession,
13
- fieldAtPath,
14
- isEditablePath,
15
11
  refKey,
16
12
  type DataSource,
17
13
  type DocRef,
18
14
  type Locator,
19
15
  type MediaLibrary,
16
+ type RefKey,
20
17
  type Renderer,
21
- type SessionSnapshot,
22
18
  } from '@payload-onpage/core'
23
- import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
19
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
24
20
  import { createPortal } from 'react-dom'
25
- import { OnPageContext } from '../context.tsx'
21
+ import { OnPageProvider, useSessionSnapshot } from '../context.tsx'
26
22
  import { UI } from '../dom.ts'
27
23
  import { defaultEditors } from '../editors/index.ts'
28
24
  import { hub } from '../hub.ts'
29
25
  import { notifyLayout } from '../layout.ts'
30
26
  import { annotations, composite, stega } from '../locators.ts'
31
27
  import { createTranslate, type Messages } from '../messages.ts'
32
- import { mergeEditors, resolveEditor } from '../registry.ts'
28
+ import { mergeEditors } from '../registry.ts'
33
29
  import { clientState } from '../renderers.ts'
34
- import { injectStyles } from '../styles.ts'
35
30
  import { useTargets } from '../targets.ts'
36
31
  import type {
37
32
  EditingState,
38
- EditorRegistry,
39
33
  FieldEditorDef,
40
34
  Links,
41
- OnPageContextValue,
35
+ OnPageApi,
36
+ OnPageState,
42
37
  Panel,
43
38
  Selection,
44
39
  } from '../types.ts'
@@ -46,43 +41,46 @@ import { BlockPicker } from '../ui/BlockPicker.tsx'
46
41
  import { Dock } from '../ui/Dock.tsx'
47
42
  import { Overlay } from '../ui/Overlay.tsx'
48
43
  import { SettingsPanel } from '../ui/SettingsPanel.tsx'
44
+ import { useEditorStyles, useIsClient } from '../useIsClient.ts'
45
+ import { useEvent } from '../useEvent.ts'
46
+ import { resolveEditTarget } from './editTarget.ts'
49
47
  import { FieldHost } from './FieldHost.tsx'
50
48
  import { Ghosts } from './Ghosts.tsx'
51
- import { usePageEvents } from './usePageEvents.ts'
49
+ import { useClientDocs } from './useClientDocs.ts'
50
+ import { useEditorSession } from './useEditorSession.ts'
51
+ import { useFlash } from './useFlash.ts'
52
52
  import { useGhosts } from './useGhosts.ts'
53
+ import { usePageEvents } from './usePageEvents.ts'
53
54
 
54
55
  export interface OnPageEditorProps {
55
56
  dataSource: DataSource
56
57
  /** Image search/upload (e.g. `payloadUploads()`). Without it, upload fields can't be changed. */
57
- media?: MediaLibrary
58
+ media?: MediaLibrary | undefined
58
59
  /** How elements map to fields. Default: composite(annotations(), stega()). */
59
- locator?: Locator
60
+ locator?: Locator | undefined
60
61
  /** How saved changes reach the page. Default: clientState() (no server refresh). */
61
- renderer?: Renderer
62
+ renderer?: Renderer | undefined
62
63
  /** Extra or replacement field editors by id; `null` removes a default. */
63
- editors?: Record<string, FieldEditorDef | null>
64
+ editors?: Record<string, FieldEditorDef | null> | undefined
64
65
  links: Links
65
66
  /** Content locale to edit. */
66
- locale?: string
67
+ locale?: string | undefined
67
68
  /** Autosave debounce, ms. Default: 250 with a refreshing renderer; else the collection's autosave interval, else 800. */
68
- autosaveDelay?: number
69
+ autosaveDelay?: number | undefined
69
70
  /** Where to look for editable content. Default: document.body. */
70
- root?: () => ParentNode | null
71
+ root?: (() => ParentNode | null) | undefined
71
72
  /** Override any UI string (see `defaultMessages`). */
72
- messages?: Messages
73
+ messages?: Messages | undefined
73
74
  }
74
75
 
75
- const EMPTY_SNAP: SessionSnapshot = { docs: {}, canUndo: false, canRedo: false, busy: false }
76
- const noopSubscribe = () => () => {}
76
+ /** How long a re-rendered page gets to land (and the locator to rescan) before ghosts are retired. */
77
+ const SETTLE_MS = 250
77
78
 
78
79
  export function OnPageEditor(props: OnPageEditorProps) {
79
- const { dataSource, locale, autosaveDelay, links, media } = props
80
+ const { dataSource, locale, links, media } = props
80
81
  const renderer = useMemo(() => props.renderer ?? clientState(), [props.renderer])
81
82
  const locator = useMemo(() => props.locator ?? composite(annotations(), stega()), [props.locator])
82
- const editors: EditorRegistry = useMemo(
83
- () => mergeEditors(defaultEditors, props.editors),
84
- [props.editors],
85
- )
83
+ const editors = useMemo(() => mergeEditors(defaultEditors, props.editors), [props.editors])
86
84
  const t = useMemo(() => createTranslate(props.messages), [props.messages])
87
85
  const rootProp = props.root
88
86
  const root = useCallback(
@@ -94,161 +92,112 @@ export function OnPageEditor(props: OnPageEditorProps) {
94
92
  const [editing, setEditing] = useState<EditingState | null>(null)
95
93
  const [selected, setSelected] = useState<Selection | null>(null)
96
94
  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
-
95
+ const [flash, notify] = useFlash()
96
+ const [clientDocs, registerClientDoc] = useClientDocs()
108
97
  const isLive = useCallback(
109
- (key: string) => renderer.live || clientDocs.has(key),
98
+ (key: RefKey) => renderer.live || clientDocs.has(key),
110
99
  [renderer, clientDocs],
111
100
  )
112
101
 
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))
102
+ // Refresh mode saves quickly (the page only updates after a save); client mode uses the collection's interval.
103
+ const session = useEditorSession({
104
+ dataSource,
105
+ locale,
106
+ autosaveDelay: props.autosaveDelay ?? (renderer.live ? undefined : 250),
107
+ onSaved: (ref, seq) => void rerender([ref], seq),
108
+ })
109
+ const snap = useSessionSnapshot(session)
110
+ const targets = useTargets(locator, root)
111
+ const { ghosts, add: addGhostEntry, rendered: ghostsRendered } = useGhosts(session ? snap : null)
135
112
 
136
- // One session per data source and locale; disposed (after flushing) when they change or on unmount.
113
+ // Pending "retire ghosts" timers, cleared on unmount.
114
+ const ghostTimers = useRef(new Set<ReturnType<typeof setTimeout>>())
137
115
  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)
116
+ const timers = ghostTimers.current
150
117
  return () => {
151
- void s.flush().finally(() => s.dispose())
118
+ for (const id of timers) clearTimeout(id)
119
+ timers.clear()
152
120
  }
153
- }, [dataSource, locale, autosaveDelay, renderer.live, handleSaved])
121
+ }, [])
154
122
 
155
- useEffect(() => {
156
- if (!flash) return
157
- const id = setTimeout(() => setFlash(null), 4000)
158
- return () => clearTimeout(id)
159
- }, [flash])
123
+ /** Re-render documents that aren't client-rendered, then retire their ghosts. */
124
+ const rerender = useEvent(async (refs: DocRef[], seq?: number) => {
125
+ const stale = refs.filter((r) => !isLive(refKey(r)))
126
+ if (!stale.length) return
127
+ setRenders((n) => n + 1)
128
+ try {
129
+ await renderer.afterSave({ refs: stale })
130
+ } catch (e) {
131
+ console.error('[payload-onpage] render after save failed', e)
132
+ } finally {
133
+ setRenders((n) => n - 1)
134
+ const id = setTimeout(() => {
135
+ ghostTimers.current.delete(id)
136
+ for (const r of stale) ghostsRendered(refKey(r), seq ?? Number.MAX_SAFE_INTEGER)
137
+ }, SETTLE_MS)
138
+ ghostTimers.current.add(id)
139
+ }
140
+ })
160
141
 
161
142
  // Documents on this page: everything the locator found, plus client-mode documents.
162
143
  const docKeys = useMemo(() => {
163
- const keys = new Set<string>(clientDocs)
144
+ const keys = new Set<RefKey>(clientDocs)
164
145
  for (const target of targets.all) keys.add(refKey(target.ref))
165
146
  return [...keys]
166
147
  }, [targets, clientDocs])
167
148
 
168
149
  useEffect(() => {
169
150
  if (!session) return
170
- const seen = new Set<string>()
151
+ const seen = new Set<RefKey>()
171
152
  for (const target of targets.all) {
172
- const k = refKey(target.ref)
173
- if (seen.has(k)) continue
174
- seen.add(k)
153
+ const key = refKey(target.ref)
154
+ if (seen.has(key)) continue
155
+ seen.add(key)
175
156
  void session.open(target.ref)
176
157
  }
177
158
  }, [targets, session])
178
159
 
179
160
  useEffect(() => notifyLayout(), [snap, targets])
180
161
 
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],
162
+ const addGhost = useEvent<OnPageApi['addGhost']>((g) =>
163
+ addGhostEntry(g, session?.get(g.key)?.save.seq ?? 0),
199
164
  )
200
165
 
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')
166
+ // Stable: reads the latest state when called, so the API doesn't change on every rescan.
167
+ const edit = useEvent<OnPageApi['edit']>((key, path, opts = {}) => {
168
+ const target = resolveEditTarget(session?.get(key), editors, key, path, {
169
+ point: opts.point,
170
+ kind: opts.kind,
171
+ el: opts.anchor ?? targets.find(key, path)?.el,
172
+ })
173
+ switch (target.kind) {
174
+ case 'not-ready':
175
+ if (target.state?.status === 'error')
176
+ notify(t('notify.cantEdit', { message: target.state.error ?? '' }), true)
177
+ else notify(t('notify.loading'))
208
178
  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
- )
179
+ case 'none':
180
+ return false
181
+ case 'edit':
182
+ setEditing(target.editing)
183
+ return true
184
+ }
185
+ })
230
186
 
231
- const ctx = useMemo<OnPageContextValue | null>(
187
+ const api = useMemo<OnPageApi | null>(
232
188
  () =>
233
189
  session && {
234
190
  session,
235
- snap,
236
191
  renderer,
237
192
  media,
238
193
  editors,
239
- targets,
240
194
  links,
241
195
  t,
242
- panel,
243
196
  setPanel,
244
- editing,
245
197
  setEditing,
246
- selected,
247
198
  setSelected,
248
- docKeys,
249
199
  isLive,
250
200
  registerClientDoc,
251
- rendering: renders > 0,
252
201
  edit,
253
202
  rerender: (refs) => rerender(refs),
254
203
  notify,
@@ -256,43 +205,37 @@ export function OnPageEditor(props: OnPageEditorProps) {
256
205
  },
257
206
  [
258
207
  session,
259
- snap,
260
208
  renderer,
261
209
  media,
262
210
  editors,
263
- targets,
264
211
  links,
265
212
  t,
266
- panel,
267
- editing,
268
- selected,
269
- docKeys,
270
213
  isLive,
271
214
  registerClientDoc,
272
- renders,
273
215
  edit,
274
216
  rerender,
275
217
  notify,
276
218
  addGhost,
277
219
  ],
278
220
  )
221
+ const state = useMemo<OnPageState>(
222
+ () => ({ snap, targets, panel, editing, selected, docKeys, rendering: renders > 0, flash }),
223
+ [snap, targets, panel, editing, selected, docKeys, renders, flash],
224
+ )
279
225
 
226
+ // Components outside the editor's tree (client-mode documents) reach it through the hub.
280
227
  useEffect(() => {
281
- hub.set(ctx)
282
- }, [ctx])
228
+ hub.set(api)
229
+ }, [api])
283
230
  useEffect(() => () => hub.set(null), [])
284
- useEffect(() => injectStyles(), [])
285
- usePageEvents(ctx, root)
231
+ useEditorStyles()
232
+ usePageEvents(api && { ...api, ...state }, root)
286
233
 
287
- const mounted = useSyncExternalStore(
288
- noopSubscribe,
289
- () => true,
290
- () => false,
291
- )
292
- if (!mounted || !ctx) return null
234
+ const mounted = useIsClient()
235
+ if (!mounted || !api) return null
293
236
 
294
237
  return (
295
- <OnPageContext.Provider value={ctx}>
238
+ <OnPageProvider api={api} state={state}>
296
239
  {createPortal(
297
240
  <div className="po-doc-layer" {...{ [UI]: '' }}>
298
241
  <Ghosts ghosts={ghosts} />
@@ -306,10 +249,10 @@ export function OnPageEditor(props: OnPageEditorProps) {
306
249
  <SettingsPanel />
307
250
  <BlockPicker />
308
251
  <FieldHost mode="popover" />
309
- <Dock flash={flash} />
252
+ <Dock />
310
253
  </div>,
311
254
  document.body,
312
255
  )}
313
- </OnPageContext.Provider>
256
+ </OnPageProvider>
314
257
  )
315
258
  }