@payload-onpage/react 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/dist/OnPageClient.d.ts.map +1 -1
  2. package/dist/OnPageClient.js +9 -5
  3. package/dist/OnPageClient.js.map +1 -1
  4. package/dist/annotations.d.ts +1 -1
  5. package/dist/annotations.d.ts.map +1 -1
  6. package/dist/context.d.ts +19 -5
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +31 -7
  9. package/dist/context.js.map +1 -1
  10. package/dist/dom.d.ts +16 -12
  11. package/dist/dom.d.ts.map +1 -1
  12. package/dist/dom.js +16 -16
  13. package/dist/dom.js.map +1 -1
  14. package/dist/editor/FieldHost.d.ts.map +1 -1
  15. package/dist/editor/FieldHost.js +18 -11
  16. package/dist/editor/FieldHost.js.map +1 -1
  17. package/dist/editor/OnPageEditor.d.ts +15 -8
  18. package/dist/editor/OnPageEditor.d.ts.map +1 -1
  19. package/dist/editor/OnPageEditor.js +73 -103
  20. package/dist/editor/OnPageEditor.js.map +1 -1
  21. package/dist/editor/editTarget.d.ts +28 -0
  22. package/dist/editor/editTarget.d.ts.map +1 -0
  23. package/dist/editor/editTarget.js +20 -0
  24. package/dist/editor/editTarget.js.map +1 -0
  25. package/dist/editor/useClientDocs.d.ts +4 -0
  26. package/dist/editor/useClientDocs.d.ts.map +1 -0
  27. package/dist/editor/useClientDocs.js +16 -0
  28. package/dist/editor/useClientDocs.js.map +1 -0
  29. package/dist/editor/useEditorSession.d.ts +13 -0
  30. package/dist/editor/useEditorSession.d.ts.map +1 -0
  31. package/dist/editor/useEditorSession.js +30 -0
  32. package/dist/editor/useEditorSession.js.map +1 -0
  33. package/dist/editor/useFlash.d.ts +4 -0
  34. package/dist/editor/useFlash.d.ts.map +1 -0
  35. package/dist/editor/useFlash.js +16 -0
  36. package/dist/editor/useFlash.js.map +1 -0
  37. package/dist/editor/useGhosts.d.ts +5 -7
  38. package/dist/editor/useGhosts.d.ts.map +1 -1
  39. package/dist/editor/useGhosts.js.map +1 -1
  40. package/dist/editor/usePageEvents.d.ts.map +1 -1
  41. package/dist/editor/usePageEvents.js +16 -16
  42. package/dist/editor/usePageEvents.js.map +1 -1
  43. package/dist/editor-kit.d.ts +10 -6
  44. package/dist/editor-kit.d.ts.map +1 -1
  45. package/dist/editor-kit.js +8 -5
  46. package/dist/editor-kit.js.map +1 -1
  47. package/dist/editors/CheckboxEditor.d.ts +6 -1
  48. package/dist/editors/CheckboxEditor.d.ts.map +1 -1
  49. package/dist/editors/CheckboxEditor.js +6 -3
  50. package/dist/editors/CheckboxEditor.js.map +1 -1
  51. package/dist/editors/ColorEditor.d.ts +3 -1
  52. package/dist/editors/ColorEditor.d.ts.map +1 -1
  53. package/dist/editors/ColorEditor.js +9 -15
  54. package/dist/editors/ColorEditor.js.map +1 -1
  55. package/dist/editors/DateEditor.d.ts +3 -1
  56. package/dist/editors/DateEditor.d.ts.map +1 -1
  57. package/dist/editors/DateEditor.js +10 -12
  58. package/dist/editors/DateEditor.js.map +1 -1
  59. package/dist/editors/InlineText.d.ts +2 -2
  60. package/dist/editors/InlineText.d.ts.map +1 -1
  61. package/dist/editors/InlineText.js +2 -3
  62. package/dist/editors/InlineText.js.map +1 -1
  63. package/dist/editors/NumberEditor.d.ts +3 -1
  64. package/dist/editors/NumberEditor.d.ts.map +1 -1
  65. package/dist/editors/NumberEditor.js +9 -17
  66. package/dist/editors/NumberEditor.js.map +1 -1
  67. package/dist/editors/RelationshipEditor.d.ts +7 -1
  68. package/dist/editors/RelationshipEditor.d.ts.map +1 -1
  69. package/dist/editors/RelationshipEditor.js +10 -18
  70. package/dist/editors/RelationshipEditor.js.map +1 -1
  71. package/dist/editors/SelectEditor.d.ts +4 -1
  72. package/dist/editors/SelectEditor.d.ts.map +1 -1
  73. package/dist/editors/SelectEditor.js +8 -13
  74. package/dist/editors/SelectEditor.js.map +1 -1
  75. package/dist/editors/UploadEditor.d.ts +7 -1
  76. package/dist/editors/UploadEditor.d.ts.map +1 -1
  77. package/dist/editors/UploadEditor.js +5 -5
  78. package/dist/editors/UploadEditor.js.map +1 -1
  79. package/dist/editors/index.d.ts +0 -1
  80. package/dist/editors/index.d.ts.map +1 -1
  81. package/dist/editors/index.js +56 -16
  82. package/dist/editors/index.js.map +1 -1
  83. package/dist/editors/kit.d.ts +39 -0
  84. package/dist/editors/kit.d.ts.map +1 -0
  85. package/dist/editors/kit.js +44 -0
  86. package/dist/editors/kit.js.map +1 -0
  87. package/dist/editors/useDebouncedSearch.d.ts +10 -6
  88. package/dist/editors/useDebouncedSearch.d.ts.map +1 -1
  89. package/dist/editors/useDebouncedSearch.js +11 -7
  90. package/dist/editors/useDebouncedSearch.js.map +1 -1
  91. package/dist/geometry.d.ts +49 -0
  92. package/dist/geometry.d.ts.map +1 -0
  93. package/dist/geometry.js +48 -0
  94. package/dist/geometry.js.map +1 -0
  95. package/dist/hub.d.ts +4 -4
  96. package/dist/hub.d.ts.map +1 -1
  97. package/dist/hub.js.map +1 -1
  98. package/dist/index.d.ts +3 -3
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +2 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/layout.d.ts.map +1 -1
  103. package/dist/layout.js +4 -2
  104. package/dist/layout.js.map +1 -1
  105. package/dist/locators.d.ts +0 -2
  106. package/dist/locators.d.ts.map +1 -1
  107. package/dist/locators.js +10 -16
  108. package/dist/locators.js.map +1 -1
  109. package/dist/messages.d.ts +15 -2
  110. package/dist/messages.d.ts.map +1 -1
  111. package/dist/messages.js +5 -0
  112. package/dist/messages.js.map +1 -1
  113. package/dist/registry.d.ts +21 -2
  114. package/dist/registry.d.ts.map +1 -1
  115. package/dist/registry.js +31 -6
  116. package/dist/registry.js.map +1 -1
  117. package/dist/rowOps.d.ts +20 -0
  118. package/dist/rowOps.d.ts.map +1 -0
  119. package/dist/rowOps.js +48 -0
  120. package/dist/rowOps.js.map +1 -0
  121. package/dist/sanitize.d.ts +2 -0
  122. package/dist/sanitize.d.ts.map +1 -1
  123. package/dist/sanitize.js +3 -0
  124. package/dist/sanitize.js.map +1 -1
  125. package/dist/settings.d.ts +10 -0
  126. package/dist/settings.d.ts.map +1 -0
  127. package/dist/settings.js +30 -0
  128. package/dist/settings.js.map +1 -0
  129. package/dist/styles.d.ts +0 -4
  130. package/dist/styles.d.ts.map +1 -1
  131. package/dist/styles.js +2 -1
  132. package/dist/styles.js.map +1 -1
  133. package/dist/targets.d.ts +1 -1
  134. package/dist/targets.d.ts.map +1 -1
  135. package/dist/types.d.ts +71 -46
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/ui/BlockPicker.d.ts.map +1 -1
  138. package/dist/ui/BlockPicker.js +6 -5
  139. package/dist/ui/BlockPicker.js.map +1 -1
  140. package/dist/ui/Dialog.d.ts +0 -5
  141. package/dist/ui/Dialog.d.ts.map +1 -1
  142. package/dist/ui/Dialog.js +10 -6
  143. package/dist/ui/Dialog.js.map +1 -1
  144. package/dist/ui/Dock.d.ts +1 -6
  145. package/dist/ui/Dock.d.ts.map +1 -1
  146. package/dist/ui/Dock.js +46 -28
  147. package/dist/ui/Dock.js.map +1 -1
  148. package/dist/ui/Overlay.d.ts.map +1 -1
  149. package/dist/ui/Overlay.js +39 -37
  150. package/dist/ui/Overlay.js.map +1 -1
  151. package/dist/ui/PreviewToolbar.d.ts +3 -3
  152. package/dist/ui/PreviewToolbar.d.ts.map +1 -1
  153. package/dist/ui/PreviewToolbar.js +4 -6
  154. package/dist/ui/PreviewToolbar.js.map +1 -1
  155. package/dist/ui/SettingsPanel.d.ts +0 -9
  156. package/dist/ui/SettingsPanel.d.ts.map +1 -1
  157. package/dist/ui/SettingsPanel.js +36 -41
  158. package/dist/ui/SettingsPanel.js.map +1 -1
  159. package/dist/ui/ToolbarButton.d.ts +12 -0
  160. package/dist/ui/ToolbarButton.d.ts.map +1 -0
  161. package/dist/ui/ToolbarButton.js +7 -0
  162. package/dist/ui/ToolbarButton.js.map +1 -0
  163. package/dist/useEvent.d.ts +1 -1
  164. package/dist/useEvent.d.ts.map +1 -1
  165. package/dist/useEvent.js +3 -1
  166. package/dist/useEvent.js.map +1 -1
  167. package/dist/useIsClient.d.ts +5 -0
  168. package/dist/useIsClient.d.ts.map +1 -0
  169. package/dist/useIsClient.js +8 -0
  170. package/dist/useIsClient.js.map +1 -0
  171. package/package.json +2 -2
  172. package/src/OnPageClient.tsx +9 -5
  173. package/src/annotations.ts +1 -1
  174. package/src/context.tsx +53 -10
  175. package/src/dom.ts +17 -18
  176. package/src/editor/FieldHost.tsx +26 -11
  177. package/src/editor/OnPageEditor.tsx +103 -160
  178. package/src/editor/editTarget.ts +41 -0
  179. package/src/editor/useClientDocs.ts +19 -0
  180. package/src/editor/useEditorSession.ts +36 -0
  181. package/src/editor/useFlash.ts +18 -0
  182. package/src/editor/useGhosts.ts +5 -7
  183. package/src/editor/usePageEvents.ts +13 -14
  184. package/src/editor-kit.ts +10 -6
  185. package/src/editors/CheckboxEditor.tsx +11 -12
  186. package/src/editors/ColorEditor.tsx +15 -35
  187. package/src/editors/DateEditor.tsx +18 -37
  188. package/src/editors/InlineText.tsx +4 -5
  189. package/src/editors/NumberEditor.tsx +15 -36
  190. package/src/editors/RelationshipEditor.tsx +18 -36
  191. package/src/editors/SelectEditor.tsx +14 -22
  192. package/src/editors/UploadEditor.tsx +6 -6
  193. package/src/editors/index.ts +56 -16
  194. package/src/editors/kit.tsx +97 -0
  195. package/src/editors/useDebouncedSearch.ts +18 -10
  196. package/src/geometry.ts +62 -0
  197. package/src/hub.ts +5 -5
  198. package/src/index.ts +5 -2
  199. package/src/layout.ts +5 -2
  200. package/src/locators.ts +11 -14
  201. package/src/messages.ts +26 -5
  202. package/src/registry.ts +42 -8
  203. package/src/rowOps.ts +64 -0
  204. package/src/sanitize.ts +6 -0
  205. package/src/settings.ts +49 -0
  206. package/src/styles.ts +2 -1
  207. package/src/targets.ts +1 -1
  208. package/src/types.ts +78 -45
  209. package/src/ui/BlockPicker.tsx +6 -7
  210. package/src/ui/Dialog.tsx +9 -5
  211. package/src/ui/Dock.tsx +67 -50
  212. package/src/ui/Overlay.tsx +75 -102
  213. package/src/ui/PreviewToolbar.tsx +7 -15
  214. package/src/ui/SettingsPanel.tsx +84 -81
  215. package/src/ui/ToolbarButton.tsx +36 -0
  216. package/src/useEvent.ts +4 -2
  217. package/src/useIsClient.ts +15 -0
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
@@ -6,35 +6,34 @@
6
6
  * from the target elements whenever the layout changes.
