@payload-onpage/react 0.2.1 → 0.3.1

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 (218) hide show
  1. package/README.md +6 -0
  2. package/dist/OnPageClient.d.ts.map +1 -1
  3. package/dist/OnPageClient.js +9 -5
  4. package/dist/OnPageClient.js.map +1 -1
  5. package/dist/annotations.d.ts +1 -1
  6. package/dist/annotations.d.ts.map +1 -1
  7. package/dist/context.d.ts +19 -5
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js +31 -7
  10. package/dist/context.js.map +1 -1
  11. package/dist/dom.d.ts +16 -12
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +16 -16
  14. package/dist/dom.js.map +1 -1
  15. package/dist/editor/FieldHost.d.ts.map +1 -1
  16. package/dist/editor/FieldHost.js +18 -11
  17. package/dist/editor/FieldHost.js.map +1 -1
  18. package/dist/editor/OnPageEditor.d.ts +15 -8
  19. package/dist/editor/OnPageEditor.d.ts.map +1 -1
  20. package/dist/editor/OnPageEditor.js +73 -103
  21. package/dist/editor/OnPageEditor.js.map +1 -1
  22. package/dist/editor/editTarget.d.ts +28 -0
  23. package/dist/editor/editTarget.d.ts.map +1 -0
  24. package/dist/editor/editTarget.js +20 -0
  25. package/dist/editor/editTarget.js.map +1 -0
  26. package/dist/editor/useClientDocs.d.ts +4 -0
  27. package/dist/editor/useClientDocs.d.ts.map +1 -0
  28. package/dist/editor/useClientDocs.js +16 -0
  29. package/dist/editor/useClientDocs.js.map +1 -0
  30. package/dist/editor/useEditorSession.d.ts +13 -0
  31. package/dist/editor/useEditorSession.d.ts.map +1 -0
  32. package/dist/editor/useEditorSession.js +30 -0
  33. package/dist/editor/useEditorSession.js.map +1 -0
  34. package/dist/editor/useFlash.d.ts +4 -0
  35. package/dist/editor/useFlash.d.ts.map +1 -0
  36. package/dist/editor/useFlash.js +16 -0
  37. package/dist/editor/useFlash.js.map +1 -0
  38. package/dist/editor/useGhosts.d.ts +5 -7
  39. package/dist/editor/useGhosts.d.ts.map +1 -1
  40. package/dist/editor/useGhosts.js.map +1 -1
  41. package/dist/editor/usePageEvents.d.ts.map +1 -1
  42. package/dist/editor/usePageEvents.js +16 -16
  43. package/dist/editor/usePageEvents.js.map +1 -1
  44. package/dist/editor-kit.d.ts +10 -6
  45. package/dist/editor-kit.d.ts.map +1 -1
  46. package/dist/editor-kit.js +8 -5
  47. package/dist/editor-kit.js.map +1 -1
  48. package/dist/editors/CheckboxEditor.d.ts +6 -1
  49. package/dist/editors/CheckboxEditor.d.ts.map +1 -1
  50. package/dist/editors/CheckboxEditor.js +6 -3
  51. package/dist/editors/CheckboxEditor.js.map +1 -1
  52. package/dist/editors/ColorEditor.d.ts +3 -1
  53. package/dist/editors/ColorEditor.d.ts.map +1 -1
  54. package/dist/editors/ColorEditor.js +9 -15
  55. package/dist/editors/ColorEditor.js.map +1 -1
  56. package/dist/editors/DateEditor.d.ts +3 -1
  57. package/dist/editors/DateEditor.d.ts.map +1 -1
  58. package/dist/editors/DateEditor.js +10 -12
  59. package/dist/editors/DateEditor.js.map +1 -1
  60. package/dist/editors/InlineText.d.ts +2 -2
  61. package/dist/editors/InlineText.d.ts.map +1 -1
  62. package/dist/editors/InlineText.js +2 -3
  63. package/dist/editors/InlineText.js.map +1 -1
  64. package/dist/editors/NumberEditor.d.ts +3 -1
  65. package/dist/editors/NumberEditor.d.ts.map +1 -1
  66. package/dist/editors/NumberEditor.js +9 -17
  67. package/dist/editors/NumberEditor.js.map +1 -1
  68. package/dist/editors/RelationshipEditor.d.ts +7 -1
  69. package/dist/editors/RelationshipEditor.d.ts.map +1 -1
  70. package/dist/editors/RelationshipEditor.js +10 -18
  71. package/dist/editors/RelationshipEditor.js.map +1 -1
  72. package/dist/editors/SelectEditor.d.ts +4 -1
  73. package/dist/editors/SelectEditor.d.ts.map +1 -1
  74. package/dist/editors/SelectEditor.js +8 -13
  75. package/dist/editors/SelectEditor.js.map +1 -1
  76. package/dist/editors/UploadEditor.d.ts +7 -1
  77. package/dist/editors/UploadEditor.d.ts.map +1 -1
  78. package/dist/editors/UploadEditor.js +5 -5
  79. package/dist/editors/UploadEditor.js.map +1 -1
  80. package/dist/editors/index.d.ts +0 -1
  81. package/dist/editors/index.d.ts.map +1 -1
  82. package/dist/editors/index.js +56 -16
  83. package/dist/editors/index.js.map +1 -1
  84. package/dist/editors/kit.d.ts +39 -0
  85. package/dist/editors/kit.d.ts.map +1 -0
  86. package/dist/editors/kit.js +44 -0
  87. package/dist/editors/kit.js.map +1 -0
  88. package/dist/editors/useDebouncedSearch.d.ts +10 -6
  89. package/dist/editors/useDebouncedSearch.d.ts.map +1 -1
  90. package/dist/editors/useDebouncedSearch.js +11 -7
  91. package/dist/editors/useDebouncedSearch.js.map +1 -1
  92. package/dist/geometry.d.ts +49 -0
  93. package/dist/geometry.d.ts.map +1 -0
  94. package/dist/geometry.js +48 -0
  95. package/dist/geometry.js.map +1 -0
  96. package/dist/hub.d.ts +4 -4
  97. package/dist/hub.d.ts.map +1 -1
  98. package/dist/hub.js.map +1 -1
  99. package/dist/index.d.ts +3 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +2 -2
  102. package/dist/index.js.map +1 -1
  103. package/dist/layout.d.ts.map +1 -1
  104. package/dist/layout.js +4 -2
  105. package/dist/layout.js.map +1 -1
  106. package/dist/locators.d.ts +0 -2
  107. package/dist/locators.d.ts.map +1 -1
  108. package/dist/locators.js +10 -16
  109. package/dist/locators.js.map +1 -1
  110. package/dist/messages.d.ts +15 -2
  111. package/dist/messages.d.ts.map +1 -1
  112. package/dist/messages.js +5 -0
  113. package/dist/messages.js.map +1 -1
  114. package/dist/registry.d.ts +21 -2
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +31 -6
  117. package/dist/registry.js.map +1 -1
  118. package/dist/rowOps.d.ts +20 -0
  119. package/dist/rowOps.d.ts.map +1 -0
  120. package/dist/rowOps.js +48 -0
  121. package/dist/rowOps.js.map +1 -0
  122. package/dist/sanitize.d.ts +2 -0
  123. package/dist/sanitize.d.ts.map +1 -1
  124. package/dist/sanitize.js +3 -0
  125. package/dist/sanitize.js.map +1 -1
  126. package/dist/settings.d.ts +10 -0
  127. package/dist/settings.d.ts.map +1 -0
  128. package/dist/settings.js +30 -0
  129. package/dist/settings.js.map +1 -0
  130. package/dist/styles.d.ts +1 -5
  131. package/dist/styles.d.ts.map +1 -1
  132. package/dist/styles.js +5 -4
  133. package/dist/styles.js.map +1 -1
  134. package/dist/targets.d.ts +1 -1
  135. package/dist/targets.d.ts.map +1 -1
  136. package/dist/types.d.ts +71 -46
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/ui/BlockPicker.d.ts.map +1 -1
  139. package/dist/ui/BlockPicker.js +6 -5
  140. package/dist/ui/BlockPicker.js.map +1 -1
  141. package/dist/ui/Dialog.d.ts +0 -5
  142. package/dist/ui/Dialog.d.ts.map +1 -1
  143. package/dist/ui/Dialog.js +10 -6
  144. package/dist/ui/Dialog.js.map +1 -1
  145. package/dist/ui/Dock.d.ts +1 -6
  146. package/dist/ui/Dock.d.ts.map +1 -1
  147. package/dist/ui/Dock.js +46 -28
  148. package/dist/ui/Dock.js.map +1 -1
  149. package/dist/ui/Overlay.d.ts.map +1 -1
  150. package/dist/ui/Overlay.js +39 -37
  151. package/dist/ui/Overlay.js.map +1 -1
  152. package/dist/ui/PreviewToolbar.d.ts +3 -3
  153. package/dist/ui/PreviewToolbar.d.ts.map +1 -1
  154. package/dist/ui/PreviewToolbar.js +4 -6
  155. package/dist/ui/PreviewToolbar.js.map +1 -1
  156. package/dist/ui/SettingsPanel.d.ts +0 -9
  157. package/dist/ui/SettingsPanel.d.ts.map +1 -1
  158. package/dist/ui/SettingsPanel.js +36 -41
  159. package/dist/ui/SettingsPanel.js.map +1 -1
  160. package/dist/ui/ToolbarButton.d.ts +12 -0
  161. package/dist/ui/ToolbarButton.d.ts.map +1 -0
  162. package/dist/ui/ToolbarButton.js +7 -0
  163. package/dist/ui/ToolbarButton.js.map +1 -0
  164. package/dist/useEvent.d.ts +1 -1
  165. package/dist/useEvent.d.ts.map +1 -1
  166. package/dist/useEvent.js +3 -1
  167. package/dist/useEvent.js.map +1 -1
  168. package/dist/useIsClient.d.ts +5 -0
  169. package/dist/useIsClient.d.ts.map +1 -0
  170. package/dist/useIsClient.js +8 -0
  171. package/dist/useIsClient.js.map +1 -0
  172. package/package.json +15 -4
  173. package/src/OnPageClient.tsx +9 -5
  174. package/src/annotations.ts +1 -1
  175. package/src/context.tsx +53 -10
  176. package/src/dom.ts +17 -18
  177. package/src/editor/FieldHost.tsx +26 -11
  178. package/src/editor/OnPageEditor.tsx +103 -160
  179. package/src/editor/editTarget.ts +41 -0
  180. package/src/editor/useClientDocs.ts +19 -0
  181. package/src/editor/useEditorSession.ts +36 -0
  182. package/src/editor/useFlash.ts +18 -0
  183. package/src/editor/useGhosts.ts +5 -7
  184. package/src/editor/usePageEvents.ts +13 -14
  185. package/src/editor-kit.ts +10 -6
  186. package/src/editors/CheckboxEditor.tsx +11 -12
  187. package/src/editors/ColorEditor.tsx +15 -35
  188. package/src/editors/DateEditor.tsx +18 -37
  189. package/src/editors/InlineText.tsx +4 -5
  190. package/src/editors/NumberEditor.tsx +15 -36
  191. package/src/editors/RelationshipEditor.tsx +18 -36
  192. package/src/editors/SelectEditor.tsx +14 -22
  193. package/src/editors/UploadEditor.tsx +6 -6
  194. package/src/editors/index.ts +56 -16
  195. package/src/editors/kit.tsx +97 -0
  196. package/src/editors/useDebouncedSearch.ts +18 -10
  197. package/src/geometry.ts +62 -0
  198. package/src/hub.ts +5 -5
  199. package/src/index.ts +5 -2
  200. package/src/layout.ts +5 -2
  201. package/src/locators.ts +11 -14
  202. package/src/messages.ts +26 -5
  203. package/src/registry.ts +42 -8
  204. package/src/rowOps.ts +64 -0
  205. package/src/sanitize.ts +6 -0
  206. package/src/settings.ts +49 -0
  207. package/src/styles.ts +5 -4
  208. package/src/targets.ts +1 -1
  209. package/src/types.ts +78 -45
  210. package/src/ui/BlockPicker.tsx +6 -7
  211. package/src/ui/Dialog.tsx +9 -5
  212. package/src/ui/Dock.tsx +67 -50
  213. package/src/ui/Overlay.tsx +75 -102
  214. package/src/ui/PreviewToolbar.tsx +7 -15
  215. package/src/ui/SettingsPanel.tsx +84 -81
  216. package/src/ui/ToolbarButton.tsx +36 -0
  217. package/src/useEvent.ts +4 -2
  218. package/src/useIsClient.ts +15 -0
