@unitninety/site-kit 1.6.5 → 1.7.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.
- package/README.md +68 -4
- package/package.json +5 -4
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/U90Bridge.tsx +4 -2
- package/src/bridge/caret.ts +7 -8
- package/src/bridge/createBridge.ts +30 -10
- package/src/bridge/diagnostics.ts +61 -0
- package/src/bridge/index.ts +3 -0
- package/src/bridge/nativeDraft.ts +47 -36
- package/src/bridge/nativeOrder.ts +72 -0
- package/src/bridge/nativePath.ts +45 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/styles.ts +12 -0
- package/src/bridge/useDraftContent.tsx +68 -29
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Image.tsx +8 -4
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/components/draft.ts +19 -0
- package/src/components/index.ts +1 -1
- package/src/custom-code/fragments.ts +64 -0
- package/src/custom-code/index.ts +30 -0
- package/src/elements/blockAnchor.ts +7 -0
- package/src/elements/index.ts +1 -0
- package/src/elements/ops/draftPaths.ts +23 -0
- package/src/elements/ops/expand.ts +8 -6
- package/src/elements/ops/validate.ts +9 -2
- package/src/features/client/README.md +1 -2
- package/src/features/client/SiteFeatures.tsx +15 -25
- package/src/features/client/index.ts +0 -6
- package/src/features/client/verification.ts +3 -0
- package/src/features/contracts/base.ts +1 -5
- package/src/features/contracts/index.ts +0 -20
- package/src/features/contracts/verificationConfig.ts +7 -0
- package/src/features/server/gateway.ts +1 -3
- package/src/features/server/index.ts +0 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/server/BlockAnchors.tsx +92 -0
- package/src/server/CustomCode.tsx +29 -0
- package/src/server/index.ts +1 -0
- package/src/server/read.ts +13 -0
- package/src/server/render.tsx +12 -6
- package/src/site/fields.ts +2 -2
- package/src/site/types.ts +4 -2
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +3 -0
- package/src/features/client/ConsentRuntime.tsx +0 -95
- package/src/features/client/useConsent.ts +0 -15
- package/src/features/consent/README.md +0 -70
- package/src/features/consent/cookieScript.ts +0 -53
- package/src/features/consent/custom.ts +0 -89
- package/src/features/consent/defaults.ts +0 -40
- package/src/features/consent/drivers.ts +0 -233
- package/src/features/consent/loader.ts +0 -248
- package/src/features/consent/registry.ts +0 -398
- package/src/features/contracts/consent.ts +0 -150
- package/src/features/server/ConsentDefaults.tsx +0 -15
package/README.md
CHANGED
|
@@ -5,18 +5,41 @@ The library a UnitNinety site is built on. It reads the site's content from Unit
|
|
|
5
5
|
You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx --yes @unitninety/u90@1.0
|
|
8
|
+
npx --yes @unitninety/u90@1.1.0 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
UnitNinety imports packages made for that version only, so keep the version the tool names.
|
|
12
12
|
|
|
13
13
|
Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
|
|
14
14
|
|
|
15
|
-
###
|
|
15
|
+
### Keep cleared fields editable
|
|
16
16
|
|
|
17
|
-
Site Kit 1.
|
|
17
|
+
Site Kit 1.7.0 preserves real draft addresses on groups and block props. Nullable images still read as `null`; pass their owner address separately so Canvas can select them after a clear or reload:
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
```tsx
|
|
20
|
+
import { Image, Text, draftFieldPath } from '@unitninety/site-kit/components'
|
|
21
|
+
|
|
22
|
+
export function Hero(props) {
|
|
23
|
+
return <section>
|
|
24
|
+
<Text as="h1" value={props.title} />
|
|
25
|
+
<div style={{ position: 'relative', aspectRatio: '4 / 3' }}>
|
|
26
|
+
<Image value={props.image} draftPath={draftFieldPath(props, 'image')} fill />
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For a nested group or list row, pass that group or row to `draftFieldPath`. For a collection image, pass `entry.fields`. Keep the complete native object, including reserved `$` metadata, when handing it to another component. Iterate declared fields rather than every group property. A null leaf has no identity of its own: prefer `list(group({ image: image() }))` for editable image lists so each row retains its stable address, including when its image is empty.
|
|
33
|
+
|
|
34
|
+
For an existing `list(image())`, use `draftListItemPath(props, 'photos', index)` as the image's `draftPath`. This reads the original stable item ID from the owner's draft metadata, including after clearing and reloading; the index only identifies the current rendered item, not its saved address. Keep the owner metadata with the list. A detached array of null values alone cannot identify its fields. Shared Single aliases likewise retain their canonical addresses separately from null image values.
|
|
35
|
+
|
|
36
|
+
The standard image renderer keeps a bounded missing-image target only when it has a real draft address. Published empty images render nothing. Custom image components must preserve the same target, respect literal clearing, and never reintroduce a seed image as fallback for an explicitly empty field. The bridge also allows blank declared text, rich-text and link-label fields to resume direct editing. Formatted dates and derived values can remain inspector-only.
|
|
37
|
+
|
|
38
|
+
Use the real root `U90Bridge`; inline editing is enabled by default. Deliberate inspector-only integrations may set `inlineEditing={false}` and verify with `u90 check --canvas-mode inspector`. Ordinary checks require inline mode. The bridge exposes only its effective mode booleans in a draft-only diagnostic marker; no session or credentials are included.
|
|
39
|
+
|
|
40
|
+
The local checker verifies the actual root mode, exact field bindings, and observed image replacement → literal clear → restore against isolated content. New packages must record these checks. This does not prove client hydration or CSS hit targets: in a browser, click and type into real headings, clear and replace images, and repeat on mobile and inactive tabs/carousels. Decorative overlays must not intercept editable images; enable pointer events only on their actual interactive content. Preserve test content and report untested states explicitly.
|
|
41
|
+
|
|
42
|
+
For a custom integration's browser diagnostics, `inspectCanvas(document)` from `@unitninety/site-kit/bridge` reads the existing frame without starting another bridge or editing content. It reports the root mode, mounted bridge stylesheet, sampled click targets and blockers. Hidden, offscreen and zero-size fields are reported separately; a reachable sample does not prove typing, saving, or another viewport. Run it inside the site frame, not the outer dashboard.
|
|
20
43
|
|
|
21
44
|
### Fixed field values
|
|
22
45
|
|
|
@@ -61,6 +84,19 @@ Call the hook on native reads before converting them for a presentation componen
|
|
|
61
84
|
|
|
62
85
|
Canvas prepares locally resolvable list defaults with a stable random item ID, previews the same list in both inspector and site, then saves through the normal validated queue. Nested lists, empty lists and duplicate responsive renderings work without DOM cloning. Later edits survive older refresh responses; rejected or ambiguous structural saves reconcile with an authoritative read. Draft IDs cannot collide with an existing item. Media/reference defaults requiring resolution still use the server path, and references continue using their existing entry previews. This opt-in requires the updated Site Kit; older bridges retain their ordinary DOM previews and refresh behavior.
|
|
63
86
|
|
|
87
|
+
### Stable block reorder previews
|
|
88
|
+
|
|
89
|
+
Site Kit 1.6.6 keeps existing block content mounted while Canvas changes its order, including hidden destinations and repeated responsive views. The default `useDraftPreview(source)` preserves native field objects and pending edits instead of expanding a whole zone snapshot again.
|
|
90
|
+
|
|
91
|
+
Custom presentation adapters retain their existing snapshot behavior. If every rendered block collection keeps its native `$path` values, opt into the shared order adapter:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
import { useDraftPreview, reorderNativeDraft } from '@unitninety/site-kit/bridge'
|
|
95
|
+
const current = useDraftPreview(source, applyPresentationPatch, reorderNativeDraft)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For plain presentation arrays without native paths, provide a third callback `(source, { path, ids }) => source` that reorders the existing objects by stable block ID. Keep membership, content and unrelated arrays unchanged. Test both the instant preview and the later server refresh; a complete zone snapshot must never replace formatted presentation props with raw native blocks.
|
|
99
|
+
|
|
64
100
|
### Immediate collection-list previews
|
|
65
101
|
|
|
66
102
|
Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
|
|
@@ -122,3 +158,31 @@ For example, KAW's Home declares `query('industries', { previewFields: ['name',
|
|
|
122
158
|
The site component must reveal the selected entry in its own tabs/carousels, including when its data arrives after selection. `useEditing()` exposes `selected` and `selectedVia`: the latter contains outer-to-inner reference field paths, disambiguating an entry reused in several parents. Mark a displayed list container with `data-u90-reference={reference.$path}` in draft mode to preserve this context when selecting its content on the canvas. Keep `data-u90-field` on each entry's own fields and a root marker on the revealed entry for row highlighting. A relation's target entry path is not its parent relation field path. These attributes do not introduce visitor-facing editing controls.
|
|
123
159
|
|
|
124
160
|
Deploy matching Site Kit/platform code before importing this metadata, and restart a running dev server after changing registry options so Payload validation hooks reload.
|
|
161
|
+
## Site-wide custom code
|
|
162
|
+
|
|
163
|
+
Settings → Custom code stores three embed fields as a draft. The workspace’s existing Publish control makes that draft live and revalidates the cached site read. Saving a snippet never rebuilds the website. Editing requires Code permission; disabling Code also hides these settings.
|
|
164
|
+
|
|
165
|
+
Install the server renderer in each root layout, using one snapshot for all three placements. Keep the site’s existing head content, body attributes, providers, and children:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { U90Bridge, U90CustomCode } from '@unitninety/site-kit/server'
|
|
169
|
+
import { u90 } from '@/u90'
|
|
170
|
+
|
|
171
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
172
|
+
const customCode = await u90.read.customCode()
|
|
173
|
+
return (
|
|
174
|
+
<html lang="en">
|
|
175
|
+
<head><U90CustomCode code={customCode} placement="head" /></head>
|
|
176
|
+
<body>
|
|
177
|
+
<U90CustomCode code={customCode} placement="bodyStart" />
|
|
178
|
+
<U90Bridge>{children}</U90Bridge>
|
|
179
|
+
<U90CustomCode code={customCode} placement="bodyEnd" />
|
|
180
|
+
</body>
|
|
181
|
+
</html>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Keep these slots as direct children of the native head and body; do not wrap them in an HTML container or load the code after hydration. `read.customCode()` returns only published code and suppresses output in authenticated draft and test previews, including Canvas. The website’s existing Content Security Policy continues to apply.
|
|
187
|
+
|
|
188
|
+
Existing sites need one initial code deployment to install a compatible Site Kit and these slots in every root layout. Subsequent custom-code changes use Publish and on-demand revalidation, without another deployment. New converter output includes the installation automatically.
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitninety/site-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/site/index.ts",
|
|
8
8
|
"./server": "./src/server/index.ts",
|
|
9
|
+
"./custom-code": "./src/custom-code/index.ts",
|
|
9
10
|
"./features/server": "./src/features/server/index.ts",
|
|
10
11
|
"./features/contracts": "./src/features/contracts/index.ts",
|
|
11
12
|
"./preview-lock": "./src/preview-lock/index.ts",
|
|
@@ -46,8 +47,7 @@
|
|
|
46
47
|
"!src/elements/fixtures",
|
|
47
48
|
"!src/elements/harness.ts",
|
|
48
49
|
"!src/site/fixtures",
|
|
49
|
-
"!src/test-preview/fixtures.ts"
|
|
50
|
-
"!src/features/consent/test-fixtures.ts"
|
|
50
|
+
"!src/test-preview/fixtures.ts"
|
|
51
51
|
],
|
|
52
52
|
"license": "UNLICENSED",
|
|
53
53
|
"homepage": "https://unitninety.com",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"
|
|
63
|
+
"parse5": "7.3.0",
|
|
64
|
+
"zod": "4.4.3"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
67
|
"react": ">=19",
|
|
@@ -6,6 +6,7 @@ import { DraftContentPath } from './useDraftContent'
|
|
|
6
6
|
import { registry } from './registry'
|
|
7
7
|
import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
|
|
8
8
|
import { PreviewCommitBoundary } from './PreviewCommitBoundary'
|
|
9
|
+
import { ordered, type DraftOrder } from './nativeOrder'
|
|
9
10
|
|
|
10
11
|
type Item = { id: string; path?: string; content: ReactNode }
|
|
11
12
|
type Move = { path: string; before: string | null }
|
|
@@ -55,6 +56,15 @@ export function DraftBlocks({ items }: { items: Item[] }) {
|
|
|
55
56
|
setShown(value => ({ ...value, items: value.items.filter(item => item.path !== path), removed: new Set([...value.removed, path]) }))
|
|
56
57
|
return true
|
|
57
58
|
},
|
|
59
|
+
reorder: ({ path, ids }: DraftOrder) => {
|
|
60
|
+
if (!current.some(item => item.path === `${path}/${item.id}`)) return false
|
|
61
|
+
if (host.current && needsEmbedReload(host.current)) {
|
|
62
|
+
reload.current = true
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
setShown(value => ({ ...value, items: ordered(value.items, ids, item => item.id), order: ids }))
|
|
66
|
+
return true
|
|
67
|
+
},
|
|
58
68
|
move: (path: string, before: string | null) => {
|
|
59
69
|
if (!current.some((item) => item.path === path)) return false
|
|
60
70
|
if (before !== null && !current.some((item) => item.path === before)) return false
|
package/src/bridge/U90Bridge.tsx
CHANGED
|
@@ -22,8 +22,10 @@ import { EditingProvider } from './hooks'
|
|
|
22
22
|
type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; inlineEditing?: boolean; testPreview?: () => Promise<TestPreviewContext | null> }
|
|
23
23
|
async function Gate({ platformOrigin, children, draft, testPreview, inlineEditing }: BridgeProps) {
|
|
24
24
|
const runtime = createTestPreviewRuntime({ env: { PLATFORM_URL: process.env.PLATFORM_URL, PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY, SITE_URL: process.env.SITE_URL } })
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
const state = await draft()
|
|
26
|
+
const props = await resolveBridgePreview({ platformOrigin, draft: async () => state, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
|
|
27
|
+
const diagnostic = state.enabled || props.testPreview ? { version: 1, editing: props.editing, inlineEditing: inlineEditing !== false, testPreview: props.testPreview } : null
|
|
28
|
+
return <>{diagnostic ? <span hidden data-u90-bridge={JSON.stringify(diagnostic)} /> : null}<EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider></>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
package/src/bridge/caret.ts
CHANGED
|
@@ -89,12 +89,13 @@ export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
|
|
|
89
89
|
*/
|
|
90
90
|
export const isEditableLeaf = (element: HTMLElement): boolean => {
|
|
91
91
|
if (element.matches(MEDIA)) return false
|
|
92
|
-
|
|
92
|
+
const kind = element.getAttribute('data-u90-edit')
|
|
93
|
+
if (kind === 'rich') return true
|
|
93
94
|
|
|
94
95
|
return (
|
|
95
96
|
!element.querySelector(`[${FIELD_ATTR}]`) &&
|
|
96
97
|
!element.querySelector(NON_TEXT) &&
|
|
97
|
-
Boolean(element.textContent?.trim())
|
|
98
|
+
(Boolean(element.textContent?.trim()) || kind === 'text' || kind === 'link-label')
|
|
98
99
|
)
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -110,10 +111,8 @@ export const isEditableLeaf = (element: HTMLElement): boolean => {
|
|
|
110
111
|
* `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
|
|
111
112
|
* a picture, rich or not, and the caret has no business there either.
|
|
112
113
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* interesting way for the two to fall out of step and beats making every site component
|
|
116
|
-
* carry a class.
|
|
114
|
+
* Explicit native edit kinds remain editable when blank. The bridge marks those empty
|
|
115
|
+
* surfaces so draft CSS can give them a clickable box without adding saved placeholder text.
|
|
117
116
|
*
|
|
118
117
|
* v4 change: `[${RICH_ATTR}]` (legacy's presence selector for the standalone rich attribute)
|
|
119
118
|
* becomes the value selector `[data-u90-edit="rich"]`.
|
|
@@ -235,8 +234,8 @@ const endOf = (element: HTMLElement): Range => {
|
|
|
235
234
|
const last = lastWordsIn(element)
|
|
236
235
|
|
|
237
236
|
if (!last) {
|
|
238
|
-
//
|
|
239
|
-
//
|
|
237
|
+
// An empty native field has no text node yet. Place the caret in its element;
|
|
238
|
+
// the browser creates the first text node when the person types.
|
|
240
239
|
range.selectNodeContents(element)
|
|
241
240
|
range.collapse(false)
|
|
242
241
|
return range
|
|
@@ -4,7 +4,8 @@ import { toRichText, type DomLike } from './lexicalDom'
|
|
|
4
4
|
import { applyPreview, type Undo } from './preview'
|
|
5
5
|
import { BRIDGE_VERSION, MAX_OFFENDER_LENGTH, MAX_OFFENDERS, parseCanvasMessage, type BridgeStamp, type PreviewInstruction, type SiteMessage } from './protocol'
|
|
6
6
|
import { registry } from './registry'
|
|
7
|
-
import {
|
|
7
|
+
import { snapshotOrder } from './nativeOrder'
|
|
8
|
+
import { createSelection, selectionVia } from './selection'
|
|
8
9
|
import { EDITING_CSS } from './styles'
|
|
9
10
|
|
|
10
11
|
export type BridgeOptions = {
|
|
@@ -98,6 +99,15 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
98
99
|
|
|
99
100
|
let pending: { path: string; kind: 'text' | 'rich' | 'link-label'; el: HTMLElement } | null = null
|
|
100
101
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
102
|
+
let emptyFields: MutationObserver | null = null
|
|
103
|
+
const markEmptyField = (el: HTMLElement) => {
|
|
104
|
+
const kind = el.getAttribute('data-u90-edit')
|
|
105
|
+
const empty = (kind === 'text' || kind === 'rich' || kind === 'link-label') && !el.textContent?.trim() && isEditableLeaf(el)
|
|
106
|
+
el.toggleAttribute('data-u90-empty', empty)
|
|
107
|
+
}
|
|
108
|
+
const markEmptyFields = () => {
|
|
109
|
+
for (const el of doc.querySelectorAll<HTMLElement>('[data-u90-field][data-u90-edit], [data-u90-empty]')) markEmptyField(el)
|
|
110
|
+
}
|
|
101
111
|
const valueOf = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => (kind === 'rich' ? toRichText(asDomLike(el)) : textWithTokens(el))
|
|
102
112
|
const flush = () => {
|
|
103
113
|
if (timer) clearTimeout(timer)
|
|
@@ -249,7 +259,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
249
259
|
/** Outline the element at `path` (field or block) and clear any previous outline. */
|
|
250
260
|
const selection = createSelection(win)
|
|
251
261
|
const markSelected = (path: string | null, scroll = false, via?: string[]) => {
|
|
252
|
-
selection.select(path, scroll)
|
|
262
|
+
selection.select(path, scroll, via)
|
|
253
263
|
opts.onSelect?.(path, via)
|
|
254
264
|
}
|
|
255
265
|
|
|
@@ -386,12 +396,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
386
396
|
// (a menu toggle) still works while the Canvas is open.
|
|
387
397
|
e.preventDefault()
|
|
388
398
|
}
|
|
389
|
-
const
|
|
390
|
-
for (let node: Element | null = el ?? null; node; node = node.parentElement) {
|
|
391
|
-
const reference = node.getAttribute('data-u90-reference')
|
|
392
|
-
if (reference && reference !== path) ancestors.unshift(reference)
|
|
393
|
-
}
|
|
394
|
-
const via = ancestors.length > 0 && ancestors.length <= 5 ? ancestors : undefined
|
|
399
|
+
const via = selectionVia(el ?? null, path)
|
|
395
400
|
markSelected(path, false, via)
|
|
396
401
|
send({ type: 'u90:select', path, ...(via ? {via} : {}) })
|
|
397
402
|
const edit = el?.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label' | null
|
|
@@ -408,6 +413,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
408
413
|
if (opts.inlineEditing === false) return
|
|
409
414
|
const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
|
|
410
415
|
if (el) {
|
|
416
|
+
markEmptyField(el)
|
|
411
417
|
schedule(el)
|
|
412
418
|
// The Canvas owns save debouncing. Its inspector needs each input immediately, including
|
|
413
419
|
// during continuous typing and just before focus crosses into the parent document.
|
|
@@ -571,7 +577,10 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
571
577
|
const draft = instruction.kind === 'value' ? instruction : instruction.draft
|
|
572
578
|
if (draft) {
|
|
573
579
|
let handled = false
|
|
574
|
-
|
|
580
|
+
const order = instruction.kind === 'move' ? snapshotOrder(draft, instruction.path) : undefined
|
|
581
|
+
for (const owner of registry.draftValues) if (owner.document === doc) handled = owner.preview(draft, order) || handled
|
|
582
|
+
// Update every rendered instance; hidden destinations only exist in the full snapshot.
|
|
583
|
+
if (order) for (const owner of registry.blockOrders) if (owner.document === doc) handled = !!owner.reorder?.(order) || handled
|
|
575
584
|
if (handled || instruction.kind === 'value') return
|
|
576
585
|
}
|
|
577
586
|
const list = instruction.kind === 'list' ? instruction : instruction.list
|
|
@@ -591,9 +600,11 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
591
600
|
if (handled) return
|
|
592
601
|
}
|
|
593
602
|
if (instruction.kind === 'move') {
|
|
603
|
+
let handled = false
|
|
594
604
|
for (const owner of registry.blockOrders) {
|
|
595
|
-
if (owner.document === doc
|
|
605
|
+
if (owner.document === doc) handled = owner.move(instruction.path, instruction.before) || handled
|
|
596
606
|
}
|
|
607
|
+
if (handled) return
|
|
597
608
|
}
|
|
598
609
|
if (refreshing && MOVES_NODES.has(instruction.kind)) return
|
|
599
610
|
if ((instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label') && editing?.getAttribute('data-u90-field') === instruction.path && !doc.hasFocus()) {
|
|
@@ -962,6 +973,12 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
962
973
|
|
|
963
974
|
doc.head.appendChild(style)
|
|
964
975
|
if (opts.inlineEditing === false) doc.documentElement.setAttribute('data-u90-inspector-only', '')
|
|
976
|
+
markEmptyFields()
|
|
977
|
+
const Observer = (win as Window & typeof globalThis).MutationObserver
|
|
978
|
+
if (Observer) {
|
|
979
|
+
emptyFields = new Observer(markEmptyFields)
|
|
980
|
+
emptyFields.observe(doc.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['data-u90-field', 'data-u90-edit'] })
|
|
981
|
+
}
|
|
965
982
|
selection.start()
|
|
966
983
|
applyDraftLanguage()
|
|
967
984
|
doc.addEventListener('click', onClick, true)
|
|
@@ -996,6 +1013,9 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
996
1013
|
stopped = true
|
|
997
1014
|
endEditing() // what was typed goes out, and no element is left contenteditable
|
|
998
1015
|
flush()
|
|
1016
|
+
emptyFields?.disconnect()
|
|
1017
|
+
emptyFields = null
|
|
1018
|
+
doc.querySelectorAll('[data-u90-empty]').forEach(el => el.removeAttribute('data-u90-empty'))
|
|
999
1019
|
releaseViewport()
|
|
1000
1020
|
style.remove()
|
|
1001
1021
|
if (opts.inlineEditing === false) doc.documentElement.removeAttribute('data-u90-inspector-only')
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type Runtime = { version: 1; editing: boolean; inlineEditing: boolean; testPreview: boolean }
|
|
2
|
+
export type CanvasFieldDiagnostic = {
|
|
3
|
+
path: string
|
|
4
|
+
status: 'reachable' | 'occluded' | 'hidden' | 'no-box' | 'offscreen' | 'untested'
|
|
5
|
+
samples: number
|
|
6
|
+
hits: number
|
|
7
|
+
blockers: string[]
|
|
8
|
+
}
|
|
9
|
+
export type CanvasDiagnostic = {
|
|
10
|
+
runtime: Runtime | null
|
|
11
|
+
bridgeMounted: boolean
|
|
12
|
+
viewport: { width: number; height: number }
|
|
13
|
+
fields: CanvasFieldDiagnostic[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Read-only snapshot of the existing frame. It neither starts a bridge nor changes selection.
|
|
17
|
+
* Reachability samples only the current viewport/state, not typing, saving or hidden slides. */
|
|
18
|
+
export function inspectCanvas(doc: Document = document): CanvasDiagnostic {
|
|
19
|
+
const win = doc.defaultView
|
|
20
|
+
const viewport = { width: win?.innerWidth ?? 0, height: win?.innerHeight ?? 0 }
|
|
21
|
+
let runtime: Runtime | null = null
|
|
22
|
+
const markers = doc.querySelectorAll('[data-u90-bridge]')
|
|
23
|
+
if (markers.length === 1) try {
|
|
24
|
+
const value = JSON.parse(markers[0].getAttribute('data-u90-bridge') ?? '')
|
|
25
|
+
if (value.version === 1 && ['editing', 'inlineEditing', 'testPreview'].every(key => typeof value[key] === 'boolean'))
|
|
26
|
+
runtime = { version: 1, editing: value.editing, inlineEditing: value.inlineEditing, testPreview: value.testPreview }
|
|
27
|
+
} catch { /* An absent or invalid root is not a verified runtime. */ }
|
|
28
|
+
const fields = Array.from(doc.querySelectorAll<HTMLElement>('[data-u90-field]'), el => {
|
|
29
|
+
const path = el.getAttribute('data-u90-field') ?? ''
|
|
30
|
+
const result: CanvasFieldDiagnostic = { path, status: 'untested', samples: 0, hits: 0, blockers: [] }
|
|
31
|
+
// Visibility is inherited but can be overridden by a visible descendant.
|
|
32
|
+
const visibility = win?.getComputedStyle(el).visibility
|
|
33
|
+
if (visibility === 'hidden' || visibility === 'collapse') return { ...result, status: 'hidden' as const }
|
|
34
|
+
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
|
|
35
|
+
const style = win?.getComputedStyle(node)
|
|
36
|
+
if (node.hidden || style?.display === 'none' || style?.opacity === '0') return { ...result, status: 'hidden' as const }
|
|
37
|
+
}
|
|
38
|
+
let rects = Array.from(el.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0)
|
|
39
|
+
if (!rects.length && win?.getComputedStyle(el).display === 'contents') {
|
|
40
|
+
rects = Array.from(el.querySelectorAll('*')).filter(child => child.closest('[data-u90-field]') === el)
|
|
41
|
+
.flatMap(child => Array.from(child.getClientRects())).filter(rect => rect.width > 0 && rect.height > 0)
|
|
42
|
+
}
|
|
43
|
+
if (!rects.length) return { ...result, status: 'no-box' as const }
|
|
44
|
+
const visible = rects.map(rect => ({ left: Math.max(0, rect.left), right: Math.min(viewport.width, rect.right), top: Math.max(0, rect.top), bottom: Math.min(viewport.height, rect.bottom) })).filter(rect => rect.right > rect.left && rect.bottom > rect.top)
|
|
45
|
+
if (!visible.length) return { ...result, status: 'offscreen' as const }
|
|
46
|
+
if (typeof doc.elementFromPoint !== 'function') return result
|
|
47
|
+
const blockers = new Set<string>()
|
|
48
|
+
for (const rect of visible.slice(0, 3)) for (const x of [.2, .5, .8]) for (const y of [.2, .5, .8]) {
|
|
49
|
+
result.samples++
|
|
50
|
+
const hit = doc.elementFromPoint(rect.left + (rect.right - rect.left) * x, rect.top + (rect.bottom - rect.top) * y)
|
|
51
|
+
const selected = hit?.closest('[data-u90-field], [data-u90-block]')
|
|
52
|
+
if (selected?.getAttribute('data-u90-field') === path) result.hits++
|
|
53
|
+
else if (hit) blockers.add(`${hit.tagName.toLowerCase()}${Array.from(hit.classList).slice(0, 3).map(name => `.${name}`).join('')}`)
|
|
54
|
+
else blockers.add('(no element)')
|
|
55
|
+
}
|
|
56
|
+
result.blockers = [...blockers].slice(0, 8)
|
|
57
|
+
result.status = result.hits ? 'reachable' : 'occluded'
|
|
58
|
+
return result
|
|
59
|
+
})
|
|
60
|
+
return { runtime, bridgeMounted: !!doc.querySelector('head style[data-u90-editing]'), viewport, fields }
|
|
61
|
+
}
|
package/src/bridge/index.ts
CHANGED
|
@@ -13,3 +13,6 @@ export { DraftBlocks } from './DraftBlocks'
|
|
|
13
13
|
|
|
14
14
|
/** React-owned previews for client components reading list content inside a draft block. */
|
|
15
15
|
export { useDraftContent, useDraftPreview, DraftReferences, applyNativeDraft, type NativeDraftPatch } from './useDraftContent'
|
|
16
|
+
export { reorderNativeDraft, type NativeDraftOrder } from './nativeOrder'
|
|
17
|
+
/** Read-only diagnostics for the current frame; hidden/offscreen states remain untested. */
|
|
18
|
+
export { inspectCanvas, type CanvasDiagnostic, type CanvasFieldDiagnostic } from './diagnostics'
|
|
@@ -1,57 +1,44 @@
|
|
|
1
1
|
import { isLeaf, type EntryRead, type ExpandCtx, type FieldDecl, type Id, type MediaRecord, type PageRecord, type Schema } from '../elements/define'
|
|
2
2
|
import { getContainer } from '../elements/registry'
|
|
3
3
|
import { expandField } from '../elements/ops/expand'
|
|
4
|
+
import { draftGroupMetadata, rememberDraftList } from '../elements/ops/draftPaths'
|
|
4
5
|
import { applyQueryPicks, type QueryStored } from '../elements/leaves/query'
|
|
5
6
|
import type { EntriesRead } from '../elements/leaves/selection'
|
|
6
7
|
import type { DraftSnapshot } from './protocol'
|
|
8
|
+
import { reorderNativeDraft, type DraftOrder } from './nativeOrder'
|
|
9
|
+
import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
|
|
10
|
+
export { nativePath, safeDraftPath } from './nativePath'
|
|
7
11
|
|
|
8
12
|
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
9
13
|
const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
-
export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
|
|
11
|
-
|
|
12
|
-
/** Native groups do not carry paths; their children do. Infer only when the children agree. */
|
|
13
|
-
export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
|
|
14
|
-
if (!value || typeof value !== 'object' || seen.has(value)) return
|
|
15
|
-
seen.add(value)
|
|
16
|
-
if (object(value) && typeof value.$path === 'string') return safeDraftPath(value.$path) ? value.$path : undefined
|
|
17
|
-
const candidates = new Set<string>()
|
|
18
|
-
for (const [key, child] of Object.entries(value)) {
|
|
19
|
-
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
20
|
-
const at = nativePath(child, seen)
|
|
21
|
-
if (!at) continue
|
|
22
|
-
if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
|
|
23
|
-
else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
|
|
24
|
-
}
|
|
25
|
-
return candidates.size === 1 ? [...candidates][0] : undefined
|
|
26
|
-
}
|
|
27
|
-
|
|
28
14
|
/** Immutable path replacement across any mixture of native document, entry and field reads. */
|
|
29
|
-
export function applyNativeDraft<T>(source: T, path: string, replacement: unknown): T {
|
|
15
|
+
export function applyNativeDraft<T>(source: T, path: string, replacement: unknown, rootPath?: string): T {
|
|
30
16
|
if (!safeDraftPath(path)) return source
|
|
31
|
-
const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
|
|
17
|
+
const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
|
|
32
18
|
const at = nativePath(value) ?? parent
|
|
33
19
|
if (at === path || parent === path) return replacement
|
|
34
20
|
if (!value || typeof value !== 'object' || seen.has(value)) return value
|
|
35
21
|
const nextSeen = new Set(seen).add(value)
|
|
22
|
+
const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
|
|
36
23
|
let result: any = value
|
|
37
24
|
for (const [key, child] of Object.entries(value)) {
|
|
38
25
|
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
39
|
-
const
|
|
40
|
-
const next = visit(child,
|
|
26
|
+
const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
|
|
27
|
+
const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
|
|
41
28
|
if (next !== child) {
|
|
42
|
-
if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
|
|
29
|
+
if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
|
|
43
30
|
result[key] = next
|
|
44
31
|
}
|
|
45
32
|
}
|
|
46
33
|
return result
|
|
47
34
|
}
|
|
48
|
-
return visit(source) as T
|
|
35
|
+
return visit(source, nativePath(source) ?? (rootPath && safeDraftPath(rootPath) ? rootPath : undefined)) as T
|
|
49
36
|
}
|
|
50
37
|
|
|
51
38
|
/** A local index of real native reads. Missing references remain missing until the server answers. */
|
|
52
39
|
export class NativeDraftReferences {
|
|
53
40
|
private reads = new Map<string, unknown>()
|
|
54
|
-
private pending = new Map<string, unknown>()
|
|
41
|
+
private pending = new Map<string, { kind: 'value'; path: string; value: unknown } | { kind: 'order'; path: string; ids: string[] }>()
|
|
55
42
|
private media = new Map<string, MediaRecord>()
|
|
56
43
|
private pages = new Map<string, PageRecord>()
|
|
57
44
|
private entries = new Map<string, EntryRead>()
|
|
@@ -60,11 +47,14 @@ export class NativeDraftReferences {
|
|
|
60
47
|
|
|
61
48
|
add(values: unknown): void {
|
|
62
49
|
const seen = new Set<object>()
|
|
63
|
-
const visit = (value: unknown, parent?: string) => {
|
|
50
|
+
const visit = (value: unknown, parent?: string, inheritedListPaths?: readonly string[]) => {
|
|
64
51
|
if (parent) this.reads.set(parent, value)
|
|
52
|
+
if (parent?.startsWith('single:') && !parent.includes('/')) this.singles.set(parent.slice(7), value)
|
|
65
53
|
if (!value || typeof value !== 'object' || seen.has(value)) return
|
|
66
54
|
seen.add(value)
|
|
67
55
|
const at = nativePath(value) ?? parent
|
|
56
|
+
const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
|
|
57
|
+
if (Array.isArray(value)) rememberDraftList(value, paths)
|
|
68
58
|
if (at) this.reads.set(at, value)
|
|
69
59
|
if (object(value)) {
|
|
70
60
|
if (at?.startsWith('single:') && !at.includes('/')) this.singles.set(at.slice(7), value)
|
|
@@ -96,21 +86,38 @@ export class NativeDraftReferences {
|
|
|
96
86
|
}
|
|
97
87
|
for (const [key, child] of Object.entries(value)) {
|
|
98
88
|
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
99
|
-
const
|
|
100
|
-
visit(child,
|
|
89
|
+
const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
|
|
90
|
+
visit(child, childPath, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
|
|
101
91
|
}
|
|
102
92
|
}
|
|
103
93
|
visit(values)
|
|
104
|
-
for (const
|
|
94
|
+
for (const patch of this.pending.values()) {
|
|
95
|
+
if (patch.kind === 'value') this.applyPatch(patch.path, patch.value)
|
|
96
|
+
else this.applyOrder(patch)
|
|
97
|
+
}
|
|
105
98
|
}
|
|
106
99
|
|
|
107
100
|
patch(path: string, value: unknown): void {
|
|
108
101
|
if (!path.startsWith('entry:') && !path.startsWith('single:')) return
|
|
109
|
-
for (const key of this.pending
|
|
110
|
-
this.pending.set(path, value)
|
|
102
|
+
for (const [key, patch] of this.pending) if (patch.path === path || patch.path.startsWith(`${path}/`)) this.pending.delete(key)
|
|
103
|
+
this.pending.set(`value:${path}`, { kind: 'value', path, value })
|
|
111
104
|
this.applyPatch(path, value)
|
|
112
105
|
}
|
|
113
106
|
|
|
107
|
+
order(order: DraftOrder): void {
|
|
108
|
+
const key = `order:${order.path}`
|
|
109
|
+
this.pending.delete(key)
|
|
110
|
+
this.pending.set(key, { kind: 'order', ...order })
|
|
111
|
+
this.applyOrder(order)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private applyOrder(order: DraftOrder): void {
|
|
115
|
+
for (const [key, read] of this.reads) this.reads.set(key, reorderNativeDraft(read, order))
|
|
116
|
+
for (const [key, entry] of this.entries) this.entries.set(key, reorderNativeDraft(entry, order))
|
|
117
|
+
for (const [key, single] of this.singles) this.singles.set(key, reorderNativeDraft(single, order))
|
|
118
|
+
for (const [key, query] of this.queries) this.queries.set(key, reorderNativeDraft(query, order))
|
|
119
|
+
}
|
|
120
|
+
|
|
114
121
|
beforeRefresh(): void { this.pending.clear() }
|
|
115
122
|
|
|
116
123
|
settle(values: unknown): void {
|
|
@@ -124,9 +131,9 @@ export class NativeDraftReferences {
|
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
private applyPatch(path: string, value: unknown): void {
|
|
127
|
-
for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value))
|
|
128
|
-
for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value))
|
|
129
|
-
for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value))
|
|
134
|
+
for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value, key))
|
|
135
|
+
for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value, `entry:${entry.id}`))
|
|
136
|
+
for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value, `single:${key}`))
|
|
130
137
|
for (const [key, query] of this.queries) this.queries.set(key, applyNativeDraft(query, path, value))
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -162,8 +169,11 @@ export class NativeDraftReferences {
|
|
|
162
169
|
return expandField(schema, field, value, ctx(at))
|
|
163
170
|
}
|
|
164
171
|
const children = getContainer(field.container).children(field, field.container === 'u90/group' && !object(value) ? {} : value, schema)
|
|
165
|
-
if (field.container === 'u90/group')
|
|
166
|
-
|
|
172
|
+
if (field.container === 'u90/group') {
|
|
173
|
+
const fields = Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
|
|
174
|
+
return { ...draftGroupMetadata(field.fields, at, fields), ...fields }
|
|
175
|
+
}
|
|
176
|
+
const expanded = children.map((child, index) => {
|
|
167
177
|
const path = `${at}/${child.key}`
|
|
168
178
|
const inner = expand(child.decl, child.value, path)
|
|
169
179
|
if (field.container === 'u90/list') return isLeaf(field.item) ? inner : { _id: child.key, $path: path, ...inner as object }
|
|
@@ -171,6 +181,7 @@ export class NativeDraftReferences {
|
|
|
171
181
|
const metadata = { _id: child.key, _block: item._block, ...(typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}) }
|
|
172
182
|
return Object.hasOwn(schema.blocks, String(item._block)) ? { ...metadata, $path: path, ...inner as object } : metadata
|
|
173
183
|
})
|
|
184
|
+
return field.container === 'u90/list' ? rememberDraftList(expanded, children.map(child => `${at}/${child.key}`)) : expanded
|
|
174
185
|
}
|
|
175
186
|
return expand(snapshot.field, snapshot.value, snapshot.path)
|
|
176
187
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { rememberDraftList } from '../elements/ops/draftPaths'
|
|
2
|
+
import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
|
|
3
|
+
import type { DraftSnapshot } from './protocol'
|
|
4
|
+
|
|
5
|
+
export type DraftOrder = { path: string; ids: string[] }
|
|
6
|
+
export type NativeDraftOrder = DraftOrder
|
|
7
|
+
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
8
|
+
|
|
9
|
+
/** A move's snapshot may start above its zone (for example a zone inside a group).
|
|
10
|
+
* Read only the target's IDs; reconstructing its fields would discard site adapters. */
|
|
11
|
+
export function snapshotOrder(snapshot: DraftSnapshot, movedPath: string): DraftOrder | undefined {
|
|
12
|
+
const path = movedPath.slice(0, movedPath.lastIndexOf('/'))
|
|
13
|
+
if (!safeDraftPath(path) || (path !== snapshot.path && !path.startsWith(`${snapshot.path}/`))) return
|
|
14
|
+
let value: unknown = snapshot.value
|
|
15
|
+
for (const key of path.slice(snapshot.path.length).split('/').filter(Boolean)) {
|
|
16
|
+
if (unsafe.has(key)) return
|
|
17
|
+
value = Array.isArray(value) ? value.find(item => item?._id === key)
|
|
18
|
+
: value && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : undefined
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(value) || !value.every(item => item && typeof item._id === 'string' && typeof item._block === 'string')) return
|
|
21
|
+
const ids = value.map(item => item._id as string)
|
|
22
|
+
if (new Set(ids).size !== ids.length || !ids.includes(movedPath.slice(path.length + 1))) return
|
|
23
|
+
return { path, ids }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Preserve membership and object identity, including items a newer render introduced. */
|
|
27
|
+
export function ordered<T>(items: T[], ids: string[], id: (item: T) => string | undefined): T[] {
|
|
28
|
+
const ranks = new Map(ids.map((key, index) => [key, index]))
|
|
29
|
+
const known = items.filter(item => ranks.has(id(item)!)).sort((a, b) => ranks.get(id(a)!)! - ranks.get(id(b)!)!)
|
|
30
|
+
let index = 0
|
|
31
|
+
const next = items.map(item => ranks.has(id(item)!) ? known[index++]! : item)
|
|
32
|
+
return next.every((item, i) => item === items[i]) ? items : next
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reorder each native/presentation alias of a zone, leaving its content intact. */
|
|
36
|
+
export function applyNativeOrder<T>(source: T, order: DraftOrder): { value: T; matched: boolean } {
|
|
37
|
+
if (!safeDraftPath(order.path)) return { value: source, matched: false }
|
|
38
|
+
let matched = false
|
|
39
|
+
const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
|
|
40
|
+
if (!value || typeof value !== 'object' || seen.has(value) || '$$typeof' in value) return value
|
|
41
|
+
const at = nativePath(value) ?? parent
|
|
42
|
+
const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
|
|
43
|
+
if (Array.isArray(value) && at === order.path) {
|
|
44
|
+
matched = true
|
|
45
|
+
const indexed = value.map((item, index) => ({ item, index }))
|
|
46
|
+
const sorted = ordered(indexed, order.ids, ({ item, index }) => {
|
|
47
|
+
const path = paths?.[index] ?? nativePath(item)
|
|
48
|
+
return path?.startsWith(`${order.path}/`) ? path.slice(order.path.length + 1)
|
|
49
|
+
: typeof item?._id === 'string' ? item._id : undefined
|
|
50
|
+
})
|
|
51
|
+
return sorted === indexed ? value : rememberDraftList(sorted.map(({ item }) => item), paths ? sorted.map(({ index }) => paths[index]!) : undefined)
|
|
52
|
+
}
|
|
53
|
+
const nextSeen = new Set(seen).add(value)
|
|
54
|
+
let result: any = value
|
|
55
|
+
for (const [key, child] of Object.entries(value)) {
|
|
56
|
+
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
57
|
+
const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
|
|
58
|
+
const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
|
|
59
|
+
if (next !== child) {
|
|
60
|
+
if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
|
|
61
|
+
result[key] = next
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
return { value: visit(source) as T, matched }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Opt-in reorder adapter for presentation collections whose blocks retain native $path values. */
|
|
70
|
+
export function reorderNativeDraft<T>(source: T, order: NativeDraftOrder): T {
|
|
71
|
+
return applyNativeOrder(source, order).value
|
|
72
|
+
}
|