@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
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * The parts every popover editor shares: the anchored dialog, the Apply/Clear
5
+ * bar, and a draft that previews live and commits once. Exported from
6
+ * `@payload-onpage/react/editor-kit` for custom editors.
7
+ */
8
+ import { useState, type ReactNode } from 'react'
9
+ import type { Translate } from '../messages.ts'
10
+ import type { FieldEditorProps } from '../types.ts'
11
+ import { Dialog } from '../ui/Dialog.tsx'
12
+
13
+ /** The popover a field editor opens beside its element, titled with the field's label. */
14
+ export function EditorPopover({
15
+ props,
16
+ testId,
17
+ width,
18
+ children,
19
+ }: {
20
+ props: Pick<FieldEditorProps, 'element' | 'field' | 'close' | 't'>
21
+ testId: string
22
+ width?: number
23
+ children: ReactNode
24
+ }) {
25
+ return (
26
+ <Dialog
27
+ variant="popover"
28
+ anchor={props.element}
29
+ title={props.field.label}
30
+ onClose={props.close}
31
+ t={props.t}
32
+ testId={testId}
33
+ {...(width ? { width } : {})}
34
+ >
35
+ {children}
36
+ </Dialog>
37
+ )
38
+ }
39
+
40
+ /** Apply, plus Clear when the field may be empty. */
41
+ export function ApplyBar({
42
+ onApply,
43
+ onClear,
44
+ t,
45
+ }: {
46
+ onApply: () => void
47
+ onClear?: (() => void) | undefined
48
+ t: Translate
49
+ }) {
50
+ return (
51
+ <div className="po-actions">
52
+ <button
53
+ type="button"
54
+ className="po-btn po-primary"
55
+ onClick={onApply}
56
+ data-testid="onpage-apply"
57
+ >
58
+ {t('common.apply')}
59
+ </button>
60
+ {onClear && (
61
+ <button type="button" className="po-btn" onClick={onClear} data-testid="onpage-clear">
62
+ {t('common.clear')}
63
+ </button>
64
+ )}
65
+ </div>
66
+ )
67
+ }
68
+
69
+ /**
70
+ * A text draft for a value: `parse` turns the draft into a value (undefined =
71
+ * not valid yet). Valid drafts preview live when the page renders from editor
72
+ * state; `save` commits only a changed, valid value, then closes.
73
+ */
74
+ export function useDraft<V>(
75
+ props: Pick<FieldEditorProps<never, V>, 'value' | 'commit' | 'preview' | 'close' | 'live'>,
76
+ {
77
+ format,
78
+ parse,
79
+ }: { format: (value: V) => string; parse: (draft: string) => V | null | undefined },
80
+ ) {
81
+ const [draft, setDraft] = useState(() => format(props.value))
82
+ const change = (next: string) => {
83
+ setDraft(next)
84
+ const value = parse(next)
85
+ if (props.live && value !== undefined && value !== null) props.preview(value)
86
+ }
87
+ const save = () => {
88
+ const value = parse(draft)
89
+ if (value !== undefined && value !== props.value) props.commit(value)
90
+ props.close()
91
+ }
92
+ /** Enter applies. */
93
+ const onKeyDown = (e: { key: string }) => {
94
+ if (e.key === 'Enter') save()
95
+ }
96
+ return { draft, change, save, onKeyDown, clear: () => change('') }
97
+ }
@@ -1,15 +1,22 @@
1
1
  'use client'
2
2
 
3
+ import { errorText } from '@payload-onpage/core'
3
4
  import { useEffect, useState } from 'react'
4
5
 
6
+ /** A search the hook runs: it should stop when `signal` aborts. */
7
+ export type SearchFn<T> = (
8
+ query: string,
9
+ page: number,
10
+ signal: AbortSignal,
11
+ ) => Promise<{ items: T[]; hasMore: boolean }>
12
+
5
13
  /**
6
- * Run `search(query, page)` 250ms after the inputs settle; results accumulate
7
- * across pages and reset when the query changes. Stale responses are ignored.
14
+ * Run `search(query, page, signal)` 250ms after the inputs settle; results
15
+ * accumulate across pages and reset when the query changes. A superseded search
16
+ * is aborted, and its response ignored. Re-runs when `search` changes, so keep
17
+ * it stable (`useCallback`) with its inputs as dependencies.
8
18
  */
