@payload-onpage/react 0.2.1 → 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
package/src/registry.ts CHANGED
@@ -7,24 +7,37 @@
7
7
  * No match: the field isn't edited in place (it may still be in the row's
8
8
  * settings panel).
9
9
  */
10
- import type { SchemaField, TargetKind } from '@payload-onpage/core'
11
- import type { EditorRegistry } from './types.ts'
10
+ import type { FieldOf, SchemaField, SchemaFieldType, TargetKind } from '@payload-onpage/core'
11
+ import type { EditorRegistry, FieldEditorDef, FieldEditorProps } from './types.ts'
12
+ import type { ComponentType } from 'react'
12
13
 
13
14
  const KIND_EDITOR: Partial<Record<TargetKind, string>> = { richtext: 'richText', media: 'upload' }
14
- const TEXTUAL = new Set(['text', 'textarea', 'email'])
15
+ /** Field types a "text" marker or annotation keeps as their own editor (email stays email). */
16
+ const TEXT_TYPES: ReadonlySet<SchemaFieldType> = new Set(['text', 'textarea', 'email'])
17
+
18
+ /** Can this editor take this field? Its declared kinds, and never a list of values in place. */
19
+ export function canEdit(
20
+ def: FieldEditorDef | undefined,
21
+ field: SchemaField,
22
+ ): def is FieldEditorDef {
23
+ if (!def) return false
24
+ if (def.accepts && !def.accepts.includes(field.type)) return false
25
+ return !(def.mode === 'inline' && 'hasMany' in field && field.hasMany)
26
+ }
15
27
 
16
28
  export function resolveEditor(
17
29
  editors: EditorRegistry,
18
30
  field: SchemaField,
19
31
  kind?: TargetKind,
20
32
  ): string | null {
21
- if (field.editor && editors[field.editor]) return field.editor
22
- // A marker/annotation saying "text" keeps the field's own textual type (email stays email).
23
- if ((kind === 'text' || kind === 'textarea') && TEXTUAL.has(field.type) && editors[field.type])
33
+ const ok = (id: string | undefined): id is string => !!id && canEdit(editors[id], field)
34
+ if (ok(field.editor)) return field.editor
35
+ if ((kind === 'text' || kind === 'textarea') && TEXT_TYPES.has(field.type) && ok(field.type))
24
36
  return field.type
25
37
  const byKind = kind ? KIND_EDITOR[kind] : undefined
26
- if (byKind && editors[byKind] && (byKind !== 'upload' || field.type === 'upload')) return byKind
27
- return editors[field.type] ? field.type : null
38
+ // A "media" marker means the image picker only for upload fields (an <img alt> can show a text field).
39
+ if (ok(byKind) && (byKind !== 'upload' || field.type === 'upload')) return byKind
40
+ return ok(field.type) ? field.type : null
28
41
  }
29
42
 
30
43
  /** Merge registries left to right; `null` removes a default. */
@@ -41,3 +54,24 @@ export function mergeEditors(
41
54
  }
42
55
  return out
43
56
  }
