@payload-onpage/react 0.0.0-stage → 0.1.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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/OnPageClient.d.ts +18 -0
  4. package/dist/OnPageClient.d.ts.map +1 -0
  5. package/dist/OnPageClient.js +51 -0
  6. package/dist/OnPageClient.js.map +1 -0
  7. package/dist/annotations.d.ts +55 -0
  8. package/dist/annotations.d.ts.map +1 -0
  9. package/dist/annotations.js +60 -0
  10. package/dist/annotations.js.map +1 -0
  11. package/dist/context.d.ts +7 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +19 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/dom.d.ts +42 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +95 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/editor/FieldHost.d.ts +5 -0
  20. package/dist/editor/FieldHost.d.ts.map +1 -0
  21. package/dist/editor/FieldHost.js +36 -0
  22. package/dist/editor/FieldHost.js.map +1 -0
  23. package/dist/editor/Ghosts.d.ts +6 -0
  24. package/dist/editor/Ghosts.d.ts.map +1 -0
  25. package/dist/editor/Ghosts.js +37 -0
  26. package/dist/editor/Ghosts.js.map +1 -0
  27. package/dist/editor/OnPageEditor.d.ts +25 -0
  28. package/dist/editor/OnPageEditor.d.ts.map +1 -0
  29. package/dist/editor/OnPageEditor.js +211 -0
  30. package/dist/editor/OnPageEditor.js.map +1 -0
  31. package/dist/editor/useGhosts.d.ts +24 -0
  32. package/dist/editor/useGhosts.d.ts.map +1 -0
  33. package/dist/editor/useGhosts.js +34 -0
  34. package/dist/editor/useGhosts.js.map +1 -0
  35. package/dist/editor/usePageEvents.d.ts +3 -0
  36. package/dist/editor/usePageEvents.d.ts.map +1 -0
  37. package/dist/editor/usePageEvents.js +164 -0
  38. package/dist/editor/usePageEvents.js.map +1 -0
  39. package/dist/editor-kit.d.ts +19 -0
  40. package/dist/editor-kit.d.ts.map +1 -0
  41. package/dist/editor-kit.js +17 -0
  42. package/dist/editor-kit.js.map +1 -0
  43. package/dist/editors/CheckboxEditor.d.ts +3 -0
  44. package/dist/editors/CheckboxEditor.d.ts.map +1 -0
  45. package/dist/editors/CheckboxEditor.js +10 -0
  46. package/dist/editors/CheckboxEditor.js.map +1 -0
  47. package/dist/editors/ColorEditor.d.ts +4 -0
  48. package/dist/editors/ColorEditor.d.ts.map +1 -0
  49. package/dist/editors/ColorEditor.js +21 -0
  50. package/dist/editors/ColorEditor.js.map +1 -0
  51. package/dist/editors/DateEditor.d.ts +3 -0
  52. package/dist/editors/DateEditor.d.ts.map +1 -0
  53. package/dist/editors/DateEditor.js +28 -0
  54. package/dist/editors/DateEditor.js.map +1 -0
  55. package/dist/editors/InlineText.d.ts +4 -0
  56. package/dist/editors/InlineText.d.ts.map +1 -0
  57. package/dist/editors/InlineText.js +77 -0
  58. package/dist/editors/InlineText.js.map +1 -0
  59. package/dist/editors/NumberEditor.d.ts +4 -0
  60. package/dist/editors/NumberEditor.d.ts.map +1 -0
  61. package/dist/editors/NumberEditor.js +23 -0
  62. package/dist/editors/NumberEditor.js.map +1 -0
  63. package/dist/editors/RelationshipEditor.d.ts +3 -0
  64. package/dist/editors/RelationshipEditor.d.ts.map +1 -0
  65. package/dist/editors/RelationshipEditor.js +60 -0
  66. package/dist/editors/RelationshipEditor.js.map +1 -0
  67. package/dist/editors/SelectEditor.d.ts +4 -0
  68. package/dist/editors/SelectEditor.d.ts.map +1 -0
  69. package/dist/editors/SelectEditor.js +36 -0
  70. package/dist/editors/SelectEditor.js.map +1 -0
  71. package/dist/editors/UploadEditor.d.ts +3 -0
  72. package/dist/editors/UploadEditor.d.ts.map +1 -0
  73. package/dist/editors/UploadEditor.js +48 -0
  74. package/dist/editors/UploadEditor.js.map +1 -0
  75. package/dist/editors/index.d.ts +13 -0
  76. package/dist/editors/index.d.ts.map +1 -0
  77. package/dist/editors/index.js +23 -0
  78. package/dist/editors/index.js.map +1 -0
  79. package/dist/editors/useDebouncedSearch.d.ts +17 -0
  80. package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
  81. package/dist/editors/useDebouncedSearch.js +50 -0
  82. package/dist/editors/useDebouncedSearch.js.map +1 -0
  83. package/dist/hub.d.ts +14 -0
  84. package/dist/hub.d.ts.map +1 -0
  85. package/dist/hub.js +20 -0
  86. package/dist/hub.js.map +1 -0
  87. package/dist/index.d.ts +20 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +19 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/layout.d.ts +12 -0
  92. package/dist/layout.d.ts.map +1 -0
  93. package/dist/layout.js +83 -0
  94. package/dist/layout.js.map +1 -0
  95. package/dist/locators.d.ts +19 -0
  96. package/dist/locators.d.ts.map +1 -0
  97. package/dist/locators.js +215 -0
  98. package/dist/locators.js.map +1 -0
  99. package/dist/messages.d.ts +100 -0
  100. package/dist/messages.d.ts.map +1 -0
  101. package/dist/messages.js +112 -0
  102. package/dist/messages.js.map +1 -0
  103. package/dist/registry.d.ts +15 -0
  104. package/dist/registry.d.ts.map +1 -0
  105. package/dist/registry.js +29 -0
  106. package/dist/registry.js.map +1 -0
  107. package/dist/renderers.d.ts +7 -0
  108. package/dist/renderers.d.ts.map +1 -0
  109. package/dist/renderers.js +12 -0
  110. package/dist/renderers.js.map +1 -0
  111. package/dist/sanitize.d.ts +2 -0
  112. package/dist/sanitize.d.ts.map +1 -0
  113. package/dist/sanitize.js +49 -0
  114. package/dist/sanitize.js.map +1 -0
  115. package/dist/styles.d.ts +8 -0
  116. package/dist/styles.d.ts.map +1 -0
  117. package/dist/styles.js +122 -0
  118. package/dist/styles.js.map +1 -0
  119. package/dist/targets.d.ts +18 -0
  120. package/dist/targets.d.ts.map +1 -0
  121. package/dist/targets.js +86 -0
  122. package/dist/targets.js.map +1 -0
  123. package/dist/types.d.ts +131 -0
  124. package/dist/types.d.ts.map +1 -0
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -0
  127. package/dist/ui/BlockPicker.d.ts +3 -0
  128. package/dist/ui/BlockPicker.d.ts.map +1 -0
  129. package/dist/ui/BlockPicker.js +27 -0
  130. package/dist/ui/BlockPicker.js.map +1 -0
  131. package/dist/ui/Dialog.d.ts +22 -0
  132. package/dist/ui/Dialog.d.ts.map +1 -0
  133. package/dist/ui/Dialog.js +83 -0
  134. package/dist/ui/Dialog.js.map +1 -0
  135. package/dist/ui/Dock.d.ts +7 -0
  136. package/dist/ui/Dock.d.ts.map +1 -0
  137. package/dist/ui/Dock.js +104 -0
  138. package/dist/ui/Dock.js.map +1 -0
  139. package/dist/ui/Overlay.d.ts +2 -0
  140. package/dist/ui/Overlay.d.ts.map +1 -0
  141. package/dist/ui/Overlay.js +150 -0
  142. package/dist/ui/Overlay.js.map +1 -0
  143. package/dist/ui/PostButton.d.ts +10 -0
  144. package/dist/ui/PostButton.d.ts.map +1 -0
  145. package/dist/ui/PostButton.js +7 -0
  146. package/dist/ui/PostButton.js.map +1 -0
  147. package/dist/ui/PreviewToolbar.d.ts +15 -0
  148. package/dist/ui/PreviewToolbar.d.ts.map +1 -0
  149. package/dist/ui/PreviewToolbar.js +45 -0
  150. package/dist/ui/PreviewToolbar.js.map +1 -0
  151. package/dist/ui/SettingsPanel.d.ts +11 -0
  152. package/dist/ui/SettingsPanel.d.ts.map +1 -0
  153. package/dist/ui/SettingsPanel.js +130 -0
  154. package/dist/ui/SettingsPanel.js.map +1 -0
  155. package/dist/useEvent.d.ts +7 -0
  156. package/dist/useEvent.d.ts.map +1 -0
  157. package/dist/useEvent.js +15 -0
  158. package/dist/useEvent.js.map +1 -0
  159. package/package.json +71 -4
  160. package/src/OnPageClient.tsx +67 -0
  161. package/src/annotations.ts +83 -0
  162. package/src/context.tsx +23 -0
  163. package/src/dom.ts +118 -0
  164. package/src/editor/FieldHost.tsx +66 -0
  165. package/src/editor/Ghosts.tsx +55 -0
  166. package/src/editor/OnPageEditor.tsx +315 -0
  167. package/src/editor/useGhosts.ts +64 -0
  168. package/src/editor/usePageEvents.ts +161 -0
  169. package/src/editor-kit.ts +27 -0
  170. package/src/editors/CheckboxEditor.tsx +31 -0
  171. package/src/editors/ColorEditor.tsx +64 -0
  172. package/src/editors/DateEditor.tsx +64 -0
  173. package/src/editors/InlineText.tsx +106 -0
  174. package/src/editors/NumberEditor.tsx +69 -0
  175. package/src/editors/RelationshipEditor.tsx +153 -0
  176. package/src/editors/SelectEditor.tsx +74 -0
  177. package/src/editors/UploadEditor.tsx +130 -0
  178. package/src/editors/index.ts +35 -0
  179. package/src/editors/useDebouncedSearch.ts +54 -0
  180. package/src/hub.ts +32 -0
  181. package/src/index.ts +55 -0
  182. package/src/layout.ts +83 -0
  183. package/src/locators.ts +210 -0
  184. package/src/messages.ts +120 -0
  185. package/src/registry.ts +43 -0
  186. package/src/renderers.ts +15 -0
  187. package/src/sanitize.ts +52 -0
  188. package/src/styles.ts +122 -0
  189. package/src/targets.ts +101 -0
  190. package/src/types.ts +132 -0
  191. package/src/ui/BlockPicker.tsx +49 -0
  192. package/src/ui/Dialog.tsx +159 -0
  193. package/src/ui/Dock.tsx +240 -0
  194. package/src/ui/Overlay.tsx +324 -0
  195. package/src/ui/PostButton.tsx +26 -0
  196. package/src/ui/PreviewToolbar.tsx +123 -0
  197. package/src/ui/SettingsPanel.tsx +279 -0
  198. package/src/useEvent.ts +16 -0