9
- export function useDebouncedSearch<T>(
10
- search: ((query: string, page: number) => Promise<{ items: T[]; hasMore: boolean }>) | null,
11
- deps: unknown[],
12
- ) {
19
+ export function useDebouncedSearch<T>(search: SearchFn<T> | null) {
13
20
  const [query, setQuery] = useState('')
14
21
  const [page, setPage] = useState(1)
15
22
  const [items, setItems] = useState<T[]>([])
@@ -20,24 +27,25 @@ export function useDebouncedSearch<T>(
20
27
  useEffect(() => {
21
28
  if (!search) return
22
29
  let live = true
30
+ const aborter = new AbortController()
23
31
  const timer = setTimeout(() => {
24
32
  setBusy(true)
25
- search(query, page)
33
+ search(query, page, aborter.signal)
26
34
  .then((res) => {
27
35
  if (!live) return
28
36
  setItems((prev) => (page === 1 ? res.items : [...prev, ...res.items]))
29
37
  setHasMore(res.hasMore)
30
38
  setError(null)
31
39
  })
32
- .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)))
40
+ .catch((e: unknown) => live && !aborter.signal.aborted && setError(errorText(e)))
33
41
  .finally(() => live && setBusy(false))
34
42
  }, 250)
35
43
  return () => {
36
44
  live = false
37
45
  clearTimeout(timer)
46
+ aborter.abort()
38
47
  }
39
- // eslint-disable-next-line react-hooks/exhaustive-deps
40
- }, [search, query, page, ...deps])
48
+ }, [search, query, page])
41
49
 
