@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
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 = `
@@ -51,14 +52,14 @@ export const PO_CSS = `
51
52
  .po-blocks{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
52
53
  .po-blocks button{all:unset;padding:14px;border:1px solid #e2e8f0;border-radius:10px;cursor:pointer;font:600 13px system-ui,sans-serif}
53
54
  .po-blocks button:hover,.po-blocks button:focus-visible{border-color:#4f7cff;background:#f5f8ff}
54
- .po-dock{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:4px;padding:6px;background:#0f172a;color:#fff;border-radius:12px;box-shadow:0 12px 40px #0f172a55;pointer-events:auto;max-width:calc(100vw - 24px);flex-wrap:wrap}
55
+ .po-dock{position:fixed;left:50%;width:max-content;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:4px;padding:6px;background:#0f172a;color:#fff;border-radius:12px;box-shadow:0 12px 40px #0f172a55;pointer-events:auto;max-width:calc(100vw - 24px);flex-wrap:wrap}
55
56
  .po-dock .po-status{padding:0 10px;font-size:12px;opacity:.85;white-space:nowrap}
56
57
  .po-dock .po-status.is-bad{color:#fca5a5;opacity:1}
57
58
  .po-dock .po-sep{width:1px;height:20px;background:#ffffff26;margin:0 4px}
58
59
  .po-dock .po-primary{background:#3b63e0}
59
60
  .po-dock .po-primary:hover{background:#2f5bd9}
60
61
  .po-chip-locale{padding:2px 7px;border-radius:999px;background:#ffffff1f;font-size:11px;font-weight:700;text-transform:uppercase}
61
- .po-notice{position:fixed;left:50%;bottom:72px;transform:translateX(-50%);max-width:min(560px,calc(100vw - 24px));padding:8px 12px;border-radius:10px;background:#fffbeb;color:#78350f;border:1px solid #fcd34d;font-size:12px;pointer-events:auto}
62
+ .po-notice{position:fixed;left:50%;width:max-content;bottom:72px;transform:translateX(-50%);max-width:min(560px,calc(100vw - 24px));padding:8px 12px;border-radius:10px;background:#fffbeb;color:#78350f;border:1px solid #fcd34d;font-size:12px;pointer-events:auto}
62
63
  .po-doc-layer{position:absolute;top:0;left:0;width:0;height:0;overflow:visible;z-index:${PO_Z};pointer-events:none;font:13px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif}
63
64
  .po-inline{position:absolute;pointer-events:auto;outline:2px solid #4f7cff;outline-offset:2px;border-radius:2px;background:transparent;white-space:pre-wrap;word-break:break-word;caret-color:currentColor;cursor:text}
64
65
  .po-inline:focus{outline-color:#4f7cff}
@@ -81,7 +82,7 @@ export const PO_CSS = `
81
82
  .po-opaque{display:inline-flex;align-items:center;gap:6px;margin:6px 0;padding:6px 10px;border:1px dashed #94a3b8;border-radius:8px;background:#f8fafc;color:#475569;font:12px system-ui,sans-serif;user-select:none}
82
83
  html[data-onpage-editing] [data-onpage-field]{cursor:pointer}
83
84
  [data-onpage-hidden="true"]{visibility:hidden!important}
84
- .po-preview{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;padding:6px;background:#0f172a;color:#fff;border-radius:12px;box-shadow:0 12px 40px #0f172a55;pointer-events:auto;max-width:calc(100vw - 24px);flex-wrap:wrap}
85
+ .po-preview{position:fixed;left:50%;width:max-content;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;padding:6px;background:#0f172a;color:#fff;border-radius:12px;box-shadow:0 12px 40px #0f172a55;pointer-events:auto;max-width:calc(100vw - 24px);flex-wrap:wrap}
85
86
  .po-preview .po-status{padding:0 8px;font-size:12px;opacity:.85}
86
87
  .po-rel-item{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;background:#f1f5f9;font-size:12px;margin:0 0 4px}
87
88
  .po-rel-item button{all:unset;cursor:pointer;margin-left:auto;padding:0 4px;color:#64748b}
@@ -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">
package/src/ui/Dock.tsx CHANGED
@@ -5,14 +5,49 @@
5
5
  * recovery: reload after a conflict, retry after an error), undo/redo, locale,
6
6
  * share link, preview, discard, admin, exit and publish.
7
7
  */
8
+ import { errorText } from '@payload-onpage/core'
8
9
  import type { DocumentState } from '@payload-onpage/core'
9
10
  import { useEffect, useState } from 'react'
10
11
  import { useOnPage } from '../context.tsx'
11
- import { relativeTime } from '../messages.ts'
12
+ import { relativeTime, type Translate } from '../messages.ts'
12
13
  import { PostButton } from './PostButton.tsx'
14
+ import { ToolbarButton } from './ToolbarButton.tsx'
15
+ import type { Flash } from '../types.ts'
13
16
 
14
17
  const title = (d: DocumentState) => d.schema?.labels.singular ?? d.ref.slug
15
18
 
19
+ interface DockState {
20
+ docs: DocumentState[]
21
+ ready: DocumentState[]
22
+ failed: DocumentState | undefined
23
+ conflict: DocumentState | undefined
24
+ error: DocumentState | undefined
25
+ saving: boolean
26
+ rendering: boolean
27
+ lastSaved: number
28
+ }
29
+
30
+ /** What the dock says, most urgent first. */
31
+ function dockStatus(s: DockState, t: Translate): Flash {
32
+ if (s.failed)
33
+ return {
34
+ text: t('dock.unavailable', { doc: title(s.failed), error: s.failed.error ?? '' }),
35
+ bad: true,
36
+ }
37
+ if (!s.docs.length) return { text: t('dock.nothing') }
38
+ if (!s.ready.length) return { text: t('dock.loading') }
39
+ if (s.conflict) return { text: t('dock.conflict', { doc: title(s.conflict) }), bad: true }
40
+ if (s.error)
41
+ return {
42
+ text: t('dock.saveFailed', { doc: title(s.error), message: s.error.save.message ?? '' }),
43
+ bad: true,
44
+ }
45
+ if (s.saving) return { text: t('dock.saving') }
46
+ if (s.rendering) return { text: t('dock.updating') }
47
+ if (s.lastSaved) return { text: t('dock.saved', { ago: relativeTime(s.lastSaved) }) }
48
+ return { text: t('dock.editing') }
49
+ }
50
+
16
51
  function useTicker(ms: number) {
17
52
  const [, setN] = useState(0)
18
53
  useEffect(() => {
@@ -21,8 +56,8 @@ function useTicker(ms: number) {
21
56
  }, [ms])
22
57
  }
23
58
 
24
- export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null }) {
25
- const { session, snap, links, rendering, notify, rerender, docKeys, t } = useOnPage()
59
+ export function Dock() {
60
+ const { session, snap, links, rendering, notify, rerender, docKeys, flash, t } = useOnPage()
26
61
  const [confirmDiscard, setConfirmDiscard] = useState(false)
27
62
  useTicker(5000)
28
63
  useEffect(() => {
@@ -41,23 +76,11 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
41
76
  const primary = docs[0]
42
77
  const busy = snap.busy
43
78
 
44
- const status = failed
45
- ? { text: t('dock.unavailable', { doc: title(failed), error: failed.error ?? '' }), bad: true }
46
- : !docs.length
47
- ? { text: t('dock.nothing') }
48
- : !ready.length
49
- ? { text: t('dock.loading') }
50
- : conflict
51
- ? { text: t('dock.conflict', { doc: title(conflict) }), bad: true }
52
- : error
53
- ? { text: `${title(error)}: ${error.save.message ?? ''}`, bad: true }
54
- : saving
55
- ? { text: t('dock.saving') }
56
- : rendering
57
- ? { text: t('dock.updating') }
58
- : lastSaved
59
- ? { text: t('dock.saved', { ago: relativeTime(lastSaved) }) }
60
- : { text: t('dock.editing') }
79
+ const retryable = failed ?? error
80
+ const status = dockStatus(
81
+ { docs, ready, failed, conflict, error, saving, rendering, lastSaved },
82
+ t,
83
+ )
61
84
 
62
85
  const schema = ready[0]?.schema
63
86
  const nonDefaultLocale =
@@ -67,10 +90,10 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
67
90
  try {
68
91
  const res = await session.publish({ keys: docKeys })
69
92
  if (res.published.length) await rerender(res.published)
70
- if (res.ok) notify(res.published.length ? 'notify.published' : 'notify.nothingToPublish')
71
- else notify('notify.publishFailed', { message: res.message ?? '' }, true)
93
+ if (res.ok) notify(t(res.published.length ? 'notify.published' : 'notify.nothingToPublish'))
94
+ else notify(t('notify.publishFailed', { message: res.message ?? '' }), true)
72
95
  } catch (e) {
73
- notify('notify.publishFailed', { message: (e as Error).message }, true)
96
+ notify(t('notify.publishFailed', { message: errorText(e) }), true)
74
97
  }
75
98
  }
76
99
  const discard = async () => {
@@ -79,9 +102,9 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
79
102
  setConfirmDiscard(false)
80
103
  try {
81
104
  await session.discard({ keys: docKeys })
82
- notify('notify.discarded')
105
+ notify(t('notify.discarded'))
83
106
  } catch (e) {
84
- notify((e as Error).message, undefined, true)
107
+ notify(errorText(e), true)
85
108
  }
86
109
  }
87
110
  const reload = async (d: DocumentState) => {
@@ -89,7 +112,7 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
89
112
  await session.reload(d.key)
90
113
  await rerender([d.ref])
91
114
  } catch (e) {
92
- notify((e as Error).message, undefined, true)
115
+ notify(errorText(e), true)
93
116
  }
94
117
  }
95
118
  const createLink = session.dataSource.createPreviewLink?.bind(session.dataSource)
@@ -98,9 +121,9 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
98
121
  try {
99
122
  const { url, expiresAt } = await createLink(primary.ref, { locale: session.locale })
100
123
  await navigator.clipboard.writeText(url)
101
- notify('notify.linkCopied', { date: new Date(expiresAt).toLocaleString() })
124
+ notify(t('notify.linkCopied', { date: new Date(expiresAt).toLocaleString() }))
102
125
  } catch (e) {
103
- notify('notify.linkFailed', { message: (e as Error).message }, true)
126
+ notify(t('notify.linkFailed', { message: errorText(e) }), true)
104
127
  }
105
128
  }
106
129
 
@@ -136,11 +159,11 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
136
159
  {t('dock.reload')}
137
160
  </button>
138
161
  )}
139
- {(failed ?? error) && (
162
+ {retryable && (
140
163
  <button
141
164
  type="button"
142
165
  className="po-btn"
143
- onClick={() => void session.retry((failed ?? error)!.key)}
166
+ onClick={() => void session.retry(retryable.key)}
144
167
  data-testid="onpage-retry"
145
168
  >
146
169
  {t('dock.retry')}
@@ -157,28 +180,22 @@ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null
157
180
  )}
158
181
  {session.locale && <span className="po-chip-locale">{session.locale}</span>}
159
182
  <span className="po-sep" />
160
- <button
161
- type="button"
162
- className="po-btn"
163
- onClick={() => session.undo()}
183
+ <ToolbarButton
184
+ label={t('dock.undo')}
185
+ hint={`${t('dock.undo')} (⌘Z)`}
186
+ icon="↶"
164
187
  disabled={!snap.canUndo}
165
- aria-label={t('dock.undo')}
166
- title={`${t('dock.undo')} (⌘Z)`}
167
- data-testid="onpage-undo"
168
- >
169
- ↶
170
- </button>
171
- <button
172
- type="button"
173
- className="po-btn"
174
- onClick={() => session.redo()}
188
+ onClick={() => session.undo()}
189
+ testId="onpage-undo"
190
+ />
191
+ <ToolbarButton
192
+ label={t('dock.redo')}
193
+ hint={`${t('dock.redo')} (⇧⌘Z)`}
194
+ icon="↷"
175
195
  disabled={!snap.canRedo}
176
- aria-label={t('dock.redo')}
177
- title={`${t('dock.redo')} (⇧⌘Z)`}
178
- data-testid="onpage-redo"
179
- >
180
- ↷
181
- </button>
196
+ onClick={() => session.redo()}
197
+ testId="onpage-redo"
198
+ />
182
199
  <span className="po-sep" />
183
200
  {links.mode && (
184
201
  <PostButton