@@ -0,0 +1,240 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * The bottom bar: save status across the page's documents (with per-document
5
+ * recovery: reload after a conflict, retry after an error), undo/redo, locale,
6
+ * share link, preview, discard, admin, exit and publish.
7
+ */
8
+ import type { DocumentState } from '@payload-onpage/core'
9
+ import { useEffect, useState } from 'react'
10
+ import { useOnPage } from '../context.tsx'
11
+ import { relativeTime } from '../messages.ts'
12
+ import { PostButton } from './PostButton.tsx'
13
+
14
+ const title = (d: DocumentState) => d.schema?.labels.singular ?? d.ref.slug
15
+
16
+ function useTicker(ms: number) {
17
+ const [, setN] = useState(0)
18
+ useEffect(() => {
19
+ const id = setInterval(() => setN((n) => n + 1), ms)
20
+ return () => clearInterval(id)
21
+ }, [ms])
22
+ }
23
+
24
+ export function Dock({ flash }: { flash: { text: string; bad?: boolean } | null }) {
25
+ const { session, snap, links, rendering, notify, rerender, docKeys, t } = useOnPage()
26
+ const [confirmDiscard, setConfirmDiscard] = useState(false)
27
+ useTicker(5000)
28
+ useEffect(() => {
29
+ if (!confirmDiscard) return
30
+ const id = setTimeout(() => setConfirmDiscard(false), 4000)
31
+ return () => clearTimeout(id)
32
+ }, [confirmDiscard])
33
+
34
+ const docs = docKeys.map((k) => snap.docs[k]).filter((d): d is DocumentState => !!d)
35
+ const ready = docs.filter((d) => d.status === 'ready')
36
+ const failed = docs.find((d) => d.status === 'error')
37
+ const conflict = docs.find((d) => d.save.status === 'conflict')
38
+ const error = docs.find((d) => d.save.status === 'error')
39
+ const saving = docs.some((d) => d.save.status === 'saving' || d.save.status === 'pending')
40
+ const lastSaved = Math.max(0, ...docs.map((d) => d.save.lastSavedAt ?? 0))
41
+ const primary = docs[0]
42
+ const busy = snap.busy
43
+
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') }
61
+
62
+ const schema = ready[0]?.schema
63
+ const nonDefaultLocale =
64
+ schema?.defaultLocale && session.locale && session.locale !== schema.defaultLocale
65
+
66
+ const publish = async () => {
67
+ try {
68
+ const res = await session.publish({ keys: docKeys })
69
+ 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)
72
+ } catch (e) {
73
+ notify('notify.publishFailed', { message: (e as Error).message }, true)
74
+ }
75
+ }
76
+ const discard = async () => {
77
+ // Two-step: the first click arms, the second (within 4s) discards.
78
+ if (!confirmDiscard) return setConfirmDiscard(true)
79
+ setConfirmDiscard(false)
80
+ try {
81
+ await session.discard({ keys: docKeys })
82
+ notify('notify.discarded')
83
+ } catch (e) {
84
+ notify((e as Error).message, undefined, true)
85
+ }
86
+ }
87
+ const reload = async (d: DocumentState) => {
88
+ try {
89
+ await session.reload(d.key)
90
+ await rerender([d.ref])
91
+ } catch (e) {
92
+ notify((e as Error).message, undefined, true)
93
+ }
94
+ }
95
+ const createLink = session.dataSource.createPreviewLink?.bind(session.dataSource)
96
+ const share = async () => {
97
+ if (!createLink || !primary) return
98
+ try {
99
+ const { url, expiresAt } = await createLink(primary.ref, { locale: session.locale })
100
+ await navigator.clipboard.writeText(url)
101
+ notify('notify.linkCopied', { date: new Date(expiresAt).toLocaleString() })
102
+ } catch (e) {
103
+ notify('notify.linkFailed', { message: (e as Error).message }, true)
104
+ }
105
+ }
106
+
107
+ return (
108
+ <>
109
+ {nonDefaultLocale && (
110
+ <div className="po-notice" role="note" data-testid="onpage-locale-notice">
111
+ {t('dock.localeNotice', { locale: session.locale ?? '' })}
112
+ </div>
113
+ )}
114
+ <div
115
+ className="po-dock"
116
+ role="toolbar"
117
+ aria-label={t('dock.label')}
118
+ data-testid="onpage-dock"
119
+ >
120
+ <span
121
+ className={`po-status${(flash?.bad ?? status.bad) ? ' is-bad' : ''}`}
122
+ role="status"
123
+ aria-live="polite"
124
+ data-testid="onpage-status"
125
+ >
126
+ {flash?.text ?? status.text}
127
+ </span>
128
+ {conflict && (
129
+ <button
130
+ type="button"
131
+ className="po-btn"
132
+ disabled={busy}
133
+ onClick={() => void reload(conflict)}
134
+ data-testid="onpage-reload"
135
+ >
136
+ {t('dock.reload')}
137
+ </button>
138
+ )}
139
+ {(failed ?? error) && (
140
+ <button
141
+ type="button"
142
+ className="po-btn"
143
+ onClick={() => void session.retry((failed ?? error)!.key)}
144
+ data-testid="onpage-retry"
145
+ >
146
+ {t('dock.retry')}
147
+ </button>
148
+ )}
149
+ {docs.length > 1 && (
150
+ <span
151
+ className="po-docs"
152
+ title={docs.map(title).join(', ')}
153
+ data-testid="onpage-doc-count"
154
+ >
155
+ {t('dock.documents', { count: docs.length })}
156
+ </span>
157
+ )}
158
+ {session.locale && <span className="po-chip-locale">{session.locale}</span>}
159
+ <span className="po-sep" />
160
+ <button
161
+ type="button"
162
+ className="po-btn"
163
+ onClick={() => session.undo()}
164
+ 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()}
175
+ disabled={!snap.canRedo}
176
+ aria-label={t('dock.redo')}
177
+ title={`${t('dock.redo')} (⇧⌘Z)`}
178
+ data-testid="onpage-redo"
179
+ >
180
+ ↷
181
+ </button>
182
+ <span className="po-sep" />
183
+ {links.mode && (
184
+ <PostButton
185
+ action={links.mode('view')}
186
+ title={t('dock.previewHint')}
187
+ testId="onpage-preview"
188
+ >
189
+ {t('dock.preview')}
190
+ </PostButton>
191
+ )}
192
+ {createLink && primary && (
193
+ <button
194
+ type="button"
195
+ className="po-btn"
196
+ onClick={() => void share()}
197
+ title={t('dock.shareHint')}
198
+ data-testid="onpage-share"
199
+ >
200
+ {t('dock.share')}
201
+ </button>
202
+ )}
203
+ <button
204
+ type="button"
205
+ className={`po-btn${confirmDiscard ? ' is-danger' : ''}`}
206
+ disabled={!ready.length || busy}
207
+ onClick={() => void discard()}
208
+ onBlur={() => setConfirmDiscard(false)}
209
+ title={t('dock.discardHint')}
210
+ data-testid="onpage-discard"
211
+ >
212
+ {t(confirmDiscard ? 'dock.discardConfirm' : 'dock.discard')}
213
+ </button>
214
+ {links.admin && primary && (
215
+ <a
216
+ className="po-btn"
217
+ href={links.admin(primary.ref)}
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ data-testid="onpage-open-admin"
221
+ >
222
+ {t('dock.admin')}
223
+ </a>
224
+ )}
225
+ <PostButton action={links.exit} testId="onpage-exit">
226
+ {t('dock.exit')}
227
+ </PostButton>
228
+ <button
229
+ type="button"
230
+ className="po-btn po-primary"
231
+ disabled={!ready.length || busy || !!conflict}
232
+ onClick={() => void publish()}
233
+ data-testid="onpage-publish"
234
+ >
235
+ {t(busy ? 'dock.publishing' : 'dock.publish')}
236
+ </button>
237
+ </div>
238
+ </>
239
+ )
240
+ }
@@ -0,0 +1,324 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Everything drawn over the page: hover outlines, the section and item
5
+ * toolbars, "+" insert points and "+ Add" for lists. Positions are measured
6
+ * from the target elements whenever the layout changes.
7
+ */
8
+ import {
9
+ cloneRow,
10
+ fieldAtPath,
11
+ getAt,
12
+ insertItem,
13
+ isEditablePath,
14
+ moveItem,
15
+ newArrayRow,
16
+ refKey,
17
+ removeItem,
18
+ resolveRow,
19
+ updateArray,
20
+ type Doc,
21
+ type RowInfo,
22
+ type Target,
23
+ } from '@payload-onpage/core'
24
+ import { useEffect, useState } from 'react'
25
+ import { useOnPage } from '../context.tsx'
26
+ import { isOurs } from '../dom.ts'
27
+ import { useLayoutTick } from '../layout.ts'
28
+ import { targetsAt } from '../targets.ts'
29
+ import { hasSettings } from './SettingsPanel.tsx'
30
+
31
+ interface Hover {
32
+ field?: Target
33
+ rows: Target[] // innermost first
34
+ }
35
+
36
+ const VIEW_PAD = 8
37
+
38
+ function useHover(): Hover {
39
+ const { targets } = useOnPage()
40
+ const [hover, setHover] = useState<Hover>({ rows: [] })
41
+ useEffect(() => {
42
+ const onMove = (e: PointerEvent) => {
43
+ const t = e.target
44
+ if (!(t instanceof Element) || isOurs(t)) return // over our UI: keep the current hover
45
+ const next = targetsAt(targets, t)
46
+ setHover((h) =>
47
+ h.field === next.field &&
48
+ h.rows.length === next.rows.length &&
49
+ h.rows.every((r, i) => r === next.rows[i])
50
+ ? h
51
+ : next,
52
+ )
53
+ }
54
+ window.addEventListener('pointermove', onMove, { passive: true })
55
+ return () => window.removeEventListener('pointermove', onMove)
56
+ }, [targets])
57
+ return hover
58
+ }
59
+
60
+ function Outline({
61
+ el,
62
+ variant,
63
+ label,
64
+ }: {
65
+ el: Element
66
+ variant?: 'block' | 'item'
67
+ label?: string | undefined
68
+ }) {
69
+ const r = el.getBoundingClientRect()
70
+ if (r.width === 0 && r.height === 0) return null
71
+ return (
72
+ <>
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
+ />
77
+ {label && (
78
+ <div
79
+ className={`po-chip${variant === 'item' ? ' is-item' : ''}`}
80
+ style={{ top: Math.max(r.top - 2, 22), left: r.left - 2 }}
81
+ >
82
+ {label}
83
+ </div>
84
+ )}
85
+ </>
86
+ )
87
+ }
88
+
89
+ interface ActiveRow {
90
+ key: string
91
+ info: RowInfo
92
+ el: Element
93
+ }
94
+
95
+ function RowToolbar({ row, variant }: { row: ActiveRow; variant: 'block' | 'item' }) {
96
+ const { session, setSelected, setPanel, selected, targets, t } = useOnPage()
97
+ const { key, info, el } = row
98
+ 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
103
+ 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
+
108
+ const move = (dir: -1 | 1) => {
109
+ apply((d) => updateArray(d, listPath, (a) => moveItem(a, index, index + dir)))
110
+ setSelected({ key, path: `${listPath}.${index + dir}` })
111
+ }
112
+ 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}` })
117
+ }
118
+ const remove = () => {
119
+ apply((d) => updateArray(d, listPath, (a) => removeItem(a, index)))
120
+ if (selected?.key === key && selected.path.startsWith(path)) setSelected(null)
121
+ }
122
+ return (
123
+ <div
124
+ className={`po-bar${variant === 'item' ? ' is-item' : ''}`}
125
+ style={{ top, right }}
126
+ role="toolbar"
127
+ aria-label={t('row.toolbar', { label })}
128
+ data-testid={`onpage-${variant}-toolbar`}
129
+ >
130
+ <span className="po-btn po-bar-label">{label}</span>
131
+ <button
132
+ type="button"
133
+ className="po-btn"
134
+ disabled={index === 0}
135
+ 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}
146
+ 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}
157
+ 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"
168
+ 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>
175
+ )}
176
+ <button
177
+ type="button"
178
+ className="po-btn is-danger"
179
+ disabled={length <= min}
180
+ onClick={remove}
181
+ aria-label={t('row.delete')}
182
+ title={t('row.delete')}
183
+ data-testid={`onpage-${variant}-delete`}
184
+ >
185
+ ✕
186
+ </button>
187
+ </div>
188
+ )
189
+ }
190
+
191
+ function InsertPoints({ row }: { row: ActiveRow }) {
192
+ const { setPanel, t } = useOnPage()
193
+ const r = row.el.getBoundingClientRect()
194
+ const x = r.left + r.width / 2
195
+ const at = (index: number, y: number, edge: 'above' | 'below') =>
196
+ y > 0 && y < window.innerHeight ? (
197
+ <div className="po-add" style={{ top: y, left: x }} key={edge}>
198
+ <button
199
+ type="button"
200
+ className="po-btn"
201
+ onClick={() =>
202
+ setPanel({ kind: 'blocks', key: row.key, listPath: row.info.listPath, index })
203
+ }
204
+ aria-label={`${t('row.addSection')} (${edge})`}
205
+ data-testid={`onpage-insert-${edge}`}
206
+ >
207
+ + {t('row.addSection')}
208
+ </button>
209
+ </div>
210
+ ) : null
211
+ return (
212
+ <>
213
+ {at(row.info.index, r.top, 'above')}
214
+ {at(row.info.index + 1, r.bottom, 'below')}
215
+ </>
216
+ )
217
+ }
218
+
219
+ function AddItem({ list }: { list: Target }) {
220
+ const { snap, session, setPanel, setSelected, t } = useOnPage()
221
+ const key = refKey(list.ref)
222
+ const state = snap.docs[key]
223
+ if (!state?.schema || !state.doc || !isEditablePath(state.schema, list.path)) return null
224
+ 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
227
+ const r = list.el.getBoundingClientRect()
228
+ if (field.type === 'blocks') {
229
+ // Sections get "+ Add section" above/below each one; this covers an empty list.
230
+ if (length) return null
231
+ return (
232
+ <div
233
+ className="po-add"
234
+ style={{ top: r.top + Math.min(r.height / 2, 160), left: r.left + r.width / 2 }}
235
+ >
236
+ <button
237
+ type="button"
238
+ className="po-btn"
239
+ onClick={() => setPanel({ kind: 'blocks', key, listPath: list.path, index: 0 })}
240
+ data-testid="onpage-add-first-section"
241
+ >
242
+ + {t('row.addFirstSection')}
243
+ </button>
244
+ </div>
245
+ )
246
+ }
247
+ if (field.maxRows !== undefined && length >= field.maxRows) return null
248
+ // 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)
251
+ 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
+ }
256
+ return (
257
+ <div className="po-add is-item" style={{ top: y, left: r.left + r.width / 2 }}>
258
+ <button type="button" className="po-btn" onClick={add} data-testid="onpage-add-item">
259
+ + {t('row.addItem', { item })}
260
+ </button>
261
+ </div>
262
+ )
263
+ }
264
+
265
+ export function Overlay() {
266
+ const { selected, editing, targets, snap } = useOnPage()
267
+ useLayoutTick()
268
+ const hover = useHover()
269
+
270
+ const rowFor = (target: Target | undefined): ActiveRow | null => {
271
+ if (!target) return null
272
+ const key = refKey(target.ref)
273
+ const state = snap.docs[key]
274
+ const info =
275
+ state?.schema && state.doc ? resolveRow(state.schema, state.doc, target.path) : null
276
+ return info ? { key, info, el: target.el } : null
277
+ }
278
+
279
+ // The active row: the selected one, else the innermost hovered row.
280
+ const active = rowFor(
281
+ selected ? targets.find(selected.key, selected.path, ['item']) : hover.rows[0],
282
+ )
283
+
284
+ // Its section: the outermost enclosing row of a blocks field (may be itself).
285
+ let section: ActiveRow | null = null
286
+ 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('.')
291
+ const candidate =
292
+ path === active.info.path ? active : rowFor(targets.find(active.key, path, ['item']))
293
+ if (candidate?.info.isBlock) section = candidate
294
+ }
295
+ }
296
+ const item = active && section?.info.path !== active.info.path ? active : null
297
+
298
+ // "+ Add" for the active item's list, and for empty annotated lists.
299
+ const lists = targets.all.filter((t) => t.kind === 'list')
300
+ const itemList = item
301
+ ? lists.find((l) => refKey(l.ref) === item.key && l.path === item.info.listPath)
302
+ : undefined
303
+ const emptyLists = lists.filter((l) => {
304
+ const doc = snap.docs[refKey(l.ref)]?.doc
305
+ return doc && !(getAt<unknown[]>(doc, l.path) ?? []).length
306
+ })
307
+ const hoverField = !editing ? hover.field : undefined
308
+
309
+ return (
310
+ <>
311
+ {section && <Outline el={section.el} variant="block" />}
312
+ {item && <Outline el={item.el} variant="item" />}
313
+ {hoverField && <Outline el={hoverField.el} label={hoverField.label} />}
314
+ {!editing && section && <InsertPoints row={section} />}
315
+ {!editing && section && <RowToolbar row={section} variant="block" />}
316
+ {!editing && item && <RowToolbar row={item} variant="item" />}
317
+ {!editing && itemList && <AddItem list={itemList} />}
318
+ {!editing &&
319
+ emptyLists
320
+ .filter((l) => l !== itemList)
321
+ .map((l) => <AddItem key={`${refKey(l.ref)}|${l.path}`} list={l} />)}
322
+ </>
323
+ )
324
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ /** A button that POSTs to `action` and follows the response (the plugin's state-changing endpoints). */
6
+ export function PostButton({
7
+ action,
8
+ children,
9
+ className = 'po-btn',
10
+ title,
11
+ testId,
12
+ }: {
13
+ action: string
14
+ children: ReactNode
15
+ className?: string
16
+ title?: string
17
+ testId?: string
18
+ }) {
19
+ return (
20
+ <form method="post" action={action} style={{ display: 'contents' }}>
21
+ <button type="submit" className={className} title={title} data-testid={testId}>
22
+ {children}
23
+ </button>
24
+ </form>
25
+ )
26
+ }