7
7
  */
8
8
  import {
9
- cloneRow,
10
9
  fieldAtPath,
11
10
  getAt,
12
- insertItem,
13
11
  isEditablePath,
14
- moveItem,
12
+ isListField,
15
13
  newArrayRow,
16
14
  refKey,
17
- removeItem,
18
15
  resolveRow,
19
- updateArray,
20
- type Doc,
16
+ rowPrefixes,
21
17
  type RowInfo,
22
18
  type Target,
19
+ isReady,
20
+ type RefKey,
23
21
  } from '@payload-onpage/core'
24
22
  import { useEffect, useState } from 'react'
25
23
  import { useOnPage } from '../context.tsx'
26
24
  import { isOurs } from '../dom.ts'
25
+ import { belowList, GEOMETRY, inViewport, outlineBox, toolbarPosition } from '../geometry.ts'
27
26
  import { useLayoutTick } from '../layout.ts'
27
+ import { ToolbarButton } from './ToolbarButton.tsx'
28
28
  import { targetsAt } from '../targets.ts'
29
- import { hasSettings } from './SettingsPanel.tsx'
29
+ import { insertRow, rowOps } from '../rowOps.ts'
30
+ import { hasSettings } from '../settings.ts'
30
31
 
31
32
  interface Hover {
32
- field?: Target
33
+ field?: Target | undefined
33
34
  rows: Target[] // innermost first
34
35
  }