@@ -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
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What clicking or pressing Enter on a field opens: a pure function of the
3
+ * document's state, the editor registry and what was clicked.
4
+ */
5
+ import {
6
+ fieldAtPath,
7
+ isEditablePath,
8
+ isReady,
9
+ type DocumentState,
10
+ type RefKey,
11
+ type TargetKind,
12
+ } from '@payload-onpage/core'
13
+ import { resolveEditor } from '../registry.ts'
14
+ import type { EditingState, EditorRegistry } from '../types.ts'
15
+
16
+ export type EditTarget =
17
+ /** The document isn't loaded (yet, or it failed). */
18
+ | { kind: 'not-ready'; state: DocumentState | undefined }
19
+ /** Nothing here is edited in place. */
20
+ | { kind: 'none' }
21
+ | { kind: 'edit'; editing: EditingState }
22
+
23
+ export function resolveEditTarget(
24
+ state: DocumentState | undefined,
25
+ editors: EditorRegistry,
26
+ key: RefKey,
27
+ path: string,
28
+ opts: {
29
+ point?: { x: number; y: number } | undefined
30
+ kind?: TargetKind | undefined
31
+ el?: Element | undefined
32
+ },
33
+ ): EditTarget {
34
+ if (!isReady(state)) return { kind: 'not-ready', state }
35
+ if (!isEditablePath(state.schema, path)) return { kind: 'none' }
36
+ const hit = fieldAtPath(state.schema.fields, state.doc, path)
37
+ if (!hit) return { kind: 'none' }
38
+ const editor = resolveEditor(editors, hit.field, opts.kind === 'field' ? undefined : opts.kind)
39
+ if (!editor) return { kind: 'none' }
40
+ return { kind: 'edit', editing: { key, path, editor, point: opts.point, el: opts.el } }
41
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import type { RefKey } from '@payload-onpage/core'
4
+ import { useCallback, useState } from 'react'
5
+
6
+ /** Documents rendered from editor state on this page (client mode), and how they register. */
7
+ export function useClientDocs(): [ReadonlySet<RefKey>, (key: RefKey) => () => void] {
8
+ const [docs, setDocs] = useState<ReadonlySet<RefKey>>(() => new Set())
9
+ const register = useCallback((key: RefKey) => {
10
+ setDocs((s) => new Set(s).add(key))
11
+ return () =>
12
+ setDocs((s) => {
13
+ const next = new Set(s)
14
+ next.delete(key)
15
+ return next
16
+ })
17
+ }, [])
18
+ return [docs, register]
19
+ }
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { EditorSession, type DataSource, type DocRef } from '@payload-onpage/core'
4
+ import { useEffect, useState } from 'react'
5
+ import { useEvent } from '../useEvent.ts'
6
+
7
+ /**
8
+ * One editor session per data source and locale, disposed (after flushing its
9
+ * saves) when they change or on unmount. It lives in state, so StrictMode's
10
+ * mount/unmount/mount gets a fresh one each time.
11
+ */
12
+ export function useEditorSession(opts: {
13
+ dataSource: DataSource
14
+ locale: string | undefined
15
+ autosaveDelay: number | undefined
16
+ onSaved: (ref: DocRef, seq: number) => void
17
+ }): EditorSession | null {
18
+ const { dataSource, locale, autosaveDelay } = opts
19
+ const onSaved = useEvent(opts.onSaved)
20
+ const [session, setSession] = useState<EditorSession | null>(null)
21
+ useEffect(() => {
22
+ const s = new EditorSession({
23
+ dataSource,
24
+ locale,
25
+ autosaveDelay,
26
+ onSaved: (ref, seq) => onSaved(ref, seq),
27
+ })
28
+ // The session is an external resource with a lifecycle (see above).
29
+ // eslint-disable-next-line react-hooks/set-state-in-effect
30
+ setSession(s)
31
+ return () => {
32
+ void s.flush().finally(() => s.dispose())
33
+ }
34
+ }, [dataSource, locale, autosaveDelay, onSaved])
35
+ return session
36
+ }
@@ -0,0 +1,18 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import type { Flash } from '../types.ts'
5
+
6
+ const SHOWN_FOR = 4000
7
+
8
+ /** The dock's short message, cleared after a few seconds. */
9
+ export function useFlash(): [Flash | null, (text: string, bad?: boolean) => void] {
10
+ const [flash, setFlash] = useState<Flash | null>(null)
11
+ useEffect(() => {
12
+ if (!flash) return
13
+ const id = setTimeout(() => setFlash(null), SHOWN_FOR)
14
+ return () => clearTimeout(id)
15
+ }, [flash])
16
+ const notify = useCallback((text: string, bad?: boolean) => setFlash({ text, bad }), [])
17
+ return [flash, notify]
18
+ }
@@ -6,16 +6,14 @@
6
6
  * records the document's save sequence when it was made; the render after a
7
7
  * later save retires it. Failed saves and a TTL retire ghosts too.
8
8
  */
9
- import type { SessionSnapshot } from '@payload-onpage/core'
9
+ import type { SessionSnapshot, RefKey } from '@payload-onpage/core'
10
+ import type { GhostInput } from '../types.ts'
10
11
  import { useCallback, useEffect, useState } from 'react'
11
12
 
12
- export interface Ghost {
13
+ export interface Ghost extends GhostInput {
13
14
  id: string
14
- key: string
15
+ key: RefKey
15
16
  path: string
16
- html: string
17
- className?: string
18
- style?: Partial<CSSStyleDeclaration>
19
17
  /** Save sequence of the document when the ghost was made. */
20
18
  afterSeq: number
21
19
  until: number
@@ -35,7 +33,7 @@ export function useGhosts(snap: SessionSnapshot | null) {
35
33
  }, [])
36
34
 
37
35
  /** The page re-rendered after save `seq` of document `key`. */
38
- const rendered = useCallback((key: string, seq: number) => {
36
+ const rendered = useCallback((key: RefKey, seq: number) => {
39
37
  setGhosts((gs) =>
40
38
  gs.some((g) => g.key === key && g.afterSeq < seq)
41
39
  ? gs.filter((g) => !(g.key === key && g.afterSeq < seq))
@@ -8,13 +8,14 @@
8
8
  * - a warning before leaving with unsaved changes
9
9
  */
10
10
  import { useEvent } from '../useEvent.ts'
11
- import { moveItem, refKey, removeItem, resolveRow, updateArray } from '@payload-onpage/core'
11
+ import { refKey, resolveRow, isReady } from '@payload-onpage/core'
12
+ import { rowOps } from '../rowOps.ts'
12
13
  import { useEffect } from 'react'
13
- import { isOurs, isTyping } from '../dom.ts'
14
+ import { DOM_ATTR, isOurs, isTyping } from '../dom.ts'
14
15
  import { isFieldTarget, isRowTarget, targetsAt } from '../targets.ts'
15
16
  import type { OnPageContextValue } from '../types.ts'
16
17
 
17
- const FOCUSABLE_FLAG = 'data-onpage-focusable'
18
+ const FOCUSABLE_FLAG = DOM_ATTR.focusable
18
19
 
19
20
  export function usePageEvents(ctx: OnPageContextValue | null, root: () => ParentNode | null): void {
20
21
  const onClick = useEvent((e: MouseEvent) => {
@@ -43,22 +44,20 @@ export function usePageEvents(ctx: OnPageContextValue | null, root: () => Parent
43
44
  }
44
45
  })
45
46
 
47
+ /** ⌥↑/⌥↓ move the selected row, ⌥⌫ deletes it. True when the key was ours (even if the row can't move). */
46
48
  const moveSelected = (dir: -1 | 1 | 'delete') => {
47
49
  if (!ctx?.selected) return false
48
50
  const { key, path } = ctx.selected
49
51
  const state = ctx.snap.docs[key]
50
- const info = state?.schema && state.doc ? resolveRow(state.schema, state.doc, path) : null
52
+ const info = isReady(state) ? resolveRow(state.schema, state.doc, path) : null
51
53
  if (!info) return false
54
+ const ops = rowOps(ctx.session, key, info)
52
55
  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
56
+ if (ops.remove()) ctx.setSelected(null)
57
+ } else {
58
+ const next = ops.move(dir)
59
+ if (next) ctx.setSelected(next)
57
60
  }
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
61
  return true
63
62
  }
64
63
 
@@ -120,7 +119,7 @@ export function usePageEvents(ctx: OnPageContextValue | null, root: () => Parent
120
119
  })
121
120
 
122
121
  useEffect(() => {
123
- document.documentElement.setAttribute('data-onpage-editing', '')
122
+ document.documentElement.setAttribute(DOM_ATTR.editing, '')
124
123
  const click = (e: MouseEvent) => onClick(e)
125
124
  const key = (e: KeyboardEvent) => onKey(e)
126
125
  const focus = (e: FocusEvent) => onFocus(e)
@@ -130,7 +129,7 @@ export function usePageEvents(ctx: OnPageContextValue | null, root: () => Parent
130
129
  document.addEventListener('focusin', focus)
131
130
  window.addEventListener('beforeunload', unload)
132
131
  return () => {
133
- document.documentElement.removeAttribute('data-onpage-editing')
132
+ document.documentElement.removeAttribute(DOM_ATTR.editing)
134
133
  document.removeEventListener('click', click, true)
135
134
  document.removeEventListener('keydown', key)
136
135
  document.removeEventListener('focusin', focus)
package/src/editor-kit.ts CHANGED
@@ -2,14 +2,17 @@
2
2
  * @payload-onpage/react/editor-kit — building blocks for custom field editors
3
3
  * (the default editors and @payload-onpage/lexical use exactly these).
4
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
- * }
5
+ * const json = defineEditor({
6
+ * mode: 'popover',
7
+ * accepts: ['json'],
8
+ * component: (props) => <EditorPopover props={props} testId="my-json">…</EditorPopover>,
9
+ * })
8
10
  */
9
11
  export { Dialog, type DialogProps } from './ui/Dialog.tsx'
10
12
  export { useOnPage } from './context.tsx'
11
13
  export { useDebouncedSearch } from './editors/useDebouncedSearch.ts'
12
- export { escapeHtml } from './editors/InlineText.tsx'
14
+ export { ApplyBar, EditorPopover, useDraft } from './editors/kit.tsx'
15
+ export { canEdit, defineEditor } from './registry.ts'
13
16
  export {
14
17
  HIDDEN,
15
18
  UI,
@@ -22,6 +25,7 @@ export {
22
25
  } from './dom.ts'
23
26
  export { useTrackElement, useLayoutTick, notifyLayout } from './layout.ts'
24
27
  export { useEvent } from './useEvent.ts'
25
- export { sanitizeHtml } from './sanitize.ts'
26
- export type { EditorRegistry, FieldEditorDef, FieldEditorProps } from './types.ts'
28
+ export { escapeHtml, sanitizeHtml } from './sanitize.ts'
29
+ export type { EditorRegistry, FieldEditorDef, FieldEditorProps, GhostInput } from './types.ts'
30
+ export type { FieldOf } from '@payload-onpage/core'
27
31
  export type { Translate } from './messages.ts'