57
+
58
+ /**
59
+ * Define a field editor typed by the field kinds it accepts:
60
+ *
61
+ * defineEditor({
62
+ * mode: 'popover',
63
+ * accepts: ['number'],
64
+ * normalize: (raw) => (typeof raw === 'number' ? raw : null),
65
+ * component: ({ field, value }) => … // field: NumberField, value: number | null
66
+ * })
67
+ */
68
+ export function defineEditor<T extends SchemaFieldType, V = unknown>(def: {
69
+ mode: FieldEditorDef['mode']
70
+ accepts: readonly T[]
71
+ normalize?: (raw: unknown, field: FieldOf<T>) => V
72
+ component: ComponentType<FieldEditorProps<FieldOf<T>, V>>
73
+ }): FieldEditorDef {
74
+ // Widened once, here: resolveEditor only hands it fields whose type is in `accepts`,
75
+ // and FieldHost runs `normalize` before rendering.
76
+ return def as unknown as FieldEditorDef
77
+ }
package/src/rowOps.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Row operations on array and blocks fields, with one set of rules (bounds,
3
+ * `minRows`, `maxRows`) for the toolbar, the keyboard shortcuts, the block
4
+ * picker and "+ Add". Each returns where the selection should go, or null when
5
+ * the operation isn't allowed (nothing changes).
6
+ */
7
+ import {
8
+ cloneRow,
9
+ insertItem,
10
+ moveItem,
11
+ removeItem,
12
+ updateArray,
13
+ type EditorSession,
14
+ type RowInfo,
15
+ type RefKey,
16
+ } from '@payload-onpage/core'
17
+ import type { Selection } from './types.ts'
18
+
19
+ const rowAt = (key: RefKey, listPath: string, index: number): Selection => ({
20
+ key,
21
+ path: `${listPath}.${index}`,
22
+ })
23
+
24
+ /** Insert a row into the list at `listPath`; returns the new row's selection. */
25
+ export function insertRow(
26
+ session: EditorSession,
27
+ key: RefKey,
28
+ listPath: string,
29
+ index: number,
30
+ row: unknown,
31
+ ): Selection {
32
+ session.apply(key, (d) => updateArray(d, listPath, (a) => insertItem(a, index, row)))
33
+ return rowAt(key, listPath, index)
34
+ }
35
+
36
+ export function rowOps(session: EditorSession, key: RefKey, info: RowInfo) {
37
+ const { listPath, index, length, rowFields, listField } = info
38
+ const apply = (fn: (rows: unknown[]) => unknown[]) =>
39
+ session.apply(key, (d) => updateArray(d, listPath, fn))
40
+ const canMove = (dir: -1 | 1) => index + dir >= 0 && index + dir < length
41
+ const canDuplicate = listField.maxRows === undefined || length < listField.maxRows
42
+ const canRemove = length > (listField.minRows ?? 0)
43
+ return {
44
+ canMove,
45
+ canDuplicate,
46
+ canRemove,
47
+ move(dir: -1 | 1): Selection | null {
48
+ if (!canMove(dir)) return null
49
+ apply((a) => moveItem(a, index, index + dir))
50
+ return rowAt(key, listPath, index + dir)
51
+ },
52
+ duplicate(): Selection | null {
53
+ if (!canDuplicate) return null
54
+ apply((a) => insertItem(a, index + 1, cloneRow(a[index], rowFields)))
55
+ return rowAt(key, listPath, index + 1)
56
+ },
57
+ /** Removes the row; true when it did. */
58
+ remove(): boolean {
59
+ if (!canRemove) return false
60
+ apply((a) => removeItem(a, index))
61
+ return true
62
+ },
63
+ }
64
+ }
package/src/sanitize.ts CHANGED
@@ -50,3 +50,9 @@ export function sanitizeHtml(html: string): string {
50
50
  walk(tpl.content)
51
51
  return tpl.innerHTML
52
52
  }