35
36
 
36
- const VIEW_PAD = 8
37
-
38
37
  function useHover(): Hover {
39
38
  const { targets } = useOnPage()
40
39
  const [hover, setHover] = useState<Hover>({ rows: [] })
@@ -70,14 +69,14 @@ function Outline({
70
69
  if (r.width === 0 && r.height === 0) return null
71
70
  return (
72
71
  <>
73
- <div
74
- className={`po-outline${variant ? ` is-${variant}` : ''}`}
75
- style={{ top: r.top - 2, left: r.left - 2, width: r.width + 4, height: r.height + 4 }}
76
- />
72
+ <div className={`po-outline${variant ? ` is-${variant}` : ''}`} style={outlineBox(r)} />
77
73
  {label && (
78
74
  <div
79
75
  className={`po-chip${variant === 'item' ? ' is-item' : ''}`}
80
- style={{ top: Math.max(r.top - 2, 22), left: r.left - 2 }}
76
+ style={{
77
+ top: Math.max(r.top - GEOMETRY.outline, GEOMETRY.chipMinTop),
78
+ left: r.left - GEOMETRY.outline,
79
+ }}
81
80
  >
82
81
  {label}
83
82
  </div>
@@ -87,7 +86,7 @@ function Outline({
87
86
  }
88
87
 
89
88
  interface ActiveRow {
90
- key: string
89
+ key: RefKey
91
90
  info: RowInfo
92
91
  el: Element
93
92
  }
@@ -96,94 +95,67 @@ function RowToolbar({ row, variant }: { row: ActiveRow; variant: 'block' | 'item
96
95
  const { session, setSelected, setPanel, selected, targets, t } = useOnPage()
97
96
  const { key, info, el } = row
98
97
  const r = el.getBoundingClientRect()
99
- if (r.bottom < 40 || r.top > window.innerHeight - 40) return null
100
- const top = Math.min(Math.max(r.top + VIEW_PAD, 60), r.bottom - 40)
101
- const right = Math.max(window.innerWidth - r.right + VIEW_PAD, VIEW_PAD)
102
- const { listPath, index, length, rowFields, path } = info
98
+ if (!inViewport(r, GEOMETRY.toolbar)) return null
99
+ const { path } = info
103
100
  const label = targets.find(key, path, ['item'])?.label || info.label
104
- const apply = (fn: (d: Doc) => Doc) => session.apply(key, fn)
105
- const max = info.listField.maxRows
106
- const min = info.listField.minRows ?? 0
107
-
101
+ const ops = rowOps(session, key, info)
108
102
  const move = (dir: -1 | 1) => {
109
- apply((d) => updateArray(d, listPath, (a) => moveItem(a, index, index + dir)))
110
- setSelected({ key, path: `${listPath}.${index + dir}` })
103
+ const next = ops.move(dir)
104
+ if (next) setSelected(next)
111
105
  }
112
106
  const duplicate = () => {
113
- apply((d) =>
114
- updateArray(d, listPath, (a) => insertItem(a, index + 1, cloneRow(a[index], rowFields))),
115
- )
116
- setSelected({ key, path: `${listPath}.${index + 1}` })
107
+ const next = ops.duplicate()
108
+ if (next) setSelected(next)
117
109
  }
118
110
  const remove = () => {
119
- apply((d) => updateArray(d, listPath, (a) => removeItem(a, index)))
120
- if (selected?.key === key && selected.path.startsWith(path)) setSelected(null)
111
+ if (ops.remove() && selected?.key === key && selected.path.startsWith(path)) setSelected(null)
121
112
  }
122
113
  return (
123
114
  <div
124
115
  className={`po-bar${variant === 'item' ? ' is-item' : ''}`}
125
- style={{ top, right }}
116
+ style={toolbarPosition(r)}
126
117
  role="toolbar"
127
118
  aria-label={t('row.toolbar', { label })}
128
119
  data-testid={`onpage-${variant}-toolbar`}
129
120
  >
130
121
  <span className="po-btn po-bar-label">{label}</span>
131
- <button
132
- type="button"
133
- className="po-btn"
134
- disabled={index === 0}
122
+ <ToolbarButton
123
+ label={t('row.moveUp')}
124
+ icon="↑"
125
+ disabled={!ops.canMove(-1)}
135
126
  onClick={() => move(-1)}
136
- aria-label={t('row.moveUp')}
137
- title={t('row.moveUp')}
138
- data-testid={`onpage-${variant}-up`}
139
- >
140
- ↑
141
- </button>
142
- <button
143
- type="button"
144
- className="po-btn"
145
- disabled={index >= length - 1}
127
+ testId={`onpage-${variant}-up`}
128
+ />
129
+ <ToolbarButton
130
+ label={t('row.moveDown')}
131
+ icon="↓"
132
+ disabled={!ops.canMove(1)}
146
133
  onClick={() => move(1)}
147
- aria-label={t('row.moveDown')}
148
- title={t('row.moveDown')}
149
- data-testid={`onpage-${variant}-down`}
150
- >
151
- ↓
152
- </button>
153
- <button
154
- type="button"
155
- className="po-btn"
156
- disabled={max !== undefined && length >= max}
134
+ testId={`onpage-${variant}-down`}
135
+ />
136
+ <ToolbarButton
137
+ label={t('row.duplicate')}
138
+ icon="⧉"
139
+ disabled={!ops.canDuplicate}
157
140
  onClick={duplicate}
158
- aria-label={t('row.duplicate')}
159
- title={t('row.duplicate')}
160
- data-testid={`onpage-${variant}-duplicate`}
161
- >
162
- ⧉
163
- </button>
164
- {hasSettings(rowFields, path, key, targets) && (
165
- <button
166
- type="button"
167
- className="po-btn"
141
+ testId={`onpage-${variant}-duplicate`}
142
+ />
143
+ {hasSettings(info.rowFields, path, key, targets) && (
144
+ <ToolbarButton
145
+ label={t('row.settings')}
146
+ icon="⚙"
168
147
  onClick={() => setPanel({ kind: 'settings', key, path, title: label })}
169
- aria-label={t('row.settings')}
170
- title={t('row.settings')}
171
- data-testid={`onpage-${variant}-settings`}
172
- >
173
- ⚙
174
- </button>
148
+ testId={`onpage-${variant}-settings`}
149
+ />
175
150
  )}
176
- <button
177
- type="button"
178
- className="po-btn is-danger"
179
- disabled={length <= min}
151
+ <ToolbarButton
152
+ label={t('row.delete')}
153
+ icon="✕"
154
+ danger
155
+ disabled={!ops.canRemove}
180
156
  onClick={remove}
181
- aria-label={t('row.delete')}
182
- title={t('row.delete')}
183
- data-testid={`onpage-${variant}-delete`}
184
- >
185
- ✕
186
- </button>
157
+ testId={`onpage-${variant}-delete`}
158
+ />
187
159
  </div>
188
160
  )
189
161
  }
@@ -201,7 +173,7 @@ function InsertPoints({ row }: { row: ActiveRow }) {
201
173
  onClick={() =>
202
174
  setPanel({ kind: 'blocks', key: row.key, listPath: row.info.listPath, index })
203
175
  }
204
- aria-label={`${t('row.addSection')} (${edge})`}
176
+ aria-label={t(edge === 'above' ? 'row.addSectionAbove' : 'row.addSectionBelow')}
205
177
  data-testid={`onpage-insert-${edge}`}
206
178
  >
207
179
  + {t('row.addSection')}
@@ -220,10 +192,11 @@ function AddItem({ list }: { list: Target }) {
220
192
  const { snap, session, setPanel, setSelected, t } = useOnPage()
221
193
  const key = refKey(list.ref)
222
194
  const state = snap.docs[key]
223
- if (!state?.schema || !state.doc || !isEditablePath(state.schema, list.path)) return null
195
+ if (!isReady(state) || !isEditablePath(state.schema, list.path)) return null
224
196
  const field = fieldAtPath(state.schema.fields, state.doc, list.path)?.field
225
- if (!field || (field.type !== 'array' && field.type !== 'blocks')) return null
226
- const length = (getAt<unknown[]>(state.doc, list.path) ?? []).length
197
+ if (!field || !isListField(field)) return null
198
+ const rows = getAt(state.doc, list.path)
199
+ const length = Array.isArray(rows) ? rows.length : 0
227
200
  const r = list.el.getBoundingClientRect()
228
201
  if (field.type === 'blocks') {
229
202
  // Sections get "+ Add section" above/below each one; this covers an empty list.
@@ -231,7 +204,10 @@ function AddItem({ list }: { list: Target }) {
231
204
  return (
232
205
  <div
233
206
  className="po-add"
234
- style={{ top: r.top + Math.min(r.height / 2, 160), left: r.left + r.width / 2 }}
207
+ style={{
208
+ top: r.top + Math.min(r.height / 2, GEOMETRY.emptyListOffset),
209
+ left: r.left + r.width / 2,
210
+ }}
235
211
  >
236
212
  <button
237
213
  type="button"
@@ -246,13 +222,10 @@ function AddItem({ list }: { list: Target }) {
246
222
  }
247
223
  if (field.maxRows !== undefined && length >= field.maxRows) return null
248
224
  // Below the list, or pinned to the bottom of the viewport while the list runs past it.
249
- if (r.bottom < 0 || r.top > window.innerHeight - 40) return null
250
- const y = Math.min(r.bottom + 14, window.innerHeight - 84)
225
+ if (!inViewport(r) || r.top > window.innerHeight - GEOMETRY.toolbar) return null
226
+ const y = belowList(r)
251
227
  const item = list.label || field.labels?.singular || field.label
252
- const add = () => {
253
- session.apply(key, (d) => updateArray(d, list.path, (a) => [...a, newArrayRow(field)]))
254
- setSelected({ key, path: `${list.path}.${length}` })
255
- }
228
+ const add = () => setSelected(insertRow(session, key, list.path, length, newArrayRow(field)))
256
229
  return (
257
230
  <div className="po-add is-item" style={{ top: y, left: r.left + r.width / 2 }}>
258
231
  <button type="button" className="po-btn" onClick={add} data-testid="onpage-add-item">
@@ -271,8 +244,7 @@ export function Overlay() {
271
244
  if (!target) return null
272
245
  const key = refKey(target.ref)
273
246
  const state = snap.docs[key]
274
- const info =
275
- state?.schema && state.doc ? resolveRow(state.schema, state.doc, target.path) : null
247
+ const info = isReady(state) ? resolveRow(state.schema, state.doc, target.path) : null
276
248
  return info ? { key, info, el: target.el } : null
277
249
  }
278
250
 
@@ -284,13 +256,13 @@ export function Overlay() {
284
256
  // Its section: the outermost enclosing row of a blocks field (may be itself).
285
257
  let section: ActiveRow | null = null
286
258
  if (active) {
287
- const segs = active.info.path.split('.')
288
- for (let i = 1; i < segs.length && !section; i++) {
289
- if (!/^\d+$/.test(segs[i])) continue
290
- const path = segs.slice(0, i + 1).join('.')
259
+ for (const path of [...rowPrefixes(active.info.path), active.info.path]) {
291
260
  const candidate =
292
261
  path === active.info.path ? active : rowFor(targets.find(active.key, path, ['item']))
293
- if (candidate?.info.isBlock) section = candidate
262
+ if (candidate?.info.isBlock) {
263
+ section = candidate
264
+ break
265
+ }
294
266
  }
295
267
  }
296
268
  const item = active && section?.info.path !== active.info.path ? active : null
@@ -302,7 +274,8 @@ export function Overlay() {
302
274
  : undefined
303
275
  const emptyLists = lists.filter((l) => {
304
276
  const doc = snap.docs[refKey(l.ref)]?.doc
305
- return doc && !(getAt<unknown[]>(doc, l.path) ?? []).length
277
+ const rows = doc ? getAt(doc, l.path) : undefined
278
+ return doc && !(Array.isArray(rows) && rows.length)
306
279
  })
307
280
  const hoverField = !editing ? hover.field : undefined
308
281
 
@@ -5,21 +5,21 @@
5
5
  * page in a framed viewport. Signed-in editors can switch back to editing;
6
6
  * people on a shared link can leave to the published page.
7
7
  */
8
- import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
8
+ import { useEffect, useMemo, useState } from 'react'
9
+ import { useEditorStyles, useIsClient } from '../useIsClient.ts'
9
10
  import { createPortal } from 'react-dom'
10
11
  import { UI } from '../dom.ts'
11
12
  import { createTranslate, type Messages } from '../messages.ts'
12
- import { injectStyles } from '../styles.ts'
13
13
  import { PostButton } from './PostButton.tsx'
14
14
 
15
15
  export interface PreviewToolbarProps {
16
16
  /** Endpoint that leaves preview (POST), back to the published page. */
17
17
  exitHref: string
18
18
  /** Endpoint that switches to editing (POST); omit for people who can't edit (shared links). */
19
- editHref?: string
19
+ editHref?: string | undefined
20
20
  /** Opened from a shared preview link. */
21
- shared?: boolean
22
- messages?: Messages
21
+ shared?: boolean | undefined
22
+ messages?: Messages | undefined
23
23
  }
24
24
 
25
25
  const DEVICES = [
@@ -34,19 +34,11 @@ export const inPreviewFrame = (): boolean =>
34
34
  typeof window !== 'undefined' &&
35
35
  (window.self !== window.top || new URLSearchParams(window.location.search).has(FRAME_PARAM))
36
36
 
37
- const noop = () => () => {}
38
- const useMounted = () =>
39
- useSyncExternalStore(
40
- noop,
41
- () => true,
42
- () => false,
43
- )
44
-
45
37
  export function PreviewToolbar({ exitHref, editHref, shared, messages }: PreviewToolbarProps) {
46
38
  const [device, setDevice] = useState<(typeof DEVICES)[number] | null>(null)
47
- const mounted = useMounted()
39
+ const mounted = useIsClient()
48
40
  const t = useMemo(() => createTranslate(messages), [messages])
49
- useEffect(() => injectStyles(), [])
41
+ useEditorStyles()
50
42
  useEffect(() => {
51
43
  if (!device) return
52
44
  const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setDevice(null)