@unitninety/site-kit 0.0.0-stage → 1.6.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 +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import type { LinkTo } from '../elements/leaves/link'
|
|
2
|
+
import { activeBlock, activeMarks, applyCaret, caretTargetIn, isEditableLeaf } from './caret'
|
|
3
|
+
import { toRichText, type DomLike } from './lexicalDom'
|
|
4
|
+
import { applyPreview, type Undo } from './preview'
|
|
5
|
+
import { BRIDGE_VERSION, MAX_OFFENDER_LENGTH, MAX_OFFENDERS, parseCanvasMessage, type BridgeStamp, type PreviewInstruction, type SiteMessage } from './protocol'
|
|
6
|
+
import { registry } from './registry'
|
|
7
|
+
import { EDITING_CSS } from './styles'
|
|
8
|
+
|
|
9
|
+
export type BridgeOptions = {
|
|
10
|
+
platformOrigin: string
|
|
11
|
+
parent: Pick<Window, 'postMessage'>
|
|
12
|
+
refresh: () => void
|
|
13
|
+
onSelect?: (path: string | null) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEBOUNCE = 150
|
|
17
|
+
|
|
18
|
+
let warnedOfCeiling = false
|
|
19
|
+
/** Development only, and once: see `setRefreshing`. */
|
|
20
|
+
const warnOnceThatRefreshIsSlow = () => {
|
|
21
|
+
if (warnedOfCeiling || (typeof process !== 'undefined' && process.env.NODE_ENV === 'production')) return
|
|
22
|
+
warnedOfCeiling = true
|
|
23
|
+
console.warn('[u90] a refresh is still in flight after 5s; React-owned block moves remain available, but reconciliation waits for the actual commit (BridgeClient isPending).')
|
|
24
|
+
}
|
|
25
|
+
/** For tests: forget that the warning has been given. */
|
|
26
|
+
export const resetCeilingWarning = () => {
|
|
27
|
+
warnedOfCeiling = false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A real element as the structural node `toRichText` walks (`lexicalDom.ts` takes a `DomLike`, not
|
|
32
|
+
* an `Element`, so it can be tested without a browser). Copied from `legacy/bridge.tsx`'s
|
|
33
|
+
* `asDomLike`; before it was ported, the live element went to `toRichText` as it was, and every
|
|
34
|
+
* rich `edit` threw on its `HTMLCollection` (`nodes.flatMap is not a function`) when it flushed.
|
|
35
|
+
*/
|
|
36
|
+
const asDomLike = (node: Node): DomLike => {
|
|
37
|
+
if (node.nodeType === 3) return { text: node.textContent ?? '' }
|
|
38
|
+
const element = node as HTMLElement
|
|
39
|
+
const href = element.getAttribute?.('href')
|
|
40
|
+
const singleKey = element.getAttribute?.('data-single-key')
|
|
41
|
+
// `data-u90-link` (stamped by `RichText.tsx` in draft, and by `applyFormat`'s own `link` action
|
|
42
|
+
// below) is what lets `toRichText` read an untouched or freshly-linked anchor's `LinkTo` back
|
|
43
|
+
// exactly, rather than reclassifying it from `href` alone — forwarded here so it survives the
|
|
44
|
+
// trip from a live element into the structural `DomLike` `toRichText` walks.
|
|
45
|
+
const linkTarget = element.getAttribute?.('data-u90-link')
|
|
46
|
+
return {
|
|
47
|
+
attributes: {
|
|
48
|
+
...(href ? { href } : {}),
|
|
49
|
+
...(singleKey ? { 'data-single-key': singleKey } : {}),
|
|
50
|
+
...(linkTarget ? { 'data-u90-link': linkTarget } : {}),
|
|
51
|
+
},
|
|
52
|
+
children: [...(element.childNodes ?? [])].map(asDomLike),
|
|
53
|
+
tag: element.tagName?.toLowerCase(),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A text leaf's value as it is stored: its text, with each Single chip a draft render marked
|
|
59
|
+
* (`data-single-key`) written back as its `{key}` token, so an edit never saves the number a token
|
|
60
|
+
* resolved to (R38), and each non-breaking space normalised to a plain one — the same thing
|
|
61
|
+
* `lexicalDom.ts`'s `inlines` does on the rich path, for the same reason: the words on record must
|
|
62
|
+
* match the words typed, not what the browser inserted for a second consecutive space.
|
|
63
|
+
*/
|
|
64
|
+
export const textWithTokens = (node: Node): string => {
|
|
65
|
+
if (node.nodeType === 3) return (node.textContent ?? '').replace(/ /g, ' ')
|
|
66
|
+
if (node.nodeType !== 1) return ''
|
|
67
|
+
const key = (node as Element).getAttribute('data-single-key')
|
|
68
|
+
if (key && /^[a-z][a-zA-Z0-9_-]*$/.test(key)) return `{${key}}`
|
|
69
|
+
return [...node.childNodes].map(textWithTokens).join('')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `Omit` is not distributive over a union (`keyof (A | B)` is the *intersection* of their keys),
|
|
74
|
+
* so `Omit<SiteMessage, 'version'>` would collapse the whole `SiteMessage` union down to the
|
|
75
|
+
* fields every variant happens to share — which is none of them, `version` aside — rather than
|
|
76
|
+
* "each variant, minus `version`". This re-distributes over the union first.
|
|
77
|
+
*/
|
|
78
|
+
type DistributiveOmit<T, K extends keyof never> = T extends unknown ? Omit<T, K> : never
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The DOM half of the bridge, independent of React so it can be driven from jsdom in tests.
|
|
82
|
+
* `bridge/client.tsx`'s `BridgeClient` is the only caller in the running app; `bridge/createBridge.test.ts`
|
|
83
|
+
* drives it directly.
|
|
84
|
+
*/
|
|
85
|
+
export function createBridge(win: Window, opts: BridgeOptions) {
|
|
86
|
+
const doc = win.document
|
|
87
|
+
/**
|
|
88
|
+
* The stamp the last `u90:hello` carried, put on every message sent after it; null until a hello
|
|
89
|
+
* carries one, and again after a hello that carries none. Without one, every message is the bytes
|
|
90
|
+
* it was before 1.3.0.
|
|
91
|
+
*/
|
|
92
|
+
let stamp: BridgeStamp | null = null
|
|
93
|
+
const send = (m: DistributiveOmit<SiteMessage, 'version' | keyof BridgeStamp>) =>
|
|
94
|
+
opts.parent.postMessage((stamp ? { ...m, ...stamp, version: BRIDGE_VERSION } : { ...m, version: BRIDGE_VERSION }) as SiteMessage, opts.platformOrigin)
|
|
95
|
+
|
|
96
|
+
let pending: { path: string; kind: 'text' | 'rich' | 'link-label'; el: HTMLElement } | null = null
|
|
97
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
98
|
+
const valueOf = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => (kind === 'rich' ? toRichText(asDomLike(el)) : textWithTokens(el))
|
|
99
|
+
const flush = () => {
|
|
100
|
+
if (timer) clearTimeout(timer)
|
|
101
|
+
timer = null
|
|
102
|
+
if (pending) {
|
|
103
|
+
send({ type: 'u90:edit', path: pending.path, kind: pending.kind, value: valueOf(pending.el, pending.kind) })
|
|
104
|
+
pending = null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Debounces an `edit` for the marked field holding `el` — typing, or a formatting command applied to it. */
|
|
108
|
+
const schedule = (el: HTMLElement) => {
|
|
109
|
+
pending = { path: el.getAttribute('data-u90-field')!, kind: el.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label', el }
|
|
110
|
+
if (timer) clearTimeout(timer)
|
|
111
|
+
timer = setTimeout(flush, DEBOUNCE)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* ── Ported from `legacy/bridge.tsx` (R40): which element is being edited, the selection inside
|
|
116
|
+
* it, and the formatting commands the Canvas's toolbar sends. ──
|
|
117
|
+
*/
|
|
118
|
+
/** The one element the bridge made `contenteditable`, or null. */
|
|
119
|
+
let editing: HTMLElement | null = null
|
|
120
|
+
/**
|
|
121
|
+
* The selection inside `editing`, remembered while it still exists (legacy `lastRange`). The
|
|
122
|
+
* formatting toolbar is drawn by the Canvas, in the parent window: pressing it blurs this frame,
|
|
123
|
+
* and a blurred document reports a collapsed selection, so by the time `u90:format` arrives there
|
|
124
|
+
* is nothing left to format unless the range was kept. Only a range inside `editing` is kept — a
|
|
125
|
+
* selection elsewhere on the page is not this field's.
|
|
126
|
+
*/
|
|
127
|
+
let lastRange: Range | null = null
|
|
128
|
+
const rememberRange = () => {
|
|
129
|
+
if (!editing) return
|
|
130
|
+
const selection = win.getSelection?.()
|
|
131
|
+
if (!selection || selection.rangeCount === 0) return
|
|
132
|
+
const range = selection.getRangeAt(0)
|
|
133
|
+
if (editing.contains(range.commonAncestorContainer)) lastRange = range.cloneRange()
|
|
134
|
+
}
|
|
135
|
+
const beginEditing = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => {
|
|
136
|
+
if (editing === el) return
|
|
137
|
+
endEditing()
|
|
138
|
+
editing = el
|
|
139
|
+
el.setAttribute('contenteditable', kind === 'rich' ? 'true' : 'plaintext-only')
|
|
140
|
+
el.focus()
|
|
141
|
+
}
|
|
142
|
+
/** Hands the element back: what was typed goes out first, so releasing the field cannot drop it. */
|
|
143
|
+
const endEditing = () => {
|
|
144
|
+
const el = editing
|
|
145
|
+
if (!el) return
|
|
146
|
+
editing = null
|
|
147
|
+
lastRange = null
|
|
148
|
+
flush()
|
|
149
|
+
el.removeAttribute('contenteditable')
|
|
150
|
+
}
|
|
151
|
+
// Attribute-selector escaping; jsdom has no CSS.escape. Paths are generated (digits, letters,
|
|
152
|
+
// dots, `@`, `~`), so escaping quotes and backslashes is sufficient — same approach as legacy
|
|
153
|
+
// `EditingStyles`' own `escaped = selected?.replace(/"/g, '\\"')`. Used below by `markSelected`
|
|
154
|
+
// for a path, and by `applyFormat`'s `link` action for a freshly-linked url.
|
|
155
|
+
const q = (path: string) => path.replace(/["\\]/g, '\\$&')
|
|
156
|
+
|
|
157
|
+
/** A `DOMRect` narrowed to the four numbers `u90:caret`'s `field` carries (`protocol.ts`). */
|
|
158
|
+
const rectOf = (r: DOMRect): { top: number; left: number; width: number; height: number } => ({ top: r.top, left: r.left, width: r.width, height: r.height })
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What each toolbar button asks the browser for (legacy `COMMANDS`). `execCommand` is deprecated
|
|
162
|
+
* and still the only formatting engine a `contenteditable` has; it emits exactly the tags
|
|
163
|
+
* `lexicalDom.ts` inverts. An action not listed here, and not `single` or `link`, does nothing.
|
|
164
|
+
*/
|
|
165
|
+
const COMMANDS: Record<string, [string, string | undefined]> = {
|
|
166
|
+
bold: ['bold', undefined],
|
|
167
|
+
bullet: ['insertUnorderedList', undefined],
|
|
168
|
+
h1: ['formatBlock', '<h1>'],
|
|
169
|
+
h2: ['formatBlock', '<h2>'],
|
|
170
|
+
h3: ['formatBlock', '<h3>'],
|
|
171
|
+
italic: ['italic', undefined],
|
|
172
|
+
number: ['insertOrderedList', undefined],
|
|
173
|
+
paragraph: ['formatBlock', '<p>'],
|
|
174
|
+
strike: ['strikeThrough', undefined],
|
|
175
|
+
underline: ['underline', undefined],
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Where a structured target's anchor should point. `url`/`email`/`tel` use the target's own
|
|
179
|
+
* address — the same thing `hrefOf` (`elements/leaves/link.ts`) computes for those three kinds,
|
|
180
|
+
* copied rather than called because `hrefOf` also resolves `page`/`entry` against an `ExpandCtx`
|
|
181
|
+
* (the site's page/entry graph) that this in-frame bridge has no access to and no need of: a
|
|
182
|
+
* page or entry target's `href` is never read back (`data-u90-link` carries the exact `to`), it
|
|
183
|
+
* only has to be *some* href so `execCommand('createLink', …)` has an anchor to build.
|
|
184
|
+
*/
|
|
185
|
+
const hrefFor = (to: LinkTo): string => {
|
|
186
|
+
if ('url' in to) return to.url
|
|
187
|
+
if ('email' in to) return `mailto:${to.email}`
|
|
188
|
+
if ('tel' in to) return `tel:${to.tel.replace(/[^+\d]/g, '')}`
|
|
189
|
+
return '#'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `u90:format`, applied only while a rich field is being edited (legacy `applyFormat`): the
|
|
194
|
+
* command is resolved first, so an unknown action has no effect at all; then focus comes back and
|
|
195
|
+
* the remembered range is restored — in that order, since focusing collapses the selection — and
|
|
196
|
+
* the command runs. `single` inserts the non-editable `{key}` chip `lexicalDom.ts` reads back as a
|
|
197
|
+
* token; `link` with a `to` links the selection to that structured target — the anchor's `href`
|
|
198
|
+
* from `hrefFor`, its `data-u90-link` stamped `{ to, newTab: newTab ?? false }` exactly, so the
|
|
199
|
+
* read `asDomLike` takes next is exact rather than reclassified from `href`. `link` with a plain
|
|
200
|
+
* `url` and no `to` behaves as before (`{ to: { url }, newTab: false }`); with neither, unlinks.
|
|
201
|
+
* The edit is scheduled explicitly rather than trusting `execCommand` to fire `input`.
|
|
202
|
+
*/
|
|
203
|
+
const applyFormat = (action: unknown, url?: string, to?: LinkTo, newTab?: boolean) => {
|
|
204
|
+
const el = editing
|
|
205
|
+
if (!el || el.getAttribute('data-u90-edit') !== 'rich' || typeof action !== 'string') return
|
|
206
|
+
let run: () => void
|
|
207
|
+
if (action === 'single') {
|
|
208
|
+
if (!url || !/^[^{}\s]+$/.test(url) || url === 'link') return
|
|
209
|
+
const chip = doc.createElement('span')
|
|
210
|
+
chip.setAttribute('data-single-key', url)
|
|
211
|
+
chip.setAttribute('contenteditable', 'false') // legacy set `.contentEditable`; the attribute is the same thing, and survives a DOM without the IDL property
|
|
212
|
+
chip.textContent = `{${url}}`
|
|
213
|
+
run = () => doc.execCommand('insertHTML', false, chip.outerHTML)
|
|
214
|
+
} else if (action === 'link') {
|
|
215
|
+
run = () => {
|
|
216
|
+
if (!url && !to) {
|
|
217
|
+
doc.execCommand('unlink')
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const href = to ? hrefFor(to) : (url as string)
|
|
221
|
+
const stamp = to ? { to, newTab: newTab ?? false } : { to: { url: href }, newTab: false }
|
|
222
|
+
doc.execCommand('createLink', false, href)
|
|
223
|
+
// The selection this just linked is not reliably still addressable afterwards (execCommand
|
|
224
|
+
// moves/collapses it), so the new anchor is found by the href it was just given rather than
|
|
225
|
+
// through the selection.
|
|
226
|
+
el.querySelectorAll(`a[href="${q(href)}"]:not([data-u90-link])`).forEach((a) =>
|
|
227
|
+
a.setAttribute('data-u90-link', JSON.stringify(stamp)),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
const command = COMMANDS[action]
|
|
232
|
+
if (!command) return
|
|
233
|
+
run = () => doc.execCommand(command[0], false, command[1])
|
|
234
|
+
}
|
|
235
|
+
el.focus({ preventScroll: true })
|
|
236
|
+
const selection = win.getSelection?.()
|
|
237
|
+
if (lastRange && selection) {
|
|
238
|
+
selection.removeAllRanges()
|
|
239
|
+
selection.addRange(lastRange)
|
|
240
|
+
}
|
|
241
|
+
run()
|
|
242
|
+
rememberRange()
|
|
243
|
+
schedule(el)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Outline the element at `path` (field or block) and clear any previous outline. */
|
|
247
|
+
const markSelected = (path: string | null) => {
|
|
248
|
+
doc.querySelectorAll('[data-u90-selected]').forEach((e) => e.removeAttribute('data-u90-selected'))
|
|
249
|
+
if (path) doc.querySelector(`[data-u90-field="${q(path)}"], [data-u90-block="${q(path)}"]`)?.setAttribute('data-u90-selected', '')
|
|
250
|
+
opts.onSelect?.(path)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `u90:select` from the Canvas also fires any `useReveal` listener whose prefix matches. */
|
|
254
|
+
const reveal = (path: string | null) => {
|
|
255
|
+
if (!path) return
|
|
256
|
+
for (const r of registry.reveals) if (path.startsWith(r.prefix)) r.fn()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Whether navigation is enabled (`u90:navigation`); off by default, matching v3's default. */
|
|
260
|
+
/*
|
|
261
|
+
* Whether the Canvas has this screen selected.
|
|
262
|
+
*
|
|
263
|
+
* **True until told otherwise, and the default is the deployment-order question.** A site
|
|
264
|
+
* re-vendored to this kit may be framed by a Canvas that predates `u90:active` and so never
|
|
265
|
+
* sends it. Defaulting to false there would mean every wheel is both forwarded AND prevented,
|
|
266
|
+
* leaving a page that cannot scroll at all — worse than the bug this fixes. Defaulting to
|
|
267
|
+
* true costs that pairing only the ability to pan the Canvas from over a frame, and panning
|
|
268
|
+
* over bare canvas still works.
|
|
269
|
+
*
|
|
270
|
+
* Against a Canvas that does send it the default barely exists: the frame is told on ready,
|
|
271
|
+
* before a client can reach for the wheel.
|
|
272
|
+
*/
|
|
273
|
+
let isActive = true
|
|
274
|
+
let navigationEnabled = false
|
|
275
|
+
|
|
276
|
+
/*
|
|
277
|
+
* `hover`: no v3 equivalent to port — legacy never sent a hover message at all. New in v4, to
|
|
278
|
+
* spec (review round 1, finding 1): `pointerover`/`pointerout` on `document`, capture phase,
|
|
279
|
+
* resolved through the same `[data-u90-field], [data-u90-block]` marker pair `onClick` uses.
|
|
280
|
+
* `data-u90-hover` is the attribute `styles.ts`'s `[data-u90-hover]` rule keys on — set on the
|
|
281
|
+
* hovered element and cleared from whichever one held it before, so at most one element ever
|
|
282
|
+
* carries it.
|
|
283
|
+
*/
|
|
284
|
+
let hoveredEl: HTMLElement | null = null
|
|
285
|
+
let hoveredPath: string | null = null
|
|
286
|
+
const onPointerOver = (e: Event) => {
|
|
287
|
+
const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-field], [data-u90-block]') ?? null
|
|
288
|
+
const path = el?.getAttribute('data-u90-field') ?? el?.getAttribute('data-u90-block') ?? null
|
|
289
|
+
if (path === hoveredPath) return
|
|
290
|
+
hoveredEl?.removeAttribute('data-u90-hover')
|
|
291
|
+
hoveredEl = el
|
|
292
|
+
hoveredPath = path
|
|
293
|
+
hoveredEl?.setAttribute('data-u90-hover', '')
|
|
294
|
+
send({ type: 'u90:hover', path })
|
|
295
|
+
}
|
|
296
|
+
const onPointerOut = (e: Event) => {
|
|
297
|
+
// `pointerover` on whatever comes next handles a marked-to-marked transition; this only
|
|
298
|
+
// has to notice "left every marked element", which a null/unmarked `relatedTarget` means.
|
|
299
|
+
const related = (e as PointerEvent).relatedTarget as Element | null
|
|
300
|
+
if (related?.closest?.('[data-u90-field], [data-u90-block]')) return
|
|
301
|
+
if (hoveredPath === null) return
|
|
302
|
+
hoveredEl?.removeAttribute('data-u90-hover')
|
|
303
|
+
hoveredEl = null
|
|
304
|
+
hoveredPath = null
|
|
305
|
+
send({ type: 'u90:hover', path: null })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
* `caret`: no v3 equivalent to port either — legacy's `u90:caret` carried a `block` kind for
|
|
310
|
+
* its own floating toolbar, a concept this v1 kit has no equivalent of; v4's `u90:caret` is
|
|
311
|
+
* `path, top, left, width, height` (`protocol.ts`), so this reports the live caret's screen
|
|
312
|
+
* rect for whichever field is being typed into. `selectionchange` is a document-level event
|
|
313
|
+
* with no useful detail of its own, so the actual read happens on the next animation frame,
|
|
314
|
+
* guarded at every step per review round 1, finding 1: not (currently) editing at all, no
|
|
315
|
+
* selection, no range, or the field stopped being editable before the frame arrived.
|
|
316
|
+
*/
|
|
317
|
+
const caretField = (): HTMLElement | null => {
|
|
318
|
+
const active = doc.activeElement as HTMLElement | null
|
|
319
|
+
if (!active || !active.hasAttribute('contenteditable')) return null
|
|
320
|
+
return active.closest<HTMLElement>('[data-u90-edit]')
|
|
321
|
+
}
|
|
322
|
+
const onSelectionChange = () => {
|
|
323
|
+
rememberRange()
|
|
324
|
+
if (!caretField()) return
|
|
325
|
+
const nextFrame: (fn: () => void) => void =
|
|
326
|
+
typeof win.requestAnimationFrame === 'function' ? (fn) => win.requestAnimationFrame(fn) : (fn) => win.setTimeout(fn, 0)
|
|
327
|
+
nextFrame(() => {
|
|
328
|
+
const el = caretField()
|
|
329
|
+
const path = el?.getAttribute('data-u90-field')
|
|
330
|
+
if (!el || !path) return
|
|
331
|
+
const selection = win.getSelection?.()
|
|
332
|
+
if (!selection || selection.rangeCount === 0) return
|
|
333
|
+
try {
|
|
334
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect()
|
|
335
|
+
// `marks`/`block`/`field` are Canvas's rich-text toolbar's own business — the active
|
|
336
|
+
// formats and the outline of the field they apply to — so they are only worth reporting
|
|
337
|
+
// for a rich field; a plain text field has no formatting elements or block structure to
|
|
338
|
+
// read, and the caret message it sends stays exactly what it was before this change.
|
|
339
|
+
const anchor = selection.anchorNode
|
|
340
|
+
const rich = el.getAttribute('data-u90-edit') === 'rich' && anchor
|
|
341
|
+
const block = rich ? activeBlock(anchor, el) : undefined
|
|
342
|
+
const extra = rich ? { marks: activeMarks(anchor, el), field: rectOf(el.getBoundingClientRect()), ...(block ? { block } : {}) } : {}
|
|
343
|
+
send({ type: 'u90:caret', path, top: rect.top, left: rect.left, width: rect.width, height: rect.height, ...extra })
|
|
344
|
+
} catch {
|
|
345
|
+
// `getBoundingClientRect` is not implemented in every DOM (jsdom included); a caret
|
|
346
|
+
// report is a nicety, not a correctness requirement, so this is silently skipped.
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const onClick = (e: Event) => {
|
|
352
|
+
const mouse = e as MouseEvent
|
|
353
|
+
|
|
354
|
+
// Navigation on: any plain link click browses, exactly as it would outside the Canvas —
|
|
355
|
+
// ahead of the marked-element selection below, so a marked field wrapped in a link still
|
|
356
|
+
// navigates while navigation is on (ported from `legacy/bridge.tsx`'s `onClick`, which
|
|
357
|
+
// checks `navigationRef.current` before it ever looks for `FIELD_ATTR`).
|
|
358
|
+
if (navigationEnabled) {
|
|
359
|
+
const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
|
|
360
|
+
const navigable = a && !a.hasAttribute('download') && (!a.target || a.target === '_self')
|
|
361
|
+
if (navigable && !mouse.metaKey && !mouse.ctrlKey && !mouse.shiftKey && !mouse.altKey) {
|
|
362
|
+
const url = new URL(a.href, win.location.href)
|
|
363
|
+
if (url.origin === win.location.origin) {
|
|
364
|
+
e.preventDefault()
|
|
365
|
+
send({ type: 'u90:navigate', path: url.pathname + url.search + url.hash })
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-field], [data-u90-block]')
|
|
372
|
+
const path = el?.getAttribute('data-u90-field') ?? el?.getAttribute('data-u90-block') ?? null
|
|
373
|
+
if (el && el.hasAttribute('data-u90-field')) {
|
|
374
|
+
// Ported from legacy (R40): inside the editor a click on a marked field is for selecting it,
|
|
375
|
+
// so it neither follows a link nor submits a form around it, and the page's own handlers
|
|
376
|
+
// never see it.
|
|
377
|
+
e.preventDefault()
|
|
378
|
+
e.stopPropagation()
|
|
379
|
+
} else if (!navigationEnabled && (e.target as Element | null)?.closest?.('a[href], button')) {
|
|
380
|
+
// Navigation off, a link or button with no field of its own — a card's stretched anchor, the
|
|
381
|
+
// padding around a marked label: swallowed, so the frame never navigates away or submits.
|
|
382
|
+
// Legacy also stopped the event here; v4 only prevents the default, so a block's own button
|
|
383
|
+
// (a menu toggle) still works while the Canvas is open.
|
|
384
|
+
e.preventDefault()
|
|
385
|
+
}
|
|
386
|
+
markSelected(path)
|
|
387
|
+
send({ type: 'u90:select', path })
|
|
388
|
+
const edit = el?.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label' | null
|
|
389
|
+
if (el && edit && isEditableLeaf(el)) {
|
|
390
|
+
// Resolve the caret target before the element becomes editable, apply it after —
|
|
391
|
+
// `contenteditable` changes hit-testing (see `caret.ts`'s `caretTargetIn` doc).
|
|
392
|
+
const target = caretTargetIn(el, mouse.clientX, mouse.clientY)
|
|
393
|
+
beginEditing(el, edit)
|
|
394
|
+
applyCaret(target)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const onInput = (e: Event) => {
|
|
399
|
+
const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
|
|
400
|
+
if (el) {
|
|
401
|
+
schedule(el)
|
|
402
|
+
// The Canvas owns save debouncing. Its inspector needs each input immediately, including
|
|
403
|
+
// during continuous typing and just before focus crosses into the parent document.
|
|
404
|
+
flush()
|
|
405
|
+
// Other copies of a responsive button live in this same frame, which Canvas excludes
|
|
406
|
+
// when echoing an in-place edit. Preview them locally; applyPreview leaves the caret alone.
|
|
407
|
+
if (el.getAttribute('data-u90-edit') === 'link-label')
|
|
408
|
+
preview({ kind: 'link-label', path: el.getAttribute('data-u90-field')!, value: textWithTokens(el) })
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/*
|
|
413
|
+
* Ported from legacy `onFocusOut` (R40). Leaving the document is not leaving the field: pressing
|
|
414
|
+
* the Canvas's formatting toolbar, switching applications or clicking the browser's own chrome all
|
|
415
|
+
* fire `focusout` here exactly as tabbing to the next field does, and ending the edit on them made
|
|
416
|
+
* the toolbar inert. `relatedTarget` tells them apart — focus moving out of this document reports
|
|
417
|
+
* none — so the element stays editable and its selection is remembered for `u90:format`. Only
|
|
418
|
+
* focus moving to another element of this document, outside the field, ends the edit.
|
|
419
|
+
*/
|
|
420
|
+
const onBlur = (e: Event) => {
|
|
421
|
+
if (!editing || e.target !== editing) return
|
|
422
|
+
const next = (e as FocusEvent).relatedTarget as Node | null
|
|
423
|
+
if (!next || !doc.contains(next)) {
|
|
424
|
+
rememberRange()
|
|
425
|
+
// The inspector can write the same field next. Hand off the current value now, while
|
|
426
|
+
// retaining the caret for a formatting-toolbar command that merely borrowed focus.
|
|
427
|
+
flush()
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
if (editing.contains(next)) return
|
|
431
|
+
endEditing()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* How to take back every preview applied since the last refresh, oldest first. React writes only
|
|
436
|
+
* what changed in its own model of the page, which a preview does not touch, so the refresh is
|
|
437
|
+
* authoritative only over a page that is exactly React's again: `u90:refresh` replays these newest
|
|
438
|
+
* first before it asks for the refresh (see `applyPreview`).
|
|
439
|
+
*/
|
|
440
|
+
let journal: Undo[] = []
|
|
441
|
+
let previewed: PreviewInstruction[] = []
|
|
442
|
+
let stopped = false
|
|
443
|
+
/** Text and media previews already journaled for a path: the first undo restores the original, so later ones add nothing. */
|
|
444
|
+
let coalesced = new Set<string>()
|
|
445
|
+
/** Keep the live editor across an undo/React commit. Clones isolate its current
|
|
446
|
+
* typing from the original nodes React is about to update; a fresh undo retains
|
|
447
|
+
* those server nodes so ending the edit still allows an authoritative refresh. */
|
|
448
|
+
const preserveEditing = (): null | (() => void) => {
|
|
449
|
+
const el = editing
|
|
450
|
+
if (!el?.isConnected) return null
|
|
451
|
+
const children = [...el.childNodes].map((node) => node.cloneNode(true))
|
|
452
|
+
const bookmark = (node: Node | null, offset: number) => {
|
|
453
|
+
if (!node || !el.contains(node)) return null
|
|
454
|
+
const path: number[] = []
|
|
455
|
+
for (let current = node; current !== el; current = current.parentNode!) {
|
|
456
|
+
path.unshift(Array.prototype.indexOf.call(current.parentNode!.childNodes, current))
|
|
457
|
+
}
|
|
458
|
+
return { path, offset }
|
|
459
|
+
}
|
|
460
|
+
const selection = win.getSelection?.()
|
|
461
|
+
const liveAnchor = bookmark(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0)
|
|
462
|
+
const liveFocus = bookmark(selection?.focusNode ?? null, selection?.focusOffset ?? 0)
|
|
463
|
+
const anchor = liveAnchor ?? bookmark(lastRange?.startContainer ?? null, lastRange?.startOffset ?? 0)
|
|
464
|
+
const focus = liveFocus ?? bookmark(lastRange?.endContainer ?? null, lastRange?.endOffset ?? 0)
|
|
465
|
+
const field = el.getAttribute('data-u90-field')!
|
|
466
|
+
return () => {
|
|
467
|
+
if (editing !== el || !el.isConnected) return
|
|
468
|
+
const serverChildren = [...el.childNodes]
|
|
469
|
+
el.replaceChildren(...children)
|
|
470
|
+
// An inspector preview predating these keystrokes must never be replayed.
|
|
471
|
+
previewed = previewed.filter((entry) => !((entry.kind === 'text' || entry.kind === 'rich' || entry.kind === 'link-label') && entry.path === field))
|
|
472
|
+
coalesced.add(`${el.getAttribute('data-u90-edit')}:${field}`)
|
|
473
|
+
journal.push(() => el.replaceChildren(...serverChildren))
|
|
474
|
+
if (el.getAttribute('data-u90-edit') === 'link-label') {
|
|
475
|
+
// The active label was restored above, but a delayed React commit also wrote its older
|
|
476
|
+
// words into responsive copies. Rebase those copies onto the live words, keeping their
|
|
477
|
+
// new server nodes for the next undo. Do not hand focus back as an inspector preview would.
|
|
478
|
+
const instruction: PreviewInstruction = { kind: 'link-label', path: field, value: textWithTokens(el) }
|
|
479
|
+
const undo = applyPreview(doc, instruction)
|
|
480
|
+
if (undo) {
|
|
481
|
+
journal.push(undo)
|
|
482
|
+
previewed.push(instruction)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (anchor && focus && selection) {
|
|
486
|
+
const at = (path: number[]) => path.reduce<Node>((node, index) => node.childNodes[index]!, el)
|
|
487
|
+
const range = doc.createRange()
|
|
488
|
+
if (liveAnchor && liveFocus) {
|
|
489
|
+
selection.setBaseAndExtent(at(anchor.path), anchor.offset, at(focus.path), focus.offset)
|
|
490
|
+
lastRange = selection.getRangeAt(0).cloneRange()
|
|
491
|
+
} else {
|
|
492
|
+
range.setStart(at(anchor.path), anchor.offset)
|
|
493
|
+
range.setEnd(at(focus.path), focus.offset)
|
|
494
|
+
lastRange = range
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/** Counts refreshes, so a fragment fetched before one and answered after it is dropped. */
|
|
500
|
+
let generation = 0
|
|
501
|
+
/**
|
|
502
|
+
* Whether a refresh has been asked for and its render has not yet committed. `router.refresh()` takes
|
|
503
|
+
* seconds: its payload commits well after `opts.refresh()` returns, and React reconciles it against its
|
|
504
|
+
* own model of the page, which excludes any preview made meanwhile. A node-moving preview made in that
|
|
505
|
+
* window leaves the document and model disagreeing. React-owned block orders remain live;
|
|
506
|
+
* legacy node-moving previews wait until commit. Field previews are rebased around the
|
|
507
|
+
* actual commit so a later refresh cannot restore values from an obsolete server render.
|
|
508
|
+
*/
|
|
509
|
+
let refreshing = false
|
|
510
|
+
let refreshAgain = false
|
|
511
|
+
let refreshingTimer: ReturnType<typeof setTimeout> | null = null
|
|
512
|
+
/**
|
|
513
|
+
* Report a slow refresh without pretending it committed. Clearing the boundary
|
|
514
|
+
* on a timer lets a late response overwrite moves made after a queued refresh.
|
|
515
|
+
*/
|
|
516
|
+
const REFRESH_CEILING_MS = 5000
|
|
517
|
+
const setRefreshing = (value: boolean) => {
|
|
518
|
+
refreshing = value
|
|
519
|
+
if (refreshingTimer) clearTimeout(refreshingTimer)
|
|
520
|
+
refreshingTimer = null
|
|
521
|
+
if (value) {
|
|
522
|
+
refreshingTimer = setTimeout(() => {
|
|
523
|
+
refreshingTimer = null
|
|
524
|
+
warnOnceThatRefreshIsSlow()
|
|
525
|
+
}, REFRESH_CEILING_MS)
|
|
526
|
+
// A pending timer must not keep a Node process alive (tests); browsers return a number.
|
|
527
|
+
;(refreshingTimer as unknown as { unref?: () => void }).unref?.()
|
|
528
|
+
}
|
|
529
|
+
else if (refreshAgain) requestRefresh()
|
|
530
|
+
}
|
|
531
|
+
function requestRefresh() {
|
|
532
|
+
if (refreshing && [...registry.blockOrders].some((owner) => owner.document === doc)) {
|
|
533
|
+
refreshAgain = true
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
refreshAgain = false
|
|
537
|
+
if ([...registry.blockOrders].some((owner) => owner.document === doc && owner.needsReload())) {
|
|
538
|
+
win.location.reload()
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
// One refresh boundary at a time: newer moves stay visible through the older render.
|
|
542
|
+
// Clear only the moves included when THIS request starts, never when another is queued.
|
|
543
|
+
for (const owner of registry.blockOrders) if (owner.document === doc) owner.beforeRefresh()
|
|
544
|
+
const restoreEditing = preserveEditing()
|
|
545
|
+
try {
|
|
546
|
+
undoPreviews()
|
|
547
|
+
restoreEditing?.()
|
|
548
|
+
} finally {
|
|
549
|
+
setRefreshing(true)
|
|
550
|
+
opts.refresh()
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const MOVES_NODES = new Set<PreviewInstruction['kind']>(['move', 'insert', 'replace', 'fragment'])
|
|
554
|
+
const preview = (instruction: PreviewInstruction) => {
|
|
555
|
+
// React-owned order is safe even while a refresh is pending. Only legacy DOM moves
|
|
556
|
+
// still need the guard below; they can disagree with React's tree during a commit.
|
|
557
|
+
if (instruction.kind === 'move') {
|
|
558
|
+
for (const owner of registry.blockOrders) {
|
|
559
|
+
if (owner.document === doc && owner.move(instruction.path, instruction.before)) return
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (refreshing && MOVES_NODES.has(instruction.kind)) return
|
|
563
|
+
if ((instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label') && editing?.getAttribute('data-u90-field') === instruction.path && !doc.hasFocus()) {
|
|
564
|
+
// Focus moved to the inspector. This is a new edit, not an echo into a field being typed in.
|
|
565
|
+
endEditing()
|
|
566
|
+
}
|
|
567
|
+
const key = instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label' || instruction.kind === 'media' ? `${instruction.kind}:${instruction.path}` : null
|
|
568
|
+
const undo = applyPreview(doc, instruction)
|
|
569
|
+
if (!undo) return
|
|
570
|
+
if (key) previewed = previewed.filter((entry) => !(entry.kind === instruction.kind && entry.path === instruction.path))
|
|
571
|
+
previewed.push(instruction)
|
|
572
|
+
if (key) {
|
|
573
|
+
if (coalesced.has(key)) return
|
|
574
|
+
coalesced.add(key)
|
|
575
|
+
}
|
|
576
|
+
journal.push(undo)
|
|
577
|
+
}
|
|
578
|
+
/** Puts the page back as React last rendered it. One failing undo (a stale one) never stops the rest. */
|
|
579
|
+
const undoPreviews = () => {
|
|
580
|
+
const undos = journal.reverse()
|
|
581
|
+
journal = []
|
|
582
|
+
previewed = []
|
|
583
|
+
coalesced = new Set()
|
|
584
|
+
generation++
|
|
585
|
+
for (const undo of undos) {
|
|
586
|
+
try {
|
|
587
|
+
undo()
|
|
588
|
+
} catch {
|
|
589
|
+
// A node the page no longer has; the sweep below and the refresh deal with what is left.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Anything a stale journal missed (a frame that reloaded mid-flight): React never created these.
|
|
593
|
+
doc.querySelectorAll('[data-u90-skeleton], [data-u90-block*="/u90-pending-"]').forEach((el) => el.remove())
|
|
594
|
+
doc.querySelectorAll('[data-u90-removed]').forEach((el) => {
|
|
595
|
+
// The attribute holds the `style` attribute the node had (JSON; null for none), as `applyPreview` hid it.
|
|
596
|
+
let written: unknown = null
|
|
597
|
+
try {
|
|
598
|
+
written = JSON.parse(el.getAttribute('data-u90-removed') ?? 'null')
|
|
599
|
+
} catch {
|
|
600
|
+
// Not ours to read: the node loses its style attribute, below.
|
|
601
|
+
}
|
|
602
|
+
if (typeof written === 'string') el.setAttribute('style', written)
|
|
603
|
+
else el.removeAttribute('style')
|
|
604
|
+
el.removeAttribute('data-u90-removed')
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Fetches `block` rendered on its own from this site's fragment page and puts it where the
|
|
610
|
+
* skeleton at `path` is. The request is relative: the frame is same-origin with the site, so the
|
|
611
|
+
* draft cookie goes with it and no CORS question arises. The page is the site's own, inside its
|
|
612
|
+
* layout, so the block is found in the response by its marker rather than taken as the body.
|
|
613
|
+
*
|
|
614
|
+
* Never reports and never retries. A failed, refused or empty answer leaves the skeleton for the
|
|
615
|
+
* `u90:refresh` that follows the save, which is what would have happened anyway. An answer that
|
|
616
|
+
* arrives after a refresh is dropped: that refresh cleared the skeleton, and a block put in now
|
|
617
|
+
* would be one nothing will undo.
|
|
618
|
+
*/
|
|
619
|
+
const loadFragment = async ({ path, block }: Extract<PreviewInstruction, { kind: 'fragment' }>) => {
|
|
620
|
+
const asked = generation
|
|
621
|
+
try {
|
|
622
|
+
const response = await win.fetch(`/_u90/fragment?block=${encodeURIComponent(block)}&path=${encodeURIComponent(path)}`)
|
|
623
|
+
if (!response.ok) return
|
|
624
|
+
const page = new (win as Window & typeof globalThis).DOMParser().parseFromString(await response.text(), 'text/html')
|
|
625
|
+
const found = [...page.querySelectorAll('[data-u90-block]')].find((el) => el.getAttribute('data-u90-block') === path)
|
|
626
|
+
if (found && asked === generation) preview({ kind: 'replace', path, html: found.outerHTML })
|
|
627
|
+
} catch {
|
|
628
|
+
// The skeleton stays until the refresh.
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const onMessage = (e: MessageEvent) => {
|
|
633
|
+
if (e.origin !== opts.platformOrigin || e.source !== (opts.parent as unknown)) return
|
|
634
|
+
const m = parseCanvasMessage(e.data)
|
|
635
|
+
if (!m) return
|
|
636
|
+
if (m.type === 'u90:hello') {
|
|
637
|
+
// The Canvas registered this frame after its `start()` ready went out, and asks again. The
|
|
638
|
+
// height `start()` reported was lost the same way, so it is reported again. The stamp is taken
|
|
639
|
+
// first, so the answer already carries it.
|
|
640
|
+
stamp = m.frame === undefined ? null : { frame: m.frame, locale: m.locale ?? null, revision: m.revision ?? 0 }
|
|
641
|
+
send({ type: 'u90:ready', path: win.location.pathname })
|
|
642
|
+
// The right-to-left check was lost the same way. Forgotten before the height, so a height that changed sends it once, not twice.
|
|
643
|
+
lastHeight = 0
|
|
644
|
+
lastDirection = ''
|
|
645
|
+
reportHeight()
|
|
646
|
+
reportDirection()
|
|
647
|
+
} else if (m.type === 'u90:refresh') {
|
|
648
|
+
flush()
|
|
649
|
+
requestRefresh()
|
|
650
|
+
} else if (m.type === 'u90:preview') {
|
|
651
|
+
if (m.instruction.kind === 'fragment') {
|
|
652
|
+
// No skeleton was placed while a refresh is in flight (`preview` drops it), so there is nothing to fill.
|
|
653
|
+
if (!refreshing) void loadFragment(m.instruction)
|
|
654
|
+
} else preview(m.instruction)
|
|
655
|
+
} else if (m.type === 'u90:select') {
|
|
656
|
+
markSelected(m.path)
|
|
657
|
+
reveal(m.path)
|
|
658
|
+
} else if (m.type === 'u90:flush') {
|
|
659
|
+
flush()
|
|
660
|
+
send({ type: 'u90:flushed', requestId: m.requestId })
|
|
661
|
+
} else if (m.type === 'u90:format') {
|
|
662
|
+
applyFormat(m.action, m.url, m.to, m.newTab)
|
|
663
|
+
} else if (m.type === 'u90:scroll') {
|
|
664
|
+
win.scrollTo({ top: m.to === 'top' ? 0 : (m.fraction ?? 0) * (doc.documentElement.scrollHeight - win.innerHeight) })
|
|
665
|
+
} else if (m.type === 'u90:viewport') {
|
|
666
|
+
// Ported from legacy's own `u90:viewport` branch (`bridge.tsx`, its `onMessage`): pin only
|
|
667
|
+
// on a real change, and re-measure the document's height afterwards — pinning changes it.
|
|
668
|
+
const height = typeof m.height === 'number' && m.height > 0 ? m.height : null
|
|
669
|
+
if (height !== pinnedHeight) {
|
|
670
|
+
pinnedHeight = height
|
|
671
|
+
pinViewport(height)
|
|
672
|
+
reportHeight()
|
|
673
|
+
}
|
|
674
|
+
} else if (m.type === 'u90:active') {
|
|
675
|
+
isActive = m.active
|
|
676
|
+
} else if (m.type === 'u90:navigation') {
|
|
677
|
+
navigationEnabled = m.enabled
|
|
678
|
+
// `styles.ts`'s `EDITING_CSS` keys both of legacy's `navigation` on/off branches off this
|
|
679
|
+
// attribute, since the stylesheet is a static string and can no longer take `navigation`
|
|
680
|
+
// as a per-render prop the way legacy's `EditingStyles` did.
|
|
681
|
+
doc.documentElement.toggleAttribute('data-u90-navigation', navigationEnabled)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/*
|
|
686
|
+
* ── Ported from `legacy/bridge.tsx`, unchanged in shape: `height`/`scrolled`/the gesture group
|
|
687
|
+
* below all still only ever send (site → canvas); `u90:viewport` is the one Canvas → site
|
|
688
|
+
* message among them, placed here because it calls `reportHeight`. Every one of them was gated
|
|
689
|
+
* in v3 behind `isActive`/`u90:focus` multi-screen bookkeeping that has no v4 equivalent —
|
|
690
|
+
* bridge v4's Canvas→Site table has no `active`/`focus` message at all — so that gating is
|
|
691
|
+
* dropped rather than ported. `height`/`scrolled`/the gesture group are not unit-tested beyond
|
|
692
|
+
* not throwing at `start()`; see the task report for the line-range each handler came from.
|
|
693
|
+
* `u90:viewport`, `u90:hover` and `u90:caret` (above) are unit-tested. ──
|
|
694
|
+
*/
|
|
695
|
+
|
|
696
|
+
// `height`: legacy `reportHeight`/`ResizeObserver`, ~legacy/bridge.tsx:1057-1067.
|
|
697
|
+
let lastHeight = 0
|
|
698
|
+
const reportHeight = () => {
|
|
699
|
+
const height = doc.documentElement.scrollHeight
|
|
700
|
+
if (Math.abs(height - lastHeight) < 1) return
|
|
701
|
+
lastHeight = height
|
|
702
|
+
send({ type: 'u90:height', height })
|
|
703
|
+
reportDirection()
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Draft only: the language a `u90-locale` page is shown in (`data-u90-language`), put on `<html>`, which the root layout rendered without it. */
|
|
707
|
+
const applyDraftLanguage = () => {
|
|
708
|
+
const marker = doc.querySelector('[data-u90-language]')
|
|
709
|
+
if (!marker) return
|
|
710
|
+
doc.documentElement.lang = marker.getAttribute('data-u90-language') ?? doc.documentElement.lang
|
|
711
|
+
doc.documentElement.dir = marker.getAttribute('data-u90-dir') === 'rtl' ? 'rtl' : 'ltr'
|
|
712
|
+
}
|
|
713
|
+
let lastDirection = ''
|
|
714
|
+
/**
|
|
715
|
+
* The right-to-left check: on a `dir="rtl"` page, how far it overflows the screen and which fields reach past an edge. Sent when it
|
|
716
|
+
* changes. A left-to-right page sends none. A field path longer than `isSiteMessage` accepts is left out, so the message is never refused.
|
|
717
|
+
*/
|
|
718
|
+
const reportDirection = () => {
|
|
719
|
+
if (doc.documentElement.dir !== 'rtl') return
|
|
720
|
+
const width = doc.documentElement.clientWidth
|
|
721
|
+
const overflow = Math.max(0, Math.round(doc.documentElement.scrollWidth - width))
|
|
722
|
+
const offenders: string[] = []
|
|
723
|
+
for (const el of doc.querySelectorAll('[data-u90-field]')) {
|
|
724
|
+
const box = el.getBoundingClientRect()
|
|
725
|
+
const path = el.getAttribute('data-u90-field')
|
|
726
|
+
if (path && path.length <= MAX_OFFENDER_LENGTH && box.width > 0 && (box.left < -1 || box.right > width + 1) && !offenders.includes(path)) offenders.push(path)
|
|
727
|
+
if (offenders.length === MAX_OFFENDERS) break
|
|
728
|
+
}
|
|
729
|
+
const key = `${overflow}|${offenders.join(' ')}`
|
|
730
|
+
if (key === lastDirection) return
|
|
731
|
+
lastDirection = key
|
|
732
|
+
send({ type: 'u90:direction', dir: 'rtl', overflow, offenders })
|
|
733
|
+
}
|
|
734
|
+
let heights: InstanceType<typeof ResizeObserver> | null = null
|
|
735
|
+
|
|
736
|
+
/*
|
|
737
|
+
* `u90:viewport` (Canvas → site): what a `vh`/`svh`/`lvh`/`dvh`/`vmin`/`vmax` declaration
|
|
738
|
+
* measures against while this frame is drawn at a fixed height in the Canvas, rather than
|
|
739
|
+
* whatever the browser's own (irrelevant, cross-origin-driven) viewport happens to be. Ported
|
|
740
|
+
* from `legacy/bridge.tsx`'s `VIEWPORT_UNIT`/`PinnedDeclaration`/`releaseViewport`/`pinValue`/
|
|
741
|
+
* `pinViewport` (lines 238–333) — real CSSOM rewriting, not a custom property, because these
|
|
742
|
+
* sites reach for viewport units inside `min()`/`calc()` (a real fixture: `min-height:
|
|
743
|
+
* min(100svh, 2000px)`), which a rule of our own could not out-cascade without matching the
|
|
744
|
+
* site's own specificity, layer and position. `doc`/`win` replace legacy's bare `document`/
|
|
745
|
+
* `window` (legacy is browser-only; this runs from jsdom in tests too); everything else,
|
|
746
|
+
* including the comments, is unchanged.
|
|
747
|
+
*/
|
|
748
|
+
const VIEWPORT_UNIT = /(-?\d*\.?\d+)(svh|lvh|dvh|vh|vmin|vmax)\b/gi
|
|
749
|
+
|
|
750
|
+
/** What a locked declaration said before it was pinned, so it can be given back. */
|
|
751
|
+
type PinnedDeclaration = {
|
|
752
|
+
priority: string
|
|
753
|
+
property: string
|
|
754
|
+
rule: CSSStyleRule
|
|
755
|
+
value: string
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let pinned: PinnedDeclaration[] = []
|
|
759
|
+
let pinnedHeight: number | null = null
|
|
760
|
+
|
|
761
|
+
/** Put every pinned declaration back exactly as it was. */
|
|
762
|
+
const releaseViewport = (): void => {
|
|
763
|
+
for (const entry of pinned) {
|
|
764
|
+
try {
|
|
765
|
+
entry.rule.style.setProperty(entry.property, entry.value, entry.priority)
|
|
766
|
+
} catch {
|
|
767
|
+
// The stylesheet was replaced underneath us — there is nothing to restore to.
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
pinned = []
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Rewrite the viewport units in one declaration to the pixels they would be at `height`. */
|
|
774
|
+
const pinValue = (value: string, height: number, width: number): string =>
|
|
775
|
+
value.replace(VIEWPORT_UNIT, (_whole, amount: string, unit: string) => {
|
|
776
|
+
const lower = unit.toLowerCase()
|
|
777
|
+
const basis = lower === 'vmin' ? Math.min(width, height) : lower === 'vmax' ? Math.max(width, height) : height
|
|
778
|
+
return `${(Number(amount) * basis) / 100}px`
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
/** Pin every viewport unit on the page to `height`, or release them all when it is null. */
|
|
782
|
+
const pinViewport = (height: null | number): void => {
|
|
783
|
+
releaseViewport()
|
|
784
|
+
if (height === null) return
|
|
785
|
+
|
|
786
|
+
const width = doc.documentElement.clientWidth
|
|
787
|
+
|
|
788
|
+
const walk = (rules: CSSRuleList): void => {
|
|
789
|
+
for (const rule of Array.from(rules)) {
|
|
790
|
+
const style = (rule as CSSStyleRule).style
|
|
791
|
+
if (style) {
|
|
792
|
+
for (const property of Array.from(style)) {
|
|
793
|
+
const value = style.getPropertyValue(property)
|
|
794
|
+
if (!VIEWPORT_UNIT.test(value)) continue
|
|
795
|
+
// `test` on a `g` regex carries `lastIndex` between calls; left alone, every
|
|
796
|
+
// second declaration would be skipped.
|
|
797
|
+
VIEWPORT_UNIT.lastIndex = 0
|
|
798
|
+
|
|
799
|
+
const priority = style.getPropertyPriority(property)
|
|
800
|
+
try {
|
|
801
|
+
style.setProperty(property, pinValue(value, height, width), priority)
|
|
802
|
+
} catch {
|
|
803
|
+
continue
|
|
804
|
+
}
|
|
805
|
+
pinned.push({ priority, property, rule: rule as CSSStyleRule, value })
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// `@media`, `@supports`, `@layer`, `@container`, and nested rules.
|
|
810
|
+
const grouped = (rule as CSSGroupingRule).cssRules
|
|
811
|
+
if (grouped?.length) walk(grouped)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
for (const sheet of Array.from(doc.styleSheets)) {
|
|
816
|
+
try {
|
|
817
|
+
walk(sheet.cssRules)
|
|
818
|
+
} catch {
|
|
819
|
+
// Cross-origin, and not readable from here.
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// `scrolled`: legacy `reportScroll`, ~legacy/bridge.tsx:1108-1121 (rAF-coalesced).
|
|
825
|
+
let scrollPending = 0
|
|
826
|
+
const reportScroll = () => {
|
|
827
|
+
if (scrollPending) return
|
|
828
|
+
scrollPending = win.requestAnimationFrame(() => {
|
|
829
|
+
scrollPending = 0
|
|
830
|
+
send({ type: 'u90:scrolled', top: win.scrollY, max: doc.documentElement.scrollHeight - win.innerHeight })
|
|
831
|
+
})
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/*
|
|
835
|
+
* gesture — wheel → `zoom`/`wheel`: legacy `onWheel`, ~legacy/bridge.tsx:1079-1090.
|
|
836
|
+
*
|
|
837
|
+
* Two lines here are the whole of the active-screen rule, and the v4 port dropped both on the
|
|
838
|
+
* reasoning that the Canvas→Site table had no `active` message. It does now, because this is
|
|
839
|
+
* what needed it: without them every wheel was forwarded AND the page scrolled under it, so
|
|
840
|
+
* one gesture moved the page and panned the Canvas at the same time.
|
|
841
|
+
*
|
|
842
|
+
* The active screen keeps its own wheel — return before sending, and WITHOUT preventing the
|
|
843
|
+
* default, so the page scrolls as a page does.
|
|
844
|
+
*
|
|
845
|
+
* An inactive screen's wheel belongs to the Canvas, and the page must not also take it. Only
|
|
846
|
+
* the site can stop that: `preventDefault` has to happen in the document that owns the scroll,
|
|
847
|
+
* and that is never the Canvas. `stopImmediatePropagation` goes with it because a smooth-scroll
|
|
848
|
+
* library listens for the wheel itself and then scrolls programmatically, which `preventDefault`
|
|
849
|
+
* alone does not touch — this listener is on the capture phase so it runs before any of them.
|
|
850
|
+
*/
|
|
851
|
+
const onWheel = (e: WheelEvent) => {
|
|
852
|
+
if (e.ctrlKey || e.metaKey) {
|
|
853
|
+
e.preventDefault()
|
|
854
|
+
send({ type: 'u90:zoom', deltaY: e.deltaY, x: e.clientX, y: e.clientY })
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
if (isActive) return
|
|
858
|
+
e.preventDefault()
|
|
859
|
+
e.stopPropagation()
|
|
860
|
+
e.stopImmediatePropagation()
|
|
861
|
+
send({ type: 'u90:wheel', deltaX: e.deltaX, deltaY: e.deltaY })
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// gesture — middle-button pan → `grab`/`drag`/`drop`: legacy `onPointerDown`/`onPointerMove`/
|
|
865
|
+
// `onPointerUp`/`onAuxClick`, ~legacy/bridge.tsx:1130-1190.
|
|
866
|
+
let panning: number | null = null
|
|
867
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
868
|
+
// A press anywhere else releases the field being edited (legacy `onPointerDown`, R40): with
|
|
869
|
+
// `onBlur` keeping the field when focus leaves for nowhere in particular, a press on plain page
|
|
870
|
+
// copy — which moves focus to no element, the same `relatedTarget: null` as leaving the frame —
|
|
871
|
+
// would otherwise leave it editable.
|
|
872
|
+
if (editing && !editing.contains(e.target as Node)) endEditing()
|
|
873
|
+
if (e.button !== 1 || panning !== null) return
|
|
874
|
+
e.preventDefault()
|
|
875
|
+
panning = e.pointerId
|
|
876
|
+
try {
|
|
877
|
+
doc.documentElement.setPointerCapture(e.pointerId)
|
|
878
|
+
} catch {
|
|
879
|
+
// Capture is a nicety; the window listeners still see the moves.
|
|
880
|
+
}
|
|
881
|
+
send({ type: 'u90:grab', screenX: e.screenX, screenY: e.screenY })
|
|
882
|
+
}
|
|
883
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
884
|
+
if (e.pointerId !== panning) return
|
|
885
|
+
send({ type: 'u90:drag', screenX: e.screenX, screenY: e.screenY })
|
|
886
|
+
}
|
|
887
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
888
|
+
if (e.pointerId !== panning) return
|
|
889
|
+
panning = null
|
|
890
|
+
try {
|
|
891
|
+
doc.documentElement.releasePointerCapture(e.pointerId)
|
|
892
|
+
} catch {
|
|
893
|
+
// Already released.
|
|
894
|
+
}
|
|
895
|
+
send({ type: 'u90:drop' })
|
|
896
|
+
}
|
|
897
|
+
const onAuxClick = (e: MouseEvent) => {
|
|
898
|
+
if (e.button === 1) e.preventDefault()
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// gesture — keydown/keyup → `key`: legacy `CANVAS_KEYS`/`isTyping`/`onKey`, ~legacy/bridge.tsx:1211-1259.
|
|
902
|
+
const CANVAS_KEYS = new Set(['KeyF', 'KeyG', 'KeyH', 'KeyN', 'KeyT', 'KeyV', 'Space'])
|
|
903
|
+
const isTyping = (target: EventTarget | null): boolean => {
|
|
904
|
+
const element = target as HTMLElement | null
|
|
905
|
+
const tag = element?.tagName
|
|
906
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || Boolean(element?.isContentEditable)
|
|
907
|
+
}
|
|
908
|
+
const onKey = (e: KeyboardEvent) => {
|
|
909
|
+
if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return
|
|
910
|
+
if (!CANVAS_KEYS.has(e.code)) return
|
|
911
|
+
if (e.code === 'Space') e.preventDefault()
|
|
912
|
+
send({ type: 'u90:key', code: e.code, down: e.type === 'keydown' })
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const style = doc.createElement('style')
|
|
916
|
+
style.setAttribute('data-u90-editing', '')
|
|
917
|
+
style.textContent = EDITING_CSS
|
|
918
|
+
|
|
919
|
+
const bridge = {
|
|
920
|
+
start() {
|
|
921
|
+
stopped = false
|
|
922
|
+
// First, so a test observing `start()`'s very first message never races height/scroll
|
|
923
|
+
// reporting (both are no-ops against jsdom's zero layout anyway, but this holds regardless
|
|
924
|
+
// of that).
|
|
925
|
+
send({ type: 'u90:ready', path: win.location.pathname })
|
|
926
|
+
|
|
927
|
+
doc.head.appendChild(style)
|
|
928
|
+
applyDraftLanguage()
|
|
929
|
+
doc.addEventListener('click', onClick, true)
|
|
930
|
+
doc.addEventListener('input', onInput, true)
|
|
931
|
+
doc.addEventListener('focusout', onBlur, true)
|
|
932
|
+
doc.addEventListener('pointerover', onPointerOver, true)
|
|
933
|
+
doc.addEventListener('pointerout', onPointerOut, true)
|
|
934
|
+
doc.addEventListener('selectionchange', onSelectionChange)
|
|
935
|
+
win.addEventListener('message', onMessage)
|
|
936
|
+
win.addEventListener('wheel', onWheel, { capture: true, passive: false })
|
|
937
|
+
win.addEventListener('scroll', reportScroll, { passive: true })
|
|
938
|
+
win.addEventListener('pointerdown', onPointerDown)
|
|
939
|
+
win.addEventListener('pointermove', onPointerMove)
|
|
940
|
+
win.addEventListener('pointerup', onPointerUp)
|
|
941
|
+
win.addEventListener('auxclick', onAuxClick)
|
|
942
|
+
win.addEventListener('keydown', onKey)
|
|
943
|
+
win.addEventListener('keyup', onKey)
|
|
944
|
+
|
|
945
|
+
// Not in lib.dom.d.ts's `Window` interface (only as the ambient global constructor), and
|
|
946
|
+
// absent altogether in jsdom — hence the cast to check for it and the guard around it.
|
|
947
|
+
const ResizeObserverCtor = (win as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver
|
|
948
|
+
if (typeof ResizeObserverCtor !== 'undefined') {
|
|
949
|
+
heights = new ResizeObserverCtor(reportHeight)
|
|
950
|
+
heights.observe(doc.documentElement)
|
|
951
|
+
}
|
|
952
|
+
reportHeight()
|
|
953
|
+
reportDirection()
|
|
954
|
+
|
|
955
|
+
registry.current = bridge
|
|
956
|
+
},
|
|
957
|
+
stop() {
|
|
958
|
+
stopped = true
|
|
959
|
+
endEditing() // what was typed goes out, and no element is left contenteditable
|
|
960
|
+
flush()
|
|
961
|
+
releaseViewport()
|
|
962
|
+
style.remove()
|
|
963
|
+
doc.removeEventListener('click', onClick, true)
|
|
964
|
+
doc.removeEventListener('input', onInput, true)
|
|
965
|
+
doc.removeEventListener('focusout', onBlur, true)
|
|
966
|
+
doc.removeEventListener('pointerover', onPointerOver, true)
|
|
967
|
+
doc.removeEventListener('pointerout', onPointerOut, true)
|
|
968
|
+
doc.removeEventListener('selectionchange', onSelectionChange)
|
|
969
|
+
win.removeEventListener('message', onMessage)
|
|
970
|
+
win.removeEventListener('wheel', onWheel, { capture: true })
|
|
971
|
+
win.removeEventListener('scroll', reportScroll)
|
|
972
|
+
win.removeEventListener('pointerdown', onPointerDown)
|
|
973
|
+
win.removeEventListener('pointermove', onPointerMove)
|
|
974
|
+
win.removeEventListener('pointerup', onPointerUp)
|
|
975
|
+
win.removeEventListener('auxclick', onAuxClick)
|
|
976
|
+
win.removeEventListener('keydown', onKey)
|
|
977
|
+
win.removeEventListener('keyup', onKey)
|
|
978
|
+
heights?.disconnect()
|
|
979
|
+
heights = null
|
|
980
|
+
refreshAgain = false
|
|
981
|
+
setRefreshing(false)
|
|
982
|
+
registry.current = null
|
|
983
|
+
},
|
|
984
|
+
/** Called once before a server subtree commits. The first boundary consumes
|
|
985
|
+
* the journal, so sibling/nested boundaries cannot replay it twice. */
|
|
986
|
+
prepareCommit(): null | (() => void) {
|
|
987
|
+
if (journal.length === 0) return null
|
|
988
|
+
const restoreEditing = preserveEditing()
|
|
989
|
+
const field = editing?.getAttribute('data-u90-field')
|
|
990
|
+
const instructions = previewed.filter((entry) => !((entry.kind === 'text' || entry.kind === 'rich' || entry.kind === 'link-label') && entry.path === field))
|
|
991
|
+
undoPreviews()
|
|
992
|
+
return () => {
|
|
993
|
+
if (stopped) return
|
|
994
|
+
for (const instruction of instructions) preview(instruction)
|
|
995
|
+
restoreEditing?.()
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
flush,
|
|
999
|
+
/** The host says its refresh has committed (`false`), or is running (`true`); see `refreshing`. */
|
|
1000
|
+
setRefreshing,
|
|
1001
|
+
markSelected,
|
|
1002
|
+
/** `u90:path`, called from `BridgeClient`'s `usePathname()` effect after a client-side navigation. */
|
|
1003
|
+
notifyPath(path: string) {
|
|
1004
|
+
send({ type: 'u90:path', path })
|
|
1005
|
+
},
|
|
1006
|
+
}
|
|
1007
|
+
return bridge
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export type Bridge = ReturnType<typeof createBridge>
|