53
+
54
+ /** Text made safe to put in HTML. */
55
+ export const escapeHtml = (s: string): string =>
56
+ s.replace(/[&<>"]/g, (c) => ESCAPES[c as keyof typeof ESCAPES])
57
+
58
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' } as const
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Which fields of a row appear in its settings panel: values that aren't edited
3
+ * on the page itself (layout selects, toggles, numbers, links, images that
4
+ * aren't shown…). Fields shown on the page are edited there instead.
5
+ */
6
+ import type { SchemaField, SchemaFieldType, RefKey } from '@payload-onpage/core'
7
+ import type { TargetIndex } from './types.ts'
8
+
9
+ /** Row bookkeeping, never a setting. */
10
+ const SKIP = new Set(['id', 'blockType', 'blockName'])
11
+
12
+ /** Types shown in the panel when the page doesn't show them (choices and toggles always are). */
13
+ const PANEL_FIELD_TYPES: ReadonlySet<SchemaFieldType> = new Set([
14
+ 'text',
15
+ 'textarea',
16
+ 'email',
17
+ 'number',
18
+ 'date',
19
+ 'relationship',
20
+ 'upload',
21
+ ])
22
+
23
+ export function settable(
24
+ field: SchemaField,
25
+ path: string,
26
+ key: RefKey,
27
+ targets: TargetIndex,
28
+ ): boolean {
29
+ if (SKIP.has(field.name)) return false
30
+ const full = `${path}.${field.name}`
31
+ switch (field.type) {
32
+ case 'select':
33
+ case 'radio':
34
+ case 'checkbox':
35
+ return true
36
+ case 'group':
37
+ return field.fields.some((f) => settable(f, full, key, targets))
38
+ default:
39
+ // Shown on the page? Edit it there.
40
+ return PANEL_FIELD_TYPES.has(field.type) && !targets.find(key, full)
41
+ }
42
+ }
43
+
44
+ export const hasSettings = (
45
+ fields: SchemaField[],
46
+ path: string,
47
+ key: RefKey,
48
+ targets: TargetIndex,
49
+ ): boolean => fields.some((f) => settable(f, path, key, targets))
package/src/styles.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * The editor's own styles, injected once. Everything is prefixed `po-` and set
3
3
  * explicitly, so the host site's CSS (and its absence) doesn't matter.
4
4
  */
5
+ import { DOM_ATTR } from './dom.ts'
5
6
  export const PO_Z = 2147483000
6
7
 
7
8
  export const PO_CSS = `
@@ -116,7 +117,7 @@ export function injectStyles() {
116
117
  if (injected || typeof document === 'undefined') return
117
118
  injected = true
118
119
  const el = document.createElement('style')
119
- el.setAttribute('data-onpage-styles', '')
120
+ el.setAttribute(DOM_ATTR.styles, '')
120
121
  el.textContent = PO_CSS
121
122
  document.head.appendChild(el)
122
123
  }
package/src/targets.ts CHANGED
@@ -42,7 +42,7 @@ export function buildIndex(all: Target[]): TargetIndex {
42
42
  export function targetsAt(
43
43
  index: TargetIndex,
44
44
  el: Element | null,
45
- ): { field?: Target; rows: Target[] } {
45
+ ): { field?: Target | undefined; rows: Target[] } {
46
46
  let field: Target | undefined
47
47
  const rows: Target[] = []
48
48
  for (let node = el; node; node = node.parentElement) {
package/src/types.ts CHANGED
@@ -6,41 +6,47 @@ import type {
6
6
  MediaLibrary,
7
7
  Renderer,
8
8
  SchemaField,
9
+ SchemaFieldType,
9
10
  SessionSnapshot,
10
11
  Target,
11
12
  TargetKind,
13
+ RefKey,
12
14
  } from '@payload-onpage/core'
13
- import type { MessageKey, Translate } from './messages.ts'
15
+ import type { Translate } from './messages.ts'
14
16
 
15
17
  export interface EditingState {
16
18
  /** refKey of the document */
17
- key: string
19
+ key: RefKey
18
20
  path: string
19
21
  /** Editor id from the registry ("text", "richText", …). */
20
22
  editor: string
21
- point?: { x: number; y: number }
23
+ point?: { x: number; y: number } | undefined
22
24
  /** The element that was clicked (several elements may show one field). */
23
- el?: Element
25
+ el?: Element | undefined
24
26
  }
25
27
 
28
+ /** Which panel is open. */
26
29
  export type Panel =
27
- | { kind: 'settings'; key: string; path: string; title: string }
28
- | { kind: 'blocks'; key: string; listPath: string; index: number }
30
+ | { kind: 'settings'; key: RefKey; path: string; title: string }
31
+ | { kind: 'blocks'; key: RefKey; listPath: string; index: number }
29
32
  | null
30
33
 
31
34
  export interface Selection {
32
- key: string
35
+ key: RefKey
33
36
  path: string
34
37
  }
35
38
 
36
- /** Props every field editor receives. */
37
- export interface FieldEditorProps {
38
- docKey: string
39
+ /**
40
+ * Props a field editor receives. `F` is the field kind it edits (e.g.
41
+ * `NumberField`, with `min`/`max`) and `V` its value after `normalize`.
42
+ */
43
+ export interface FieldEditorProps<F extends SchemaField = SchemaField, V = unknown> {
44
+ docKey: RefKey
39
45
  path: string
40
- field: SchemaField
46
+ field: F
41
47
  /** The element showing the field (re-resolved after each refresh). */
42
48
  element: () => Element | null
43
- value: unknown
49
+ value: V
44
50
  /** Commit a value (one undo step, autosaved). */
45
51
  commit: (value: unknown) => void
46
52
  /** Live preview while editing (no history entry, not saved). */
@@ -48,23 +54,35 @@ export interface FieldEditorProps {
48
54
  /** Close the editor (after commit, or to cancel: transient previews are reverted). */
49
55
  close: () => void
50
56
  /** Leave a stand-in showing the new content until the page re-renders (refresh mode). */
51
- ghost: (g: { html: string; className?: string; style?: Partial<CSSStyleDeclaration> }) => void
57
+ ghost: (g: GhostInput) => void
52
58
  /** True when the page renders this document from editor state (instant); false = server refresh. */
53
59
  live: boolean
54
- point?: { x: number; y: number }
60
+ point?: { x: number; y: number } | undefined
55
61
  /** UI strings. */
56
62
  t: Translate
57
63
  }
58
64
 
65
+ /** What an editor leaves on the page until it re-renders. */
66
+ export interface GhostInput {
67
+ html: string
68
+ className?: string | undefined
69
+ style?: Partial<CSSStyleDeclaration> | undefined
70
+ }
71
+
59
72
  /**
60
73
  * A field editor (the FieldEditor port). `inline` editors replace the element
61
74
  * in place (text, rich text); `popover` editors open beside it or as a dialog
62
75
  * (select, date, relationship, image…). Register your own by id and refer to it
63
76
  * from a field with `admin: { custom: { onpage: { editor: 'my-id' } } }`, or by
64
- * field type ("json", "code"…) to replace or add a default.
77
+ * field type ("json", "code"…) to replace or add a default. Build one with
78
+ * `defineEditor()` so its props are typed by the field kinds it accepts.
65
79
  */
66
80
  export interface FieldEditorDef {
67
81
  mode: 'inline' | 'popover'
82
+ /** Field types it can edit. The registry never hands it any other kind. Default: any. */
83
+ accepts?: readonly SchemaFieldType[]
84
+ /** The raw stored value → the editor's value. Default: passed through. */
85
+ normalize?: (raw: unknown, field: SchemaField) => unknown
68
86
  component: ComponentType<FieldEditorProps>
69
87
  }
70
88
 
@@ -81,52 +99,67 @@ export interface TargetIndex {
81
99
  all: Target[]
82
100
  /** Targets whose element is exactly `el`. */
83
101
  at: (el: Element) => Target[]
84
- find: (key: string, path: string, kinds?: TargetKind[]) => Target | undefined
102
+ find: (key: RefKey, path: string, kinds?: TargetKind[]) => Target | undefined
85
103
  }
86
104
 
87
- /** What extensions and field editors can use from the editor (`useOnPage()`). */
88
- export interface OnPageContextValue {
105
+ /** A short message shown in the dock. */
106
+ export interface Flash {
107
+ text: string
108
+ bad?: boolean | undefined
109
+ }
110
+
111
+ /**
112
+ * The editor's stable API: the session, configuration and actions. It changes
113
+ * only when the editor is reconfigured, so components that only act (toolbars,
114
+ * custom UI, client-mode documents) don't re-render on every keystroke.
115
+ */
116
+ export interface OnPageApi {
89
117
  session: EditorSession
90
- snap: SessionSnapshot
91
118
  renderer: Renderer
92
- media?: MediaLibrary
119
+ media?: MediaLibrary | undefined
93
120
  editors: EditorRegistry
94
- targets: TargetIndex
95
121
  links: Links
96
122
  t: Translate
97
- panel: Panel
98
123
  setPanel: (p: Panel) => void
99
- editing: EditingState | null
100
124
  setEditing: (e: EditingState | null) => void
101
- selected: Selection | null
102
125
  setSelected: (s: Selection | null) => void
103
- /** Documents shown on the current page (the session may hold others from earlier pages). */
104
- docKeys: string[]
105
126
  /** Is this document rendered from session state (client mode)? */
106
- isLive: (key: string) => boolean
127
+ isLive: (key: RefKey) => boolean
107
128
  /** Client-mode documents register here (they don't need a server refresh). */
108
- registerClientDoc: (key: string) => () => void
109
- /** The page is re-rendering after a save (refresh mode). */
110
- rendering: boolean
129
+ registerClientDoc: (key: RefKey) => () => void
111
130
  /** Open the right editor for a field (registry resolution); false if none applies. */
112
131
  edit: (
113
- key: string,
132
+ key: RefKey,
114
133
  path: string,
115
- opts?: { point?: { x: number; y: number }; kind?: TargetKind; anchor?: Element },
134
+ opts?: {
135
+ point?: { x: number; y: number } | undefined
136
+ kind?: TargetKind | undefined
137
+ anchor?: Element | undefined
138
+ },
116
139
  ) => boolean
117
140
  /** Re-render documents after an operation outside autosave (publish, reload). */
118
141
  rerender: (refs: DocRef[]) => Promise<void>
119
- /** Show a short message in the dock. */
120
- notify: (
121
- key: MessageKey | (string & {}),
122
- vars?: Record<string, string | number>,
123
- bad?: boolean,
124
- ) => void
125
- addGhost: (g: {
126
- key: string
127
- path: string
128
- html: string
129
- className?: string
130
- style?: Partial<CSSStyleDeclaration>
131
- }) => void
142
+ /** Show a short message in the dock: translated text (`notify(t('notify.published'))`), or an error's text. */
143
+ notify: (text: string, bad?: boolean) => void
144
+ addGhost: (g: GhostInput & { key: RefKey; path: string }) => void
145
+ }
146
+
147
+ /** What changes as people edit: the documents, what's on the page and what's open. */
148
+ export interface OnPageState {
149
+ snap: SessionSnapshot
150
+ targets: TargetIndex
151
+ panel: Panel
152
+ editing: EditingState | null
153
+ selected: Selection | null
154
+ /** Documents shown on the current page (the session may hold others from earlier pages). */
155
+ docKeys: RefKey[]
156
+ /** The page is re-rendering after a save (refresh mode). */
157
+ rendering: boolean
158
+ /** The dock's current message. */
159
+ flash: Flash | null
132
160
  }
161
+
162
+ /** Everything inside the editor (`useOnPage()`): its API and its state. */
163
+ export type OnPageContextValue = OnPageApi & OnPageState
164
+
165
+ export type PanelKind = NonNullable<Panel>['kind']
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { fieldAtPath, insertItem, newBlockRow, updateArray } from '@payload-onpage/core'
3
+ import { fieldAtPath, isReady, newBlockRow } from '@payload-onpage/core'
4
+ import { insertRow } from '../rowOps.ts'
4
5
  import { useOnPage } from '../context.tsx'
5
6
  import { Dialog } from './Dialog.tsx'
6
7
 
@@ -9,19 +10,17 @@ export function BlockPicker() {
9
10
  const { panel, setPanel, snap, session, setSelected, t } = useOnPage()
10
11
  if (panel?.kind !== 'blocks') return null
11
12
  const state = snap.docs[panel.key]
12
- if (!state?.schema || !state.doc) return null
13
+ if (!isReady(state)) return null
13
14
  const { key, listPath, index } = panel
14
15
  const field = fieldAtPath(state.schema.fields, state.doc, listPath)?.field
15
- const blocks = field?.type === 'blocks' ? (field.blocks ?? []) : []
16
+ const blocks = field?.type === 'blocks' ? field.blocks : []
16
17
 
17
18
  const choose = (slug: string) => {
18
19
  const block = blocks.find((b) => b.slug === slug)
19
20
  if (!block) return
20
- session.apply(key, (d) =>
21
- updateArray(d, listPath, (a) => insertItem(a, index, newBlockRow(block))),
22
- )
21
+ const next = insertRow(session, key, listPath, index, newBlockRow(block))
23
22
  setPanel(null)
24
- setSelected({ key, path: `${listPath}.${index}` })
23
+ setSelected(next)
25
24
  }
26
25
 
27
26
  return (
package/src/ui/Dialog.tsx CHANGED
@@ -5,6 +5,7 @@
5
5
  * close, Tab stays inside (modal), Escape closes. `variant="popover"` is
6
6
  * anchored next to an element and closes on an outside click.
7
7
  */
8
+ import { useEvent } from '../useEvent.ts'
8
9
  import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react'
9
10
  import { focusableIn, popoverPosition } from '../dom.ts'
10
11
  import { useLayoutTick } from '../layout.ts'
@@ -62,18 +63,21 @@ export function Dialog({
62
63
  return () => ro.disconnect()
63
64
  }, [variant])
64
65
 
66
+ // Stable, so the listeners below aren't re-bound on every render (callers pass inline
67
+ // closures); a re-bind would also re-open the deferral gap where an outside click is lost.
68
+ const close = useEvent(onClose)
65
69
  useEffect(() => {
66
70
  const onKey = (e: KeyboardEvent) => {
67
71
  if (e.key === 'Escape') {
68
72
  e.stopPropagation()
69
- onClose()
73
+ close()
70
74
  return
71
75
  }
72
76
  if (e.key !== 'Tab' || variant !== 'modal' || !box.current) return
73
77
  const items = focusableIn(box.current)
74
- if (!items.length) return
75
78
  const first = items[0]
76
- const last = items[items.length - 1]
79
+ const last = items.at(-1)
80
+ if (!first || !last) return
77
81
  if (e.shiftKey && document.activeElement === first) {
78
82
  e.preventDefault()
79
83
  last.focus()
@@ -83,7 +87,7 @@ export function Dialog({
83
87
  }
84
88
  }
85
89
  const onDown = (e: PointerEvent) => {
86
- if (variant === 'popover' && box.current && !box.current.contains(e.target as Node)) onClose()
90
+ if (variant === 'popover' && box.current && !box.current.contains(e.target as Node)) close()
87
91
  }
88
92
  document.addEventListener('keydown', onKey, true)
89
93
  // Deferred, so the click that opened a popover doesn't close it.
@@ -93,7 +97,7 @@ export function Dialog({
93
97
  document.removeEventListener('keydown', onKey, true)
94
98
  document.removeEventListener('pointerdown', onDown, true)
95
99
  }
96
- }, [onClose, variant])
100
+ }, [close, variant])
97
101
 
98
102
  const header = (
99
103
  <header className="po-dialog-head">