42
50
  return {
43
51
  query,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where the editor's floating UI goes: named distances (px) and the positioning
3
+ * rules shared by the overlay, toolbars and popovers.
4
+ */
5
+ type Rect = Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right' | 'width' | 'height'>
6
+
7
+ export const GEOMETRY = {
8
+ /** Gap kept between floating UI and the viewport edge or its element. */
9
+ margin: 8,
10
+ /** How far an outline sits outside its element. */
11
+ outline: 2,
12
+ /** Height of a row toolbar. */
13
+ toolbar: 40,
14
+ /** Toolbars never ride higher than this (the site header usually lives there). */
15
+ toolbarMinTop: 60,
16
+ /** Field label chips never go above this. */
17
+ chipMinTop: 22,
18
+ /** Gap between a list and its "+ Add" button. */
19
+ addGap: 14,
20
+ /** Room left at the bottom for the dock. */
21
+ dock: 84,
22
+ /** "+ Add first section" sits at most this far down an empty list. */
23
+ emptyListOffset: 160,
24
+ } as const
25
+
26
+ const { margin, outline, toolbar, toolbarMinTop, addGap, dock } = GEOMETRY
27
+
28
+ /** Is any of the element on screen, `inset` px inside the top and bottom? */
29
+ export const inViewport = (r: Rect, inset = 0): boolean =>
30
+ r.bottom >= inset && r.top <= window.innerHeight - inset
31
+
32
+ /** An outline box around an element. */
33
+ export const outlineBox = (r: Rect) => ({
34
+ top: r.top - outline,
35
+ left: r.left - outline,
36
+ width: r.width + 2 * outline,
37
+ height: r.height + 2 * outline,
38
+ })
39
+
40
+ /** A row toolbar: top-right inside the row, kept below the header and above the row's end. */
41
+ export const toolbarPosition = (r: Rect) => ({
42
+ top: Math.min(Math.max(r.top + margin, toolbarMinTop), r.bottom - toolbar),
43
+ right: Math.max(window.innerWidth - r.right + margin, margin),
44
+ })
45
+
46
+ /** "+ Add" below a list, or pinned above the dock while the list runs past the viewport. */
47
+ export const belowList = (r: Rect): number => Math.min(r.bottom + addGap, window.innerHeight - dock)
48
+
49
+ /** A fixed popover below (or, without room, above) an element, kept inside the viewport. */
50
+ export function popoverPosition(
51
+ el: Element | null,
52
+ size = { width: 300, height: 320 },
53
+ ): { top: number; left: number } {
54
+ const vw = window.innerWidth
55
+ const vh = window.innerHeight
56
+ if (!el) return { top: 80, left: Math.max(margin, vw / 2 - size.width / 2) }
57
+ const r = el.getBoundingClientRect()
58
+ const below = r.bottom + margin
59
+ const top = below + size.height < vh ? below : Math.max(margin, r.top - margin - size.height)
60
+ const left = Math.min(Math.max(margin, r.left), vw - size.width - margin)
61
+ return { top, left }
62
+ }
package/src/hub.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * The editor hub: a module-level store holding the mounted editor, so
2
+ * The editor hub: a module-level store holding the mounted editor's API, so
3
3
  * components anywhere on the page (client-mode documents, custom UI) can reach
4
4
  * it without being rendered inside it. That keeps `<OnPage />` a sibling in the
5
5
  * layout, not a wrapper. Kept on `globalThis` so two copies of the package
6
6
  * (a bundling mistake) still share one editor.
7
7
  */
8
- import type { OnPageContextValue } from './types.ts'
8
+ import type { OnPageApi } from './types.ts'
9
9
 
10
10
  interface Hub {
11
- current: OnPageContextValue | null
11
+ current: OnPageApi | null
12
12
  listeners: Set<() => void>
13
13
  }
14
14
 
@@ -19,8 +19,8 @@ const store: Hub = ((globalThis as Record<symbol, Hub | undefined>)[KEY] ??= {
19
19
  })
20
20
 
21
21
  export const hub = {
22
- get: (): OnPageContextValue | null => store.current,
23
- set: (value: OnPageContextValue | null): void => {
22
+ get: (): OnPageApi | null => store.current,
23
+ set: (value: OnPageApi | null): void => {
24
24
  if (value === store.current) return
25
25
  store.current = value
26
26
  for (const fn of store.listeners) fn()
package/src/index.ts CHANGED
@@ -14,13 +14,16 @@ export {
14
14
  FRAME_PARAM,
15
15
  type PreviewToolbarProps,
16
16
  } from './ui/PreviewToolbar.tsx'
17
- export { useOnPage, useOnPageOptional } from './context.tsx'
17
+ export { useOnPage, useOnPageApi, useOnPageOptional, useSessionSnapshot } from './context.tsx'
18
18
  export type {
19
19
  EditorRegistry,
20
20
  FieldEditorDef,
21
21
  FieldEditorProps,
22
22
  Links,
23
+ OnPageApi,
23
24
  OnPageContextValue,
25
+ OnPageState,
26
+ PanelKind,
24
27
  TargetIndex,
25
28
  } from './types.ts'
26
29
  export {
@@ -35,7 +38,7 @@ export {
35
38
  UploadEditor,
36
39
  } from './editors/index.ts'
37
40
  export { resolveEditor, mergeEditors } from './registry.ts'
38
- export { annotations, stega, composite, rowPrefixes } from './locators.ts'
41
+ export { annotations, stega, composite } from './locators.ts'
39
42
  export {
40
43
  editable,
41
44
  editableOff,
package/src/layout.ts CHANGED
@@ -71,8 +71,11 @@ export function useTrackElement(getElement: () => Element | null, fn: (el: Eleme
71
71
  run()
72
72
  const off = onLayoutChange(run)
73
73
  const el = getElement()
74
- const ro = el ? new ResizeObserver(() => run()) : undefined
75
- if (el) ro!.observe(el)
74
+ let ro: ResizeObserver | undefined
75
+ if (el) {
76
+ ro = new ResizeObserver(() => run())
77
+ ro.observe(el)
78
+ }
76
79
  return () => {
77
80
  off()
78
81
  ro?.disconnect()
package/src/locators.ts CHANGED
@@ -8,7 +8,10 @@
8
8
  * composite() merge several; earlier locators win for the same element+path
9
9
  */
10
10
  import {
11
+ hasStegaMarker,
12
+ isTargetKind,
11
13
  parseRefKey,
14
+ rowPrefixes,
12
15
  targetKey,
13
16
  stegaDecode,
14
17
  type DocRef,
@@ -29,12 +32,14 @@ export function annotations(): Locator {
29
32
  if (!ref) return
30
33
  const label = el.getAttribute(ATTR.label) ?? undefined
31
34
  const field = el.getAttribute(ATTR.field)
35
+ const kind = el.getAttribute(ATTR.kind)
32
36
  if (field)
33
37
  out.push({
34
38
  el,
35
39
  ref,
36
40
  path: field,
37
- kind: (el.getAttribute(ATTR.kind) as TargetKind) || 'field',
41
+ // Page markup is input: an unknown kind is treated as a plain field.
42
+ kind: isTargetKind(kind) ? kind : 'field',
38
43
  label,
39
44
  source: 'annotation',
40
45
  })
@@ -53,7 +58,7 @@ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TEXTAREA'
53
58
  /** Lowest common ancestor of a set of elements. */
54
59
  function commonAncestor(els: Element[]): Element | null {
55
60
  if (!els.length) return null
56
- let anc: Element | null = els[0]
61
+ let anc: Element | null = els[0] ?? null
57
62
  for (const el of els.slice(1)) {
58
63
  while (anc && !anc.contains(el)) anc = anc.parentElement
59
64
  if (!anc) return null
@@ -61,15 +66,6 @@ function commonAncestor(els: Element[]): Element | null {
61
66
  return anc
62
67
  }
63
68
 
64
- /** "layout.2.items.0.title" → ["layout.2", "layout.2.items.0"] (row paths, outermost first). */
65
- export function rowPrefixes(path: string): string[] {
66
- const segs = path.split('.')
67
- const out: string[] = []
68
- for (let i = 1; i < segs.length; i++)
69
- if (/^\d+$/.test(segs[i]) && i < segs.length - 1) out.push(segs.slice(0, i + 1).join('.'))
70
- return out
71
- }
72
-
73
69
  export function stega(opts: { inferRows?: boolean } = {}): Locator {
74
70
  const inferRows = opts.inferRows ?? true
75
71
  return {
@@ -92,7 +88,7 @@ export function stega(opts: { inferRows?: boolean } = {}): Locator {
92
88
  const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */)
93
89
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
94
90
  const text = n.nodeValue
95
- if (!text || !text.includes('\u2063\u2062')) continue
91
+ if (!text || !hasStegaMarker(text)) continue
96
92
  const parent = n.parentElement
97
93
  if (!parent || SKIP_TAGS.has(parent.tagName) || parent.closest(`[${UI}]`)) continue
98
94
  for (const m of stegaDecode(text)) {
@@ -121,8 +117,9 @@ export function stega(opts: { inferRows?: boolean } = {}): Locator {
121
117
  rich.set(k, [...(rich.get(k) ?? []), t])
122
118
  }
123
119
  for (const group of rich.values()) {
120
+ const [first] = group
124
121
  const el = commonAncestor(group.map((t) => t.el))
125
- if (el) out.push({ ...group[0], el })
122
+ if (first && el) out.push({ ...first, el })
126
123
  }
127
124
 
128
125
  if (!inferRows) return out
@@ -170,7 +167,7 @@ export function stega(opts: { inferRows?: boolean } = {}): Locator {
170
167
  byList.set(k, entry)
171
168
  }
172
169
  for (const { ref, path, els } of byList.values()) {
173
- const anc = els.length > 1 ? commonAncestor(els) : els[0].parentElement
170
+ const anc = els.length > 1 ? commonAncestor(els) : (els[0]?.parentElement ?? null)
174
171
  if (anc && anc !== root) rows.push({ el: anc, ref, path, kind: 'list', source: 'stega' })
175
172
  }
176
173
  return [...out, ...rows]
package/src/messages.ts CHANGED
@@ -7,6 +7,7 @@ export const defaultMessages = {
7
7
  'dock.label': 'Page editor',
8
8
  'dock.loading': 'Loading editor…',
9
9
  'dock.unavailable': '{doc}: {error}',
10
+ 'dock.saveFailed': '{doc}: {message}',
10
11
  'dock.nothing': 'Nothing editable on this page',
11
12
  'dock.conflict': '{doc}: changed by someone else',
12
13
  'dock.saving': 'Saving draft…',
@@ -45,6 +46,8 @@ export const defaultMessages = {
45
46
  'row.settings': 'Settings',
46
47
  'row.delete': 'Delete (undo with ⌘Z)',
47
48
  'row.addSection': 'Add section',
49
+ 'row.addSectionAbove': 'Add section above',
50
+ 'row.addSectionBelow': 'Add section below',
48
51
  'row.addFirstSection': 'Add a section',
49
52
  'row.addItem': 'Add {item}',
50
53
  'row.toolbar': '{label} actions',
@@ -57,6 +60,7 @@ export const defaultMessages = {
57
60
  'common.apply': 'Apply',
58
61
  'common.clear': 'Clear',
59
62
  'common.remove': 'Remove',
63
+ 'common.removeItem': 'Remove {item}',
60
64
  'common.none': '—',
61
65
  'common.selected': '{count} selected',
62
66
  'search.placeholder': 'Search…',
@@ -82,6 +86,7 @@ export const defaultMessages = {
82
86
  'rt.italic': 'Italic',
83
87
  'rt.underline': 'Underline',
84
88
  'rt.link': 'Link',
89
+ 'rt.linkApply': 'Apply',
85
90
  'rt.h2': 'Heading 2',
86
91
  'rt.h3': 'Heading 3',
87
92
  'rt.ul': 'Bulleted list',
@@ -94,15 +99,31 @@ export const defaultMessages = {
94
99
 
95
100
  export type MessageKey = keyof typeof defaultMessages
96
101
  export type Messages = Partial<Record<MessageKey, string>>
97
- /** Translate a message key (unknown keys are returned as-is, so plain text works too). */
98
- export type Translate = (
99
- key: MessageKey | (string & {}),
100
- vars?: Record<string, string | number>,
102
+
103
+ /** The {placeholders} of a message: `Placeholders<'Saved {when}'>` is `'when'`. */
104
+ export type Placeholders<S extends string> = S extends `${string}{${infer Name}}${infer Rest}`
105
+ ? Name | Placeholders<Rest>
106
+ : never
107
+
108
+ /** The variables a message needs, by key. */
109
+ export type MessageVars<K extends MessageKey> = Record<
110
+ Placeholders<(typeof defaultMessages)[K]>,
111
+ string | number
112
+ >
113
+
114
+ /**
115
+ * Translate a message key. A message with placeholders requires exactly those
116
+ * variables (`t('dock.saved', { ago })`); one without takes none. Unknown keys
117
+ * don't compile: show raw text without `t`.
118
+ */
119
+ export type Translate = <K extends MessageKey>(
120
+ key: K,
121
+ ...vars: [Placeholders<(typeof defaultMessages)[K]>] extends [never] ? [] : [vars: MessageVars<K>]
101
122
  ) => string
102
123
 
103
124
  export function createTranslate(overrides: Messages = {}): Translate {
104
125
  const table: Record<string, string> = { ...defaultMessages, ...overrides }
105
- return (key, vars) => {
126
+ return (key: string, vars?: Record<string, string | number>) => {
106
127
  const template = table[key] ?? key
107
128
  return vars
108
129
  ? template.replace(/\{(\w+)\}/g, (m, name: string) => (name in vars ? String(vars[name]) : m))
package/src/registry.ts CHANGED
@@ -7,24 +7,37 @@
7
7
  * No match: the field isn't edited in place (it may still be in the row's
8
8
  * settings panel).
9
9
  */
10
- import type { SchemaField, TargetKind } from '@payload-onpage/core'
11
- import type { EditorRegistry } from './types.ts'
10
+ import type { FieldOf, SchemaField, SchemaFieldType, TargetKind } from '@payload-onpage/core'
11
+ import type { EditorRegistry, FieldEditorDef, FieldEditorProps } from './types.ts'
12
+ import type { ComponentType } from 'react'
12
13
 
13
14
  const KIND_EDITOR: Partial<Record<TargetKind, string>> = { richtext: 'richText', media: 'upload' }
14
- const TEXTUAL = new Set(['text', 'textarea', 'email'])
15
+ /** Field types a "text" marker or annotation keeps as their own editor (email stays email). */
16
+ const TEXT_TYPES: ReadonlySet<SchemaFieldType> = new Set(['text', 'textarea', 'email'])
17
+
18
+ /** Can this editor take this field? Its declared kinds, and never a list of values in place. */
19
+ export function canEdit(
20
+ def: FieldEditorDef | undefined,
21
+ field: SchemaField,
22
+ ): def is FieldEditorDef {
23
+ if (!def) return false
24
+ if (def.accepts && !def.accepts.includes(field.type)) return false
25
+ return !(def.mode === 'inline' && 'hasMany' in field && field.hasMany)
26
+ }
15
27
 
16
28
  export function resolveEditor(
17
29
  editors: EditorRegistry,
18
30
  field: SchemaField,
19
31
  kind?: TargetKind,
20
32
  ): string | null {
21
- if (field.editor && editors[field.editor]) return field.editor
22
- // A marker/annotation saying "text" keeps the field's own textual type (email stays email).
23
- if ((kind === 'text' || kind === 'textarea') && TEXTUAL.has(field.type) && editors[field.type])
33
+ const ok = (id: string | undefined): id is string => !!id && canEdit(editors[id], field)
34
+ if (ok(field.editor)) return field.editor
35
+ if ((kind === 'text' || kind === 'textarea') && TEXT_TYPES.has(field.type) && ok(field.type))
24
36
  return field.type
25
37
  const byKind = kind ? KIND_EDITOR[kind] : undefined
26
- if (byKind && editors[byKind] && (byKind !== 'upload' || field.type === 'upload')) return byKind
27
- return editors[field.type] ? field.type : null
38
+ // A "media" marker means the image picker only for upload fields (an <img alt> can show a text field).
39
+ if (ok(byKind) && (byKind !== 'upload' || field.type === 'upload')) return byKind
40
+ return ok(field.type) ? field.type : null
28
41
  }
29
42
 
30
43
  /** Merge registries left to right; `null` removes a default. */
@@ -41,3 +54,24 @@ export function mergeEditors(
41
54
  }
42
55
  return out
43
56
  }
57
+
58
+ /**
59
+ * Define a field editor typed by the field kinds it accepts:
60
+ *
61
+ * defineEditor({
62
+ * mode: 'popover',
63
+ * accepts: ['number'],
64
+ * normalize: (raw) => (typeof raw === 'number' ? raw : null),
65
+ * component: ({ field, value }) => … // field: NumberField, value: number | null
66
+ * })
67
+ */
68
+ export function defineEditor<T extends SchemaFieldType, V = unknown>(def: {
69
+ mode: FieldEditorDef['mode']
70
+ accepts: readonly T[]
71
+ normalize?: (raw: unknown, field: FieldOf<T>) => V
72
+ component: ComponentType<FieldEditorProps<FieldOf<T>, V>>
73
+ }): FieldEditorDef {
74
+ // Widened once, here: resolveEditor only hands it fields whose type is in `accepts`,
75
+ // and FieldHost runs `normalize` before rendering.
76
+ return def as unknown as FieldEditorDef
77
+ }
package/src/rowOps.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Row operations on array and blocks fields, with one set of rules (bounds,
3
+ * `minRows`, `maxRows`) for the toolbar, the keyboard shortcuts, the block
4
+ * picker and "+ Add". Each returns where the selection should go, or null when
5
+ * the operation isn't allowed (nothing changes).
6
+ */
7
+ import {
8
+ cloneRow,
9
+ insertItem,
10
+ moveItem,
11
+ removeItem,
12
+ updateArray,
13
+ type EditorSession,
14
+ type RowInfo,
15
+ type RefKey,
16
+ } from '@payload-onpage/core'
17
+ import type { Selection } from './types.ts'
18
+
19
+ const rowAt = (key: RefKey, listPath: string, index: number): Selection => ({
20
+ key,
21
+ path: `${listPath}.${index}`,
22
+ })
23
+
24
+ /** Insert a row into the list at `listPath`; returns the new row's selection. */
25
+ export function insertRow(
26
+ session: EditorSession,
27
+ key: RefKey,
28
+ listPath: string,
29
+ index: number,
30
+ row: unknown,
31
+ ): Selection {
32
+ session.apply(key, (d) => updateArray(d, listPath, (a) => insertItem(a, index, row)))
33
+ return rowAt(key, listPath, index)
34
+ }
35
+
36
+ export function rowOps(session: EditorSession, key: RefKey, info: RowInfo) {
37
+ const { listPath, index, length, rowFields, listField } = info
38
+ const apply = (fn: (rows: unknown[]) => unknown[]) =>
39
+ session.apply(key, (d) => updateArray(d, listPath, fn))
40
+ const canMove = (dir: -1 | 1) => index + dir >= 0 && index + dir < length
41
+ const canDuplicate = listField.maxRows === undefined || length < listField.maxRows
42
+ const canRemove = length > (listField.minRows ?? 0)
43
+ return {
44
+ canMove,
45
+ canDuplicate,
46
+ canRemove,
47
+ move(dir: -1 | 1): Selection | null {
48
+ if (!canMove(dir)) return null
49
+ apply((a) => moveItem(a, index, index + dir))
50
+ return rowAt(key, listPath, index + dir)
51
+ },
52
+ duplicate(): Selection | null {
53
+ if (!canDuplicate) return null
54
+ apply((a) => insertItem(a, index + 1, cloneRow(a[index], rowFields)))
55
+ return rowAt(key, listPath, index + 1)
56
+ },
57
+ /** Removes the row; true when it did. */
58
+ remove(): boolean {
59
+ if (!canRemove) return false
60
+ apply((a) => removeItem(a, index))
61
+ return true
62
+ },
63
+ }
64
+ }
package/src/sanitize.ts CHANGED
@@ -50,3 +50,9 @@ export function sanitizeHtml(html: string): string {
50
50
  walk(tpl.content)
51
51
  return tpl.innerHTML
52
52
  }
53
+
54
+ /** Text made safe to put in HTML. */
55
+ export const escapeHtml = (s: string): string =>
56
+ s.replace(/[&<>"]/g, (c) => ESCAPES[c as keyof typeof ESCAPES])
57
+
58
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' } as const
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Which fields of a row appear in its settings panel: values that aren't edited
3
+ * on the page itself (layout selects, toggles, numbers, links, images that
4
+ * aren't shown…). Fields shown on the page are edited there instead.
5
+ */
6
+ import type { SchemaField, SchemaFieldType, RefKey } from '@payload-onpage/core'
7
+ import type { TargetIndex } from './types.ts'
8
+
9
+ /** Row bookkeeping, never a setting. */
10
+ const SKIP = new Set(['id', 'blockType', 'blockName'])
11
+
12
+ /** Types shown in the panel when the page doesn't show them (choices and toggles always are). */
13
+ const PANEL_FIELD_TYPES: ReadonlySet<SchemaFieldType> = new Set([
14
+ 'text',
15
+ 'textarea',
16
+ 'email',
17
+ 'number',
18
+ 'date',
19
+ 'relationship',
20
+ 'upload',
21
+ ])
22
+
23
+ export function settable(
24
+ field: SchemaField,
25
+ path: string,
26
+ key: RefKey,
27
+ targets: TargetIndex,
28
+ ): boolean {
29
+ if (SKIP.has(field.name)) return false
30
+ const full = `${path}.${field.name}`
31
+ switch (field.type) {
32
+ case 'select':
33
+ case 'radio':
34
+ case 'checkbox':
35
+ return true
36
+ case 'group':
37
+ return field.fields.some((f) => settable(f, full, key, targets))
38
+ default:
39
+ // Shown on the page? Edit it there.
40
+ return PANEL_FIELD_TYPES.has(field.type) && !targets.find(key, full)
41
+ }
42
+ }
43
+
44
+ export const hasSettings = (
45
+ fields: SchemaField[],
46
+ path: string,
47
+ key: RefKey,
48
+ targets: TargetIndex,
49
+ ): boolean => fields.some((f) => settable(f, path, key, targets))