@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,54 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ /**
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.
8
+ */
9
+ export function useDebouncedSearch<T>(
10
+ search: ((query: string, page: number) => Promise<{ items: T[]; hasMore: boolean }>) | null,
11
+ deps: unknown[],
12
+ ) {
13
+ const [query, setQuery] = useState('')
14
+ const [page, setPage] = useState(1)
15
+ const [items, setItems] = useState<T[]>([])
16
+ const [hasMore, setHasMore] = useState(false)
17
+ const [busy, setBusy] = useState(false)
18
+ const [error, setError] = useState<string | null>(null)
19
+
20
+ useEffect(() => {
21
+ if (!search) return
22
+ let live = true
23
+ const timer = setTimeout(() => {
24
+ setBusy(true)
25
+ search(query, page)
26
+ .then((res) => {
27
+ if (!live) return
28
+ setItems((prev) => (page === 1 ? res.items : [...prev, ...res.items]))
29
+ setHasMore(res.hasMore)
30
+ setError(null)
31
+ })
32
+ .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)))
33
+ .finally(() => live && setBusy(false))
34
+ }, 250)
35
+ return () => {
36
+ live = false
37
+ clearTimeout(timer)
38
+ }
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, [search, query, page, ...deps])
41
+
42
+ return {
43
+ query,
44
+ setQuery: (q: string) => {
45
+ setQuery(q)
46
+ setPage(1)
47
+ },
48
+ items,
49
+ hasMore,
50
+ more: () => setPage((p) => p + 1),
51
+ busy,
52
+ error,
53
+ }
54
+ }
package/src/hub.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The editor hub: a module-level store holding the mounted editor, so
3
+ * components anywhere on the page (client-mode documents, custom UI) can reach
4
+ * it without being rendered inside it. That keeps `<OnPage />` a sibling in the
5
+ * layout, not a wrapper. Kept on `globalThis` so two copies of the package
6
+ * (a bundling mistake) still share one editor.
7
+ */
8
+ import type { OnPageContextValue } from './types.ts'
9
+
10
+ interface Hub {
11
+ current: OnPageContextValue | null
12
+ listeners: Set<() => void>
13
+ }
14
+
15
+ const KEY = Symbol.for('payload-onpage.hub')
16
+ const store: Hub = ((globalThis as Record<symbol, Hub | undefined>)[KEY] ??= {
17
+ current: null,
18
+ listeners: new Set(),
19
+ })
20
+
21
+ export const hub = {
22
+ get: (): OnPageContextValue | null => store.current,
23
+ set: (value: OnPageContextValue | null): void => {
24
+ if (value === store.current) return
25
+ store.current = value
26
+ for (const fn of store.listeners) fn()
27
+ },
28
+ subscribe: (fn: () => void): (() => void) => {
29
+ store.listeners.add(fn)
30
+ return () => store.listeners.delete(fn)
31
+ },
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @payload-onpage/react — the on-page editor UI.
3
+ *
4
+ * <OnPageEditor dataSource={payloadRest()} media={payloadUploads()} links={{ exit: '/api/onpage/exit' }} />
5
+ *
6
+ * Framework adapters (e.g. @payload-onpage/next) mount it for you. Building a
7
+ * field editor? See `@payload-onpage/react/editor-kit`.
8
+ */
9
+ export { OnPageEditor, type OnPageEditorProps } from './editor/OnPageEditor.tsx'
10
+ export { OnPageClient, type OnPageClientProps } from './OnPageClient.tsx'
11
+ export {
12
+ PreviewToolbar,
13
+ inPreviewFrame,
14
+ FRAME_PARAM,
15
+ type PreviewToolbarProps,
16
+ } from './ui/PreviewToolbar.tsx'
17
+ export { useOnPage, useOnPageOptional } from './context.tsx'
18
+ export type {
19
+ EditorRegistry,
20
+ FieldEditorDef,
21
+ FieldEditorProps,
22
+ Links,
23
+ OnPageContextValue,
24
+ TargetIndex,
25
+ } from './types.ts'
26
+ export {
27
+ defaultEditors,
28
+ CheckboxEditor,
29
+ ColorEditor,
30
+ DateEditor,
31
+ InlineText,
32
+ NumberEditor,
33
+ RelationshipEditor,
34
+ SelectEditor,
35
+ UploadEditor,
36
+ } from './editors/index.ts'
37
+ export { resolveEditor, mergeEditors } from './registry.ts'
38
+ export { annotations, stega, composite, rowPrefixes } from './locators.ts'
39
+ export {
40
+ editable,
41
+ editableOff,
42
+ p,
43
+ ATTR,
44
+ type Editable,
45
+ type EditableTarget,
46
+ type FieldKind,
47
+ } from './annotations.ts'
48
+ export { clientState, fullReload } from './renderers.ts'
49
+ export {
50
+ defaultMessages,
51
+ createTranslate,
52
+ type Messages,
53
+ type MessageKey,
54
+ type Translate,
55
+ } from './messages.ts'
package/src/layout.ts ADDED
@@ -0,0 +1,83 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Layout change notifications for everything drawn over the page. Event
5
+ * driven (capture-phase scroll, resize, ResizeObserver on the document and on
6
+ * tracked elements), batched to one callback per animation frame: no polling.
7
+ */
8
+ import { useEvent } from './useEvent.ts'
9
+ import { useEffect, useState } from 'react'
10
+
11
+ const listeners = new Set<() => void>()
12
+ let frame = 0
13
+ let observer: ResizeObserver | undefined
14
+
15
+ function emit() {
16
+ if (frame) return
17
+ frame = requestAnimationFrame(() => {
18
+ frame = 0
19
+ for (const fn of listeners) fn()
20
+ })
21
+ }
22
+
23
+ function install() {
24
+ window.addEventListener('scroll', emit, { capture: true, passive: true })
25
+ window.addEventListener('resize', emit, { passive: true })
26
+ observer = new ResizeObserver(emit)
27
+ observer.observe(document.documentElement)
28
+ }
29
+
30
+ function uninstall() {
31
+ window.removeEventListener('scroll', emit, { capture: true })
32
+ window.removeEventListener('resize', emit)
33
+ observer?.disconnect()
34
+ observer = undefined
35
+ if (frame) cancelAnimationFrame(frame)
36
+ frame = 0
37
+ }
38
+
39
+ /** Run `fn` (batched per frame) whenever the page may have moved. Returns an unsubscribe. */
40
+ export function onLayoutChange(fn: () => void): () => void {
41
+ if (!listeners.size) install()
42
+ listeners.add(fn)
43
+ return () => {
44
+ listeners.delete(fn)
45
+ if (!listeners.size) uninstall()
46
+ }
47
+ }
48
+
49
+ /** Tell overlays to re-measure (after the editor changed the page itself). */
50
+ export const notifyLayout = (): void => {
51
+ if (listeners.size) emit()
52
+ }
53
+
54
+ /** Re-render on layout changes. */
55
+ export function useLayoutTick(): number {
56
+ const [tick, setTick] = useState(0)
57
+ useEffect(() => onLayoutChange(() => setTick((t) => t + 1)), [])
58
+ return tick
59
+ }
60
+
61
+ /**
62
+ * Call `fn(el)` now and whenever `el` may have moved or resized (page layout
63
+ * changes, or the element itself resizing, e.g. live re-render while typing).
64
+ */
65
+ export function useTrackElement(getElement: () => Element | null, fn: (el: Element) => void): void {
66
+ const run = useEvent(() => {
67
+ const el = getElement()
68
+ if (el) fn(el)
69
+ })
70
+ useEffect(() => {
71
+ run()
72
+ const off = onLayoutChange(run)
73
+ const el = getElement()
74
+ const ro = el ? new ResizeObserver(() => run()) : undefined
75
+ if (el) ro!.observe(el)
76
+ return () => {
77
+ off()
78
+ ro?.disconnect()
79
+ }
80
+ // getElement identity may change per render; tracking follows the element via run().
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [])
83
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Locators — the Locator port's default adapters. Each scans the DOM and
3
+ * returns targets: { el, ref, path, kind }.
4
+ *
5
+ * annotations() data-onpage-* attributes (precise, opt-in)
6
+ * stega() invisible markers in text and <img alt> (automatic), plus
7
+ * inferred rows and lists for array/blocks content
8
+ * composite() merge several; earlier locators win for the same element+path
9
+ */
10
+ import {
11
+ parseRefKey,
12
+ targetKey,
13
+ stegaDecode,
14
+ type DocRef,
15
+ type Locator,
16
+ type Target,
17
+ type TargetKind,
18
+ } from '@payload-onpage/core'
19
+ import { ATTR } from './annotations.ts'
20
+ import { UI } from './dom.ts'
21
+
22
+ export function annotations(): Locator {
23
+ return {
24
+ name: 'annotations',
25
+ scan(root) {
26
+ const out: Target[] = []
27
+ root.querySelectorAll(`[${ATTR.ref}]`).forEach((el) => {
28
+ const ref = parseRefKey(el.getAttribute(ATTR.ref) ?? '')
29
+ if (!ref) return
30
+ const label = el.getAttribute(ATTR.label) ?? undefined
31
+ const field = el.getAttribute(ATTR.field)
32
+ if (field)
33
+ out.push({
34
+ el,
35
+ ref,
36
+ path: field,
37
+ kind: (el.getAttribute(ATTR.kind) as TargetKind) || 'field',
38
+ label,
39
+ source: 'annotation',
40
+ })
41
+ const row = el.getAttribute(ATTR.row)
42
+ if (row) out.push({ el, ref, path: row, kind: 'item', label, source: 'annotation' })
43
+ const list = el.getAttribute(ATTR.list)
44
+ if (list) out.push({ el, ref, path: list, kind: 'list', label, source: 'annotation' })
45
+ })
46
+ return out
47
+ },
48
+ }
49
+ }
50
+
51
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TEXTAREA', 'TITLE'])
52
+
53
+ /** Lowest common ancestor of a set of elements. */
54
+ function commonAncestor(els: Element[]): Element | null {
55
+ if (!els.length) return null
56
+ let anc: Element | null = els[0]
57
+ for (const el of els.slice(1)) {
58
+ while (anc && !anc.contains(el)) anc = anc.parentElement
59
+ if (!anc) return null
60
+ }
61
+ return anc
62
+ }
63
+
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
+ export function stega(opts: { inferRows?: boolean } = {}): Locator {
74
+ const inferRows = opts.inferRows ?? true
75
+ return {
76
+ name: 'stega',
77
+ scan(root) {
78
+ const fields: Target[] = []
79
+ const seen = new Set<string>()
80
+ const ids = new WeakMap<Element, number>()
81
+ let nextId = 0
82
+ const add = (el: Element, ref: DocRef, path: string, kind: TargetKind) => {
83
+ let id = ids.get(el)
84
+ if (id === undefined) ids.set(el, (id = nextId++))
85
+ const k = `${id}|${targetKey(ref, path)}|${kind}`
86
+ if (seen.has(k)) return // several text nodes of one field in one element
87
+ seen.add(k)
88
+ fields.push({ el, ref, path, kind, source: 'stega' })
89
+ }
90
+
91
+ const doc = (root as Node).ownerDocument ?? (root as Document)
92
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */)
93
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
94
+ const text = n.nodeValue
95
+ if (!text || !text.includes('\u2063\u2062')) continue
96
+ const parent = n.parentElement
97
+ if (!parent || SKIP_TAGS.has(parent.tagName) || parent.closest(`[${UI}]`)) continue
98
+ for (const m of stegaDecode(text)) {
99
+ const ref = parseRefKey(m.ref)
100
+ if (!ref) continue
101
+ // Rich text: map the block that holds all of the field's text (resolved below).
102
+ add(parent, ref, m.path, m.kind === 'richtext' ? 'richtext' : m.kind)
103
+ }
104
+ }
105
+ root.querySelectorAll('img[alt]').forEach((img) => {
106
+ for (const m of stegaDecode(img.getAttribute('alt') ?? '')) {
107
+ const ref = parseRefKey(m.ref)
108
+ if (ref) add(img.closest('picture') ?? img, ref, m.path, 'media')
109
+ }
110
+ })
111
+
112
+ // Rich text: one target per field = the common ancestor of all its text.
113
+ const out: Target[] = []
114
+ const rich = new Map<string, Target[]>()
115
+ for (const t of fields) {
116
+ if (t.kind !== 'richtext') {
117
+ out.push(t)
118
+ continue
119
+ }
120
+ const k = targetKey(t.ref, t.path)
121
+ rich.set(k, [...(rich.get(k) ?? []), t])
122
+ }
123
+ for (const group of rich.values()) {
124
+ const el = commonAncestor(group.map((t) => t.el))
125
+ if (el) out.push({ ...group[0], el })
126
+ }
127
+
128
+ if (!inferRows) return out
129
+
130
+ // Rows: for each row path, the common ancestor of its fields — accepted
131
+ // only if it doesn't also contain a sibling row's fields.
132
+ // Precompute, for every ancestor of a field, the field keys under it (one pass).
133
+ const under = new Map<Element, string[]>()
134
+ for (const t of out) {
135
+ const key = targetKey(t.ref, t.path)
136
+ for (let el: Element | null = t.el; el && el !== root; el = el.parentElement) {
137
+ const list = under.get(el)
138
+ if (list) list.push(key)
139
+ else under.set(el, [key])
140
+ }
141
+ }
142
+ const byRow = new Map<string, { ref: DocRef; path: string; els: Element[] }>()
143
+ for (const t of out) {
144
+ for (const row of rowPrefixes(t.path)) {
145
+ const k = targetKey(t.ref, row)
146
+ const entry = byRow.get(k) ?? { ref: t.ref, path: row, els: [] }
147
+ entry.els.push(t.el)
148
+ byRow.set(k, entry)
149
+ }
150
+ }
151
+ const rows: Target[] = []
152
+ for (const [rowKey, { ref, path, els }] of byRow) {
153
+ let anc = commonAncestor(els)
154
+ if (!anc || anc === root) continue
155
+ const foreignInside = (el: Element) =>
156
+ (under.get(el) ?? []).some((k) => k !== rowKey && !k.startsWith(`${rowKey}.`))
157
+ if (foreignInside(anc)) continue // two rows share one element: can't tell them apart
158
+ // Grow to the row's wrapper: the largest ancestor holding only this row's fields.
159
+ while (anc.parentElement && anc.parentElement !== root && !foreignInside(anc.parentElement))
160
+ anc = anc.parentElement
161
+ rows.push({ el: anc, ref, path, kind: 'item', source: 'stega' })
162
+ }
163
+ // Lists: the common ancestor of a list's rows.
164
+ const byList = new Map<string, { ref: DocRef; path: string; els: Element[] }>()
165
+ for (const r of [...rows]) {
166
+ const list = r.path.slice(0, r.path.lastIndexOf('.'))
167
+ const k = targetKey(r.ref, list)
168
+ const entry = byList.get(k) ?? { ref: r.ref, path: list, els: [] }
169
+ entry.els.push(r.el)
170
+ byList.set(k, entry)
171
+ }
172
+ for (const { ref, path, els } of byList.values()) {
173
+ const anc = els.length > 1 ? commonAncestor(els) : els[0].parentElement
174
+ if (anc && anc !== root) rows.push({ el: anc, ref, path, kind: 'list', source: 'stega' })
175
+ }
176
+ return [...out, ...rows]
177
+ },
178
+ }
179
+ }
180
+
181
+ /** Merge locators; for the same element and path the earlier locator wins. Rows/lists: one element per path, first wins. */
182
+ export function composite(...locators: Locator[]): Locator {
183
+ return {
184
+ name: `composite(${locators.map((l) => l.name).join(',')})`,
185
+ scan(root) {
186
+ const out: Target[] = []
187
+ const byPath = new Set<string>()
188
+ const byElPath = new Set<string>()
189
+ const elIds = new WeakMap<Element, number>()
190
+ let next = 0
191
+ const id = (el: Element) => {
192
+ let v = elIds.get(el)
193
+ if (v === undefined) elIds.set(el, (v = next++))
194
+ return v
195
+ }
196
+ for (const loc of locators) {
197
+ for (const t of loc.scan(root)) {
198
+ const pathKey = `${targetKey(t.ref, t.path)}|${t.kind}`
199
+ if ((t.kind === 'item' || t.kind === 'list') && byPath.has(pathKey)) continue
200
+ const k = `${id(t.el)}|${pathKey}`
201
+ if (byElPath.has(k)) continue
202
+ byElPath.add(k)
203
+ byPath.add(pathKey)
204
+ out.push(t)
205
+ }
206
+ }
207
+ return out
208
+ },
209
+ }
210
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Every string the editor shows, with English defaults. Override any subset
3
+ * with the `messages` prop (e.g. from your site's translations). `{name}`
4
+ * placeholders are filled from the variables passed to `t()`.
5
+ */
6
+ export const defaultMessages = {
7
+ 'dock.label': 'Page editor',
8
+ 'dock.loading': 'Loading editor…',
9
+ 'dock.unavailable': '{doc}: {error}',
10
+ 'dock.nothing': 'Nothing editable on this page',
11
+ 'dock.conflict': '{doc}: changed by someone else',
12
+ 'dock.saving': 'Saving draft…',
13
+ 'dock.updating': 'Updating page…',
14
+ 'dock.saved': 'Draft saved {ago}',
15
+ 'dock.editing': 'Editing draft',
16
+ 'dock.documents': '{count} documents',
17
+ 'dock.localeNotice':
18
+ 'Editing {locale}: text changes apply to this language only; adding, moving or removing sections changes every language.',
19
+ 'dock.reload': 'Reload latest',
20
+ 'dock.retry': 'Retry',
21
+ 'dock.undo': 'Undo',
22
+ 'dock.redo': 'Redo',
23
+ 'dock.preview': 'Preview',
24
+ 'dock.previewHint': 'See the draft without editing tools',
25
+ 'dock.share': 'Share',
26
+ 'dock.shareHint': 'Copy a read-only link to this draft',
27
+ 'dock.discard': 'Discard',
28
+ 'dock.discardConfirm': 'Confirm discard?',
29
+ 'dock.discardHint': 'Throw away draft changes and return to the published content',
30
+ 'dock.admin': 'Admin',
31
+ 'dock.exit': 'Exit',
32
+ 'dock.publish': 'Publish',
33
+ 'dock.publishing': 'Publishing…',
34
+ 'notify.published': 'Published ✓',
35
+ 'notify.nothingToPublish': 'Nothing to publish',
36
+ 'notify.publishFailed': 'Publish failed: {message}',
37
+ 'notify.discarded': 'Draft reset to the published version',
38
+ 'notify.linkCopied': 'Preview link copied — valid until {date}',
39
+ 'notify.linkFailed': "Couldn't create a preview link: {message}",
40
+ 'notify.loading': 'Loading the editor…',
41
+ 'notify.cantEdit': "Can't edit: {message}",
42
+ 'row.moveUp': 'Move up',
43
+ 'row.moveDown': 'Move down',
44
+ 'row.duplicate': 'Duplicate',
45
+ 'row.settings': 'Settings',
46
+ 'row.delete': 'Delete (undo with ⌘Z)',
47
+ 'row.addSection': 'Add section',
48
+ 'row.addFirstSection': 'Add a section',
49
+ 'row.addItem': 'Add {item}',
50
+ 'row.toolbar': '{label} actions',
51
+ 'settings.title': '{label} settings',
52
+ 'settings.close': 'Close settings',
53
+ 'settings.chooseImage': 'Choose image…',
54
+ 'settings.changeImage': 'Change image…',
55
+ 'blocks.title': 'Add section',
56
+ 'common.close': 'Close',
57
+ 'common.apply': 'Apply',
58
+ 'common.clear': 'Clear',
59
+ 'common.remove': 'Remove',
60
+ 'common.none': '—',
61
+ 'common.selected': '{count} selected',
62
+ 'search.placeholder': 'Search…',
63
+ 'search.unsupported': "This data source can't search documents.",
64
+ 'media.search': 'Search images…',
65
+ 'media.upload': 'Upload…',
66
+ 'media.empty': 'No images found.',
67
+ 'media.more': 'Load more',
68
+ 'media.loading': 'Loading…',
69
+ 'inline.label': 'Edit {field}',
70
+ 'preview.label': 'Draft preview',
71
+ 'preview.badge': 'Draft',
72
+ 'preview.shared': 'Shared preview — not published yet',
73
+ 'preview.own': 'Previewing the draft',
74
+ 'preview.phone': 'Phone',
75
+ 'preview.tablet': 'Tablet',
76
+ 'preview.desktop': 'Desktop',
77
+ 'preview.edit': 'Edit',
78
+ 'preview.exit': 'Exit',
79
+ 'preview.viewPublished': 'View published',
80
+ 'rt.toolbar': 'Text formatting',
81
+ 'rt.bold': 'Bold',
82
+ 'rt.italic': 'Italic',
83
+ 'rt.underline': 'Underline',
84
+ 'rt.link': 'Link',
85
+ 'rt.h2': 'Heading 2',
86
+ 'rt.h3': 'Heading 3',
87
+ 'rt.ul': 'Bulleted list',
88
+ 'rt.ol': 'Numbered list',
89
+ 'rt.quote': 'Quote',
90
+ 'rt.done': 'Done',
91
+ 'rt.linkPlaceholder': 'https://… or /page (empty removes the link)',
92
+ 'rt.linkInvalid': 'Use an https://, mailto:, tel: or relative link',
93
+ } as const
94
+
95
+ export type MessageKey = keyof typeof defaultMessages
96
+ 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>,
101
+ ) => string
102
+
103
+ export function createTranslate(overrides: Messages = {}): Translate {
104
+ const table: Record<string, string> = { ...defaultMessages, ...overrides }
105
+ return (key, vars) => {
106
+ const template = table[key] ?? key
107
+ return vars
108
+ ? template.replace(/\{(\w+)\}/g, (m, name: string) => (name in vars ? String(vars[name]) : m))
109
+ : template
110
+ }
111
+ }
112
+
113
+ /** "5 seconds ago", "2 minutes ago", in the browser's language. */
114
+ export function relativeTime(ts: number, now = Date.now(), locale?: string): string {
115
+ const s = Math.round((ts - now) / 1000)
116
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
117
+ if (Math.abs(s) < 60) return rtf.format(s, 'second')
118
+ if (Math.abs(s) < 3600) return rtf.format(Math.round(s / 60), 'minute')
119
+ return rtf.format(Math.round(s / 3600), 'hour')
120
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Field-editor resolution. For a field shown on the page, the editor is:
3
+ * 1. the field's own choice: admin.custom.onpage.editor ("color", "my-editor")
4
+ * 2. what the element says it shows: an annotation/marker kind of text,
5
+ * textarea, richtext or media
6
+ * 3. the editor registered for the field's type ("select", "date", "json"…)
7
+ * No match: the field isn't edited in place (it may still be in the row's
8
+ * settings panel).
9
+ */
10
+ import type { SchemaField, TargetKind } from '@payload-onpage/core'
11
+ import type { EditorRegistry } from './types.ts'
12
+
13
+ const KIND_EDITOR: Partial<Record<TargetKind, string>> = { richtext: 'richText', media: 'upload' }
14
+ const TEXTUAL = new Set(['text', 'textarea', 'email'])
15
+
16
+ export function resolveEditor(
17
+ editors: EditorRegistry,
18
+ field: SchemaField,
19
+ kind?: TargetKind,
20
+ ): 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])
24
+ return field.type
25
+ 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
28
+ }
29
+
30
+ /** Merge registries left to right; `null` removes a default. */
31
+ export function mergeEditors(
32
+ ...regs: Array<Record<string, EditorRegistry[string] | null> | undefined>
33
+ ): EditorRegistry {
34
+ const out: EditorRegistry = {}
35
+ for (const r of regs) {
36
+ if (!r) continue
37
+ for (const [k, v] of Object.entries(r)) {
38
+ if (v) out[k] = v
39
+ else delete out[k]
40
+ }
41
+ }
42
+ return out
43
+ }
@@ -0,0 +1,15 @@
1
+ /** Framework-free Renderer adapters. Next.js's router.refresh() renderer lives in @payload-onpage/next. */
2
+ import type { Renderer } from '@payload-onpage/core'
3
+
4
+ /** The page renders from editor state (OnPageClient everywhere): nothing to do after a save. */
5
+ export const clientState = (): Renderer => ({ name: 'client', live: true, afterSave() {} })
6
+
7
+ /** Reload the whole page after each save. A last resort for frameworks without a soft refresh. */
8
+ export const fullReload = (): Renderer => ({
9
+ name: 'reload',
10
+ live: false,
11
+ afterSave() {
12
+ window.location.reload()
13
+ return new Promise(() => {}) // never resolves: the page goes away
14
+ },
15
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Minimal HTML sanitizer for editor-generated markup shown in ghosts (the
3
+ * stand-in content kept on screen until a server refresh lands). Drops active
4
+ * elements, event handlers and unsafe URLs.
5
+ */
6
+ import { safeLinkUrl } from '@payload-onpage/core'
7
+
8
+ const DROP = new Set([
9
+ 'SCRIPT',
10
+ 'STYLE',
11
+ 'IFRAME',
12
+ 'OBJECT',
13
+ 'EMBED',
14
+ 'LINK',
15
+ 'META',
16
+ 'BASE',
17
+ 'FORM',
18
+ 'INPUT',
19
+ 'TEXTAREA',
20
+ 'BUTTON',
21
+ 'SELECT',
22
+ 'TEMPLATE',
23
+ 'SVG',
24
+ 'MATH',
25
+ ])
26
+ const URL_ATTRS = new Set(['href', 'src', 'xlink:href', 'action', 'formaction', 'srcset', 'poster'])
27
+
28
+ export function sanitizeHtml(html: string): string {
29
+ const tpl = document.createElement('template')
30
+ tpl.innerHTML = html
31
+ const walk = (node: ParentNode) => {
32
+ for (const el of Array.from(node.children)) {
33
+ if (DROP.has(el.tagName.toUpperCase())) {
34
+ el.remove()
35
+ continue
36
+ }
37
+ for (const attr of Array.from(el.attributes)) {
38
+ const name = attr.name.toLowerCase()
39
+ if (
40
+ name.startsWith('on') ||
41
+ name === 'style' ||
42
+ (URL_ATTRS.has(name) && !safeLinkUrl(attr.value))
43
+ ) {
44
+ el.removeAttribute(attr.name)
45
+ }
46
+ }
47
+ walk(el)
48
+ }
49
+ }
50
+ walk(tpl.content)
51
+ return tpl.innerHTML
52
+ }