@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
@@ -1,20 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { toText } from '@payload-onpage/core'
3
+ import { asList, type ChoiceField } from '@payload-onpage/core'
4
4
  import type { FieldEditorProps } from '../types.ts'
5
- import { Dialog } from '../ui/Dialog.tsx'
5
+ import { EditorPopover } from './kit.tsx'
6
+
7
+ /** A select/radio value as the selected option values. */
8
+ export const selectedValues = (raw: unknown, field: ChoiceField): string[] =>
9
+ asList(raw, field.hasMany, (v) =>
10
+ typeof v === 'string' || typeof v === 'number' ? String(v) : undefined,
11
+ )
6
12
 
7
13
  /** Select / radio: pick one (closes) or toggle several (hasMany). */
8
- export function SelectEditor({ field, value, element, commit, close, t }: FieldEditorProps) {
9
- const options = field.options ?? []
14
+ export function SelectEditor(props: FieldEditorProps<ChoiceField, string[]>) {
15
+ const { field, value: current, commit, close, t } = props
10
16
  const many = !!field.hasMany
11
- const current: string[] = many
12
- ? Array.isArray(value)
13
- ? (value as string[])
14
- : []
15
- : value == null
16
- ? []
17
- : [toText(value)]
18
17
  const pick = (v: string | null) => {
19
18
  if (!many) {
20
19
  commit(v)
@@ -25,17 +24,10 @@ export function SelectEditor({ field, value, element, commit, close, t }: FieldE
25
24
  if (v === null) set.clear()
26
25
  else if (set.has(v)) set.delete(v)
27
26
  else set.add(v)
28
- commit(options.map((o) => o.value).filter((o) => set.has(o)))
27
+ commit(field.options.map((o) => o.value).filter((o) => set.has(o)))
29
28
  }
30
29
  return (
31
- <Dialog
32
- variant="popover"
33
- anchor={element}
34
- title={field.label}
35
- onClose={close}
36
- t={t}
37
- testId="onpage-select"
38
- >
30
+ <EditorPopover props={props} testId="onpage-select">
39
31
  <div
40
32
  className="po-pop-list"
41
33
  role="listbox"
@@ -52,7 +44,7 @@ export function SelectEditor({ field, value, element, commit, close, t }: FieldE
52
44
  {t('common.none')}
53
45
  </button>
54
46
  )}
55
- {options.map((o) => {
47
+ {field.options.map((o) => {
56
48
  const selected = current.includes(o.value)
57
49
  return (
58
50
  <button
@@ -69,6 +61,6 @@ export function SelectEditor({ field, value, element, commit, close, t }: FieldE
69
61
  )
70
62
  })}
71
63
  </div>
72
- </Dialog>
64
+ </EditorPopover>
73
65
  )
74
66
  }
@@ -5,14 +5,14 @@
5
5
  * new file, through the MediaLibrary port. The chosen document goes into state
6
6
  * populated, so a client-rendered page shows it at once; saving sends its id.
7
7
  */
8
- import { relationId, type MediaItem } from '@payload-onpage/core'
8
+ import { errorText, relationId, type MediaItem, type RelationField } from '@payload-onpage/core'
9
9
  import { useCallback, useRef, useState } from 'react'
10
10
  import { useOnPage } from '../context.tsx'
11
11
  import type { FieldEditorProps } from '../types.ts'
12
12
  import { Dialog } from '../ui/Dialog.tsx'
13
13
  import { useDebouncedSearch } from './useDebouncedSearch.ts'
14
14
 
15
- export function UploadEditor({ field, value, commit, close, t }: FieldEditorProps) {
15
+ export function UploadEditor({ field, value, commit, close, t }: FieldEditorProps<RelationField>) {
16
16
  const { media } = useOnPage()
17
17
  const relationTo =
18
18
  (Array.isArray(field.relationTo) ? field.relationTo[0] : field.relationTo) ?? 'media'
@@ -20,13 +20,13 @@ export function UploadEditor({ field, value, commit, close, t }: FieldEditorProp
20
20
  const [uploadError, setUploadError] = useState<string | null>(null)
21
21
  const fileRef = useRef<HTMLInputElement>(null)
22
22
  const search = useCallback(
23
- (query: string, page: number) =>
23
+ (query: string, page: number, signal: AbortSignal) =>
24
24
  media
25
- ? media.search({ query, page, relationTo })
25
+ ? media.search({ query, page, relationTo, signal })
26
26
  : Promise.resolve({ items: [] as MediaItem[], hasMore: false }),
27
27
  [media, relationTo],
28
28
  )
29
- const results = useDebouncedSearch<MediaItem>(media ? search : null, [])
29
+ const results = useDebouncedSearch<MediaItem>(media ? search : null)
30
30
  const currentId = relationId(value)
31
31
 
32
32
  const choose = (m: MediaItem) => {
@@ -39,7 +39,7 @@ export function UploadEditor({ field, value, commit, close, t }: FieldEditorProp
39
39
  try {
40
40
  choose(await media.upload(file, { relationTo }))
41
41
  } catch (e) {
42
- setUploadError((e as Error).message)
42
+ setUploadError(errorText(e))
43
43
  } finally {
44
44
  setUploading(false)
45
45
  }
@@ -1,26 +1,66 @@
1
1
  /** The default field editors. Spread, override or remove (`null`) entries via the `editors` option. */
2
+ import { toText } from '@payload-onpage/core'
3
+ import { defineEditor } from '../registry.ts'
2
4
  import type { EditorRegistry } from '../types.ts'
3
- import { CheckboxEditor } from './CheckboxEditor.tsx'
4
- import { ColorEditor } from './ColorEditor.tsx'
5
- import { DateEditor } from './DateEditor.tsx'
5
+ import { checkedOf, CheckboxEditor } from './CheckboxEditor.tsx'
6
+ import { ColorEditor, toColor } from './ColorEditor.tsx'
7
+ import { DateEditor, toIsoDate } from './DateEditor.tsx'
6
8
  import { InlineText } from './InlineText.tsx'
7
- import { NumberEditor } from './NumberEditor.tsx'
9
+ import { NumberEditor, toNumber } from './NumberEditor.tsx'
8
10
  import { RelationshipEditor } from './RelationshipEditor.tsx'
9
- import { SelectEditor } from './SelectEditor.tsx'
11
+ import { SelectEditor, selectedValues } from './SelectEditor.tsx'
10
12
  import { UploadEditor } from './UploadEditor.tsx'
11
13
 
14
+ const text = defineEditor({
15
+ mode: 'inline',
16
+ accepts: ['text', 'textarea', 'email'],
17
+ normalize: (raw) => toText(raw),
18
+ component: InlineText,
19
+ })
20
+ const select = defineEditor({
21
+ mode: 'popover',
22
+ accepts: ['select', 'radio'],
23
+ normalize: selectedValues,
24
+ component: SelectEditor,
25
+ })
26
+
12
27
  export const defaultEditors: EditorRegistry = {
13
- text: { mode: 'inline', component: InlineText },
14
- textarea: { mode: 'inline', component: InlineText },
15
- email: { mode: 'inline', component: InlineText },
16
- select: { mode: 'popover', component: SelectEditor },
17
- radio: { mode: 'popover', component: SelectEditor },
18
- checkbox: { mode: 'popover', component: CheckboxEditor },
19
- number: { mode: 'popover', component: NumberEditor },
20
- date: { mode: 'popover', component: DateEditor },
21
- relationship: { mode: 'popover', component: RelationshipEditor },
22
- upload: { mode: 'popover', component: UploadEditor },
23
- color: { mode: 'popover', component: ColorEditor },
28
+ text,
29
+ textarea: text,
30
+ email: text,
31
+ select,
32
+ radio: select,
33
+ checkbox: defineEditor({
34
+ mode: 'popover',
35
+ accepts: ['checkbox'],
36
+ normalize: checkedOf,
37
+ component: CheckboxEditor,
38
+ }),
39
+ number: defineEditor({
40
+ mode: 'popover',
41
+ accepts: ['number'],
42
+ normalize: toNumber,
43
+ component: NumberEditor,
44
+ }),
45
+ date: defineEditor({
46
+ mode: 'popover',
47
+ accepts: ['date'],
48
+ normalize: toIsoDate,
49
+ component: DateEditor,
50
+ }),
51
+ relationship: defineEditor({
52
+ mode: 'popover',
53
+ accepts: ['relationship'],
54
+ component: RelationshipEditor,
55
+ }),
56
+ upload: defineEditor({ mode: 'popover', accepts: ['upload'], component: UploadEditor }),
57
+ // A text field holding a hex color: `admin.custom.onpage.editor: 'color'`.
58
+ color: defineEditor({
59
+ mode: 'popover',
60
+ accepts: ['text'],
61
+ normalize: toColor,
62
+ component: ColorEditor,
63
+ }),
24
64
  }
25
65
 
26
66
  export {
@@ -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))