@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.
- package/README.md +6 -0
- package/dist/OnPageClient.d.ts.map +1 -1
- package/dist/OnPageClient.js +9 -5
- package/dist/OnPageClient.js.map +1 -1
- package/dist/annotations.d.ts +1 -1
- package/dist/annotations.d.ts.map +1 -1
- package/dist/context.d.ts +19 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +31 -7
- package/dist/context.js.map +1 -1
- package/dist/dom.d.ts +16 -12
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +16 -16
- package/dist/dom.js.map +1 -1
- package/dist/editor/FieldHost.d.ts.map +1 -1
- package/dist/editor/FieldHost.js +18 -11
- package/dist/editor/FieldHost.js.map +1 -1
- package/dist/editor/OnPageEditor.d.ts +15 -8
- package/dist/editor/OnPageEditor.d.ts.map +1 -1
- package/dist/editor/OnPageEditor.js +73 -103
- package/dist/editor/OnPageEditor.js.map +1 -1
- package/dist/editor/editTarget.d.ts +28 -0
- package/dist/editor/editTarget.d.ts.map +1 -0
- package/dist/editor/editTarget.js +20 -0
- package/dist/editor/editTarget.js.map +1 -0
- package/dist/editor/useClientDocs.d.ts +4 -0
- package/dist/editor/useClientDocs.d.ts.map +1 -0
- package/dist/editor/useClientDocs.js +16 -0
- package/dist/editor/useClientDocs.js.map +1 -0
- package/dist/editor/useEditorSession.d.ts +13 -0
- package/dist/editor/useEditorSession.d.ts.map +1 -0
- package/dist/editor/useEditorSession.js +30 -0
- package/dist/editor/useEditorSession.js.map +1 -0
- package/dist/editor/useFlash.d.ts +4 -0
- package/dist/editor/useFlash.d.ts.map +1 -0
- package/dist/editor/useFlash.js +16 -0
- package/dist/editor/useFlash.js.map +1 -0
- package/dist/editor/useGhosts.d.ts +5 -7
- package/dist/editor/useGhosts.d.ts.map +1 -1
- package/dist/editor/useGhosts.js.map +1 -1
- package/dist/editor/usePageEvents.d.ts.map +1 -1
- package/dist/editor/usePageEvents.js +16 -16
- package/dist/editor/usePageEvents.js.map +1 -1
- package/dist/editor-kit.d.ts +10 -6
- package/dist/editor-kit.d.ts.map +1 -1
- package/dist/editor-kit.js +8 -5
- package/dist/editor-kit.js.map +1 -1
- package/dist/editors/CheckboxEditor.d.ts +6 -1
- package/dist/editors/CheckboxEditor.d.ts.map +1 -1
- package/dist/editors/CheckboxEditor.js +6 -3
- package/dist/editors/CheckboxEditor.js.map +1 -1
- package/dist/editors/ColorEditor.d.ts +3 -1
- package/dist/editors/ColorEditor.d.ts.map +1 -1
- package/dist/editors/ColorEditor.js +9 -15
- package/dist/editors/ColorEditor.js.map +1 -1
- package/dist/editors/DateEditor.d.ts +3 -1
- package/dist/editors/DateEditor.d.ts.map +1 -1
- package/dist/editors/DateEditor.js +10 -12
- package/dist/editors/DateEditor.js.map +1 -1
- package/dist/editors/InlineText.d.ts +2 -2
- package/dist/editors/InlineText.d.ts.map +1 -1
- package/dist/editors/InlineText.js +2 -3
- package/dist/editors/InlineText.js.map +1 -1
- package/dist/editors/NumberEditor.d.ts +3 -1
- package/dist/editors/NumberEditor.d.ts.map +1 -1
- package/dist/editors/NumberEditor.js +9 -17
- package/dist/editors/NumberEditor.js.map +1 -1
- package/dist/editors/RelationshipEditor.d.ts +7 -1
- package/dist/editors/RelationshipEditor.d.ts.map +1 -1
- package/dist/editors/RelationshipEditor.js +10 -18
- package/dist/editors/RelationshipEditor.js.map +1 -1
- package/dist/editors/SelectEditor.d.ts +4 -1
- package/dist/editors/SelectEditor.d.ts.map +1 -1
- package/dist/editors/SelectEditor.js +8 -13
- package/dist/editors/SelectEditor.js.map +1 -1
- package/dist/editors/UploadEditor.d.ts +7 -1
- package/dist/editors/UploadEditor.d.ts.map +1 -1
- package/dist/editors/UploadEditor.js +5 -5
- package/dist/editors/UploadEditor.js.map +1 -1
- package/dist/editors/index.d.ts +0 -1
- package/dist/editors/index.d.ts.map +1 -1
- package/dist/editors/index.js +56 -16
- package/dist/editors/index.js.map +1 -1
- package/dist/editors/kit.d.ts +39 -0
- package/dist/editors/kit.d.ts.map +1 -0
- package/dist/editors/kit.js +44 -0
- package/dist/editors/kit.js.map +1 -0
- package/dist/editors/useDebouncedSearch.d.ts +10 -6
- package/dist/editors/useDebouncedSearch.d.ts.map +1 -1
- package/dist/editors/useDebouncedSearch.js +11 -7
- package/dist/editors/useDebouncedSearch.js.map +1 -1
- package/dist/geometry.d.ts +49 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +48 -0
- package/dist/geometry.js.map +1 -0
- package/dist/hub.d.ts +4 -4
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +4 -2
- package/dist/layout.js.map +1 -1
- package/dist/locators.d.ts +0 -2
- package/dist/locators.d.ts.map +1 -1
- package/dist/locators.js +10 -16
- package/dist/locators.js.map +1 -1
- package/dist/messages.d.ts +15 -2
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +5 -0
- package/dist/messages.js.map +1 -1
- package/dist/registry.d.ts +21 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +31 -6
- package/dist/registry.js.map +1 -1
- package/dist/rowOps.d.ts +20 -0
- package/dist/rowOps.d.ts.map +1 -0
- package/dist/rowOps.js +48 -0
- package/dist/rowOps.js.map +1 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.d.ts.map +1 -1
- package/dist/sanitize.js +3 -0
- package/dist/sanitize.js.map +1 -1
- package/dist/settings.d.ts +10 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +30 -0
- package/dist/settings.js.map +1 -0
- package/dist/styles.d.ts +1 -5
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +5 -4
- package/dist/styles.js.map +1 -1
- package/dist/targets.d.ts +1 -1
- package/dist/targets.d.ts.map +1 -1
- package/dist/types.d.ts +71 -46
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/BlockPicker.d.ts.map +1 -1
- package/dist/ui/BlockPicker.js +6 -5
- package/dist/ui/BlockPicker.js.map +1 -1
- package/dist/ui/Dialog.d.ts +0 -5
- package/dist/ui/Dialog.d.ts.map +1 -1
- package/dist/ui/Dialog.js +10 -6
- package/dist/ui/Dialog.js.map +1 -1
- package/dist/ui/Dock.d.ts +1 -6
- package/dist/ui/Dock.d.ts.map +1 -1
- package/dist/ui/Dock.js +46 -28
- package/dist/ui/Dock.js.map +1 -1
- package/dist/ui/Overlay.d.ts.map +1 -1
- package/dist/ui/Overlay.js +39 -37
- package/dist/ui/Overlay.js.map +1 -1
- package/dist/ui/PreviewToolbar.d.ts +3 -3
- package/dist/ui/PreviewToolbar.d.ts.map +1 -1
- package/dist/ui/PreviewToolbar.js +4 -6
- package/dist/ui/PreviewToolbar.js.map +1 -1
- package/dist/ui/SettingsPanel.d.ts +0 -9
- package/dist/ui/SettingsPanel.d.ts.map +1 -1
- package/dist/ui/SettingsPanel.js +36 -41
- package/dist/ui/SettingsPanel.js.map +1 -1
- package/dist/ui/ToolbarButton.d.ts +12 -0
- package/dist/ui/ToolbarButton.d.ts.map +1 -0
- package/dist/ui/ToolbarButton.js +7 -0
- package/dist/ui/ToolbarButton.js.map +1 -0
- package/dist/useEvent.d.ts +1 -1
- package/dist/useEvent.d.ts.map +1 -1
- package/dist/useEvent.js +3 -1
- package/dist/useEvent.js.map +1 -1
- package/dist/useIsClient.d.ts +5 -0
- package/dist/useIsClient.d.ts.map +1 -0
- package/dist/useIsClient.js +8 -0
- package/dist/useIsClient.js.map +1 -0
- package/package.json +15 -4
- package/src/OnPageClient.tsx +9 -5
- package/src/annotations.ts +1 -1
- package/src/context.tsx +53 -10
- package/src/dom.ts +17 -18
- package/src/editor/FieldHost.tsx +26 -11
- package/src/editor/OnPageEditor.tsx +103 -160
- package/src/editor/editTarget.ts +41 -0
- package/src/editor/useClientDocs.ts +19 -0
- package/src/editor/useEditorSession.ts +36 -0
- package/src/editor/useFlash.ts +18 -0
- package/src/editor/useGhosts.ts +5 -7
- package/src/editor/usePageEvents.ts +13 -14
- package/src/editor-kit.ts +10 -6
- package/src/editors/CheckboxEditor.tsx +11 -12
- package/src/editors/ColorEditor.tsx +15 -35
- package/src/editors/DateEditor.tsx +18 -37
- package/src/editors/InlineText.tsx +4 -5
- package/src/editors/NumberEditor.tsx +15 -36
- package/src/editors/RelationshipEditor.tsx +18 -36
- package/src/editors/SelectEditor.tsx +14 -22
- package/src/editors/UploadEditor.tsx +6 -6
- package/src/editors/index.ts +56 -16
- package/src/editors/kit.tsx +97 -0
- package/src/editors/useDebouncedSearch.ts +18 -10
- package/src/geometry.ts +62 -0
- package/src/hub.ts +5 -5
- package/src/index.ts +5 -2
- package/src/layout.ts +5 -2
- package/src/locators.ts +11 -14
- package/src/messages.ts +26 -5
- package/src/registry.ts +42 -8
- package/src/rowOps.ts +64 -0
- package/src/sanitize.ts +6 -0
- package/src/settings.ts +49 -0
- package/src/styles.ts +5 -4
- package/src/targets.ts +1 -1
- package/src/types.ts +78 -45
- package/src/ui/BlockPicker.tsx +6 -7
- package/src/ui/Dialog.tsx +9 -5
- package/src/ui/Dock.tsx +67 -50
- package/src/ui/Overlay.tsx +75 -102
- package/src/ui/PreviewToolbar.tsx +7 -15
- package/src/ui/SettingsPanel.tsx +84 -81
- package/src/ui/ToolbarButton.tsx +36 -0
- package/src/useEvent.ts +4 -2
- 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
|
|
7
|
-
* across pages and reset when the query changes.
|
|
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 &&
|
|
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
|
-
|
|
40
|
-
}, [search, query, page, ...deps])
|
|
48
|
+
}, [search, query, page])
|
|
41
49
|
|
|
42
50
|
return {
|
|
43
51
|
query,
|
package/src/geometry.ts
ADDED
|
@@ -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 {
|
|
8
|
+
import type { OnPageApi } from './types.ts'
|
|
9
9
|
|
|
10
10
|
interface Hub {
|
|
11
|
-
current:
|
|
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: ():
|
|
23
|
-
set: (value:
|
|
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
|
|
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
|
-
|
|
75
|
-
if (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
|
-
|
|
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
|
|
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({ ...
|
|
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]
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
if ((kind === 'text' || kind === 'textarea') &&
|
|
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
|
-
|
|
27
|
-
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"' } as const
|
package/src/settings.ts
ADDED
|
@@ -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))
|