@unitninety/site-kit 0.0.0-stage → 1.6.2
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 +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -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 +1015 -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 +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -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 +13 -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,1987 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { acceptsLanguageFrame, languageFrameNavigation, type LanguageFrameContext } from './preview'
|
|
4
|
+
|
|
5
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
BRIDGE_VERSION,
|
|
9
|
+
CANVAS_FRAME_NAME,
|
|
10
|
+
type EditorBlock,
|
|
11
|
+
type ManifestCollection,
|
|
12
|
+
} from './contract'
|
|
13
|
+
import type { CollectionRow, ContentResult, Section } from './content'
|
|
14
|
+
import {
|
|
15
|
+
applyCaret,
|
|
16
|
+
caretTargetIn,
|
|
17
|
+
EDITABLE_LEAF_SELECTOR,
|
|
18
|
+
isEditableLeaf,
|
|
19
|
+
MEDIA,
|
|
20
|
+
MEDIA_SELECTOR,
|
|
21
|
+
} from './caret'
|
|
22
|
+
import { toRichText, type DomLike } from './lexicalDom'
|
|
23
|
+
import { FIELD_ATTR, isSettledInView, revealSelectors, RICH_ATTR, SECTION_ATTR } from './shape'
|
|
24
|
+
import { resolveUploadsIn } from './uploads'
|
|
25
|
+
import { createLanguageHints } from './languageHints'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How much of a quote travels to the dashboard.
|
|
29
|
+
*
|
|
30
|
+
* The words are for a chat prefill the client then edits, and a whole paragraph pasted in
|
|
31
|
+
* front of their prompt is worse than none of it — they would delete it before typing. The
|
|
32
|
+
* dashboard holds the same figure in `canvas/agentSelection.ts`; if they ever disagree the
|
|
33
|
+
* shorter one wins, which is the safe direction.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_QUOTE = 400
|
|
36
|
+
|
|
37
|
+
/** One line, no runs of whitespace — rendered markup is full of newlines and indentation. */
|
|
38
|
+
const collapseSpace = (value: string): string =>
|
|
39
|
+
value.replace(/\s+/g, ' ').trim().slice(0, MAX_QUOTE)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An element's own words, or null when it has none worth quoting.
|
|
43
|
+
*
|
|
44
|
+
* `innerText` rather than `textContent`, for the reason `caret.ts` gives about buttons: it
|
|
45
|
+
* respects rendering, so an `<svg>` chevron inside a link contributes nothing and the value is
|
|
46
|
+
* the label alone. A media field returns nothing here, which is correct — its value is an
|
|
47
|
+
* upload id and quoting the empty string back would claim it had no content.
|
|
48
|
+
*/
|
|
49
|
+
const quotable = (element: HTMLElement): string | undefined => {
|
|
50
|
+
const words = collapseSpace(element.innerText ?? element.textContent ?? '')
|
|
51
|
+
return words || undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The site half of the UnitNinety editing protocol.
|
|
56
|
+
*
|
|
57
|
+
* When a page is framed by the dashboard's Canvas in draft mode, the dashboard pushes
|
|
58
|
+
* the editor's working copy in on every keystroke and this re-renders from it — so the
|
|
59
|
+
* client edits their actual page, with real components, CSS, fonts and video, rather
|
|
60
|
+
* than an approximation. Clicking any element carrying `data-u90-field` reports that
|
|
61
|
+
* field back, which is what makes the page directly editable.
|
|
62
|
+
*
|
|
63
|
+
* Outside a frame the hook does nothing at all, so a public visitor pays no cost.
|
|
64
|
+
*
|
|
65
|
+
* **Why this is a package rather than a file each site copies.** Almost everything below
|
|
66
|
+
* exists because a cross-origin iframe keeps its own input, and each fix was found the
|
|
67
|
+
* hard way:
|
|
68
|
+
*
|
|
69
|
+
* - ⌘/ctrl + wheel and trackpad pinch never reach the dashboard, so zoom stops working
|
|
70
|
+
* wherever the pointer is over the page — which is most of the stage.
|
|
71
|
+
* - A middle-button pan lands here, and the dashboard sees no pointer events until
|
|
72
|
+
* release, so the canvas appeared to stick and then jump.
|
|
73
|
+
* - Tool shortcuts and space-to-pan die the moment focus moves into the frame.
|
|
74
|
+
* - An unselected screen must not scroll, and `preventDefault` is not enough against a
|
|
75
|
+
* smooth-scroll library that moves the page programmatically.
|
|
76
|
+
*
|
|
77
|
+
* Copy-pasted, those fixes live in one repo and the next client silently gets the
|
|
78
|
+
* broken version. `BRIDGE_VERSION` is the enforcement: the dashboard refuses to render
|
|
79
|
+
* its editor against a version it doesn't speak, so a stale site degrades loudly.
|
|
80
|
+
*
|
|
81
|
+
* Security: every inbound message is checked against the platform's own origin, so
|
|
82
|
+
* another page that frames this site cannot drive it.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/** This site's collection rows, keyed by slug — the same shape `getCollection` returns. */
|
|
86
|
+
export type EntriesByCollection = Record<string, CollectionRow[]>
|
|
87
|
+
|
|
88
|
+
export type LiveContent = {
|
|
89
|
+
content: ContentResult
|
|
90
|
+
/** True when this page is inside the dashboard's editing frame. */
|
|
91
|
+
embedded: boolean
|
|
92
|
+
/**
|
|
93
|
+
* The collection rows, live while framed and the server-fetched ones otherwise.
|
|
94
|
+
*
|
|
95
|
+
* Keyed by slug, so a component reads `entries.projects` and gets exactly what
|
|
96
|
+
* `getCollection('projects')` would have returned. A collection the dashboard has not
|
|
97
|
+
* pushed is simply absent, which is what `?? initialProjects` already handles.
|
|
98
|
+
*/
|
|
99
|
+
entries: EntriesByCollection
|
|
100
|
+
/**
|
|
101
|
+
* Whether links in this frame navigate. False while the operator is editing, which
|
|
102
|
+
* is the default — pass it to `EditingStyles` so the affordances agree with the
|
|
103
|
+
* behaviour.
|
|
104
|
+
*/
|
|
105
|
+
navigation: boolean
|
|
106
|
+
selected: null | string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type MediaMap = Record<
|
|
110
|
+
string,
|
|
111
|
+
{ alt?: string; focalX?: null | number; focalY?: null | number; url?: null | string }
|
|
112
|
+
>
|
|
113
|
+
type PagesMap = Record<string, { slug?: string; title?: string }>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A hydration boundary that actively owns its one transition.
|
|
117
|
+
*
|
|
118
|
+
* The server and hydrating client both render `false`; a timer then performs an ordinary React
|
|
119
|
+
* state transition. This used to be expressed through `useSyncExternalStore`, whose browser
|
|
120
|
+
* snapshot remained on its hydration value under the sandbox's Next development runtime even
|
|
121
|
+
* after its subscriber fired. The result was a correctly compiled page that never mounted the
|
|
122
|
+
* editing effect: no ready message, no click selection and no active-screen scroll ownership.
|
|
123
|
+
*
|
|
124
|
+
* Timed rather than set synchronously inside the effect, both to give cleanup a real job and to
|
|
125
|
+
* keep the hydration transition outside the effect body. It runs once; whether a document is
|
|
126
|
+
* mounted cannot change during its lifetime.
|
|
127
|
+
*/
|
|
128
|
+
export const useHasMounted = (): boolean => {
|
|
129
|
+
const [mounted, setMounted] = useState(false)
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const timer = window.setTimeout(() => setMounted(true), 0)
|
|
133
|
+
return () => window.clearTimeout(timer)
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
return mounted
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether this mounted document is framed. */
|
|
140
|
+
export const useIsFramed = (): boolean => {
|
|
141
|
+
const mounted = useHasMounted()
|
|
142
|
+
return mounted && (window.parent !== window || window.name === CANVAS_FRAME_NAME)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Bring the selected thing into view inside this page.
|
|
147
|
+
*
|
|
148
|
+
* The dashboard can move the canvas to a screen, but only the page itself can scroll to
|
|
149
|
+
* something inside it — so a client arriving from the CMS to edit one project landed on the
|
|
150
|
+
* right screen looking at the top of it.
|
|
151
|
+
*
|
|
152
|
+
* **Where to look is `revealSelectors`' decision, not this function's**, and it is not the
|
|
153
|
+
* exact field alone. Canvas selects containers as readily as leaves — a whole section, a
|
|
154
|
+
* list, a link's group — while only leaves carry a marker, so exact matching found the target
|
|
155
|
+
* exactly when a client clicked a field and nothing the rest of the time. Clicking "Quote" in
|
|
156
|
+
* the rail outlined the block, because the outline resolves the section marker, and left the
|
|
157
|
+
* page where it was: the outline and the scroll disagreed about what a selection means.
|
|
158
|
+
*
|
|
159
|
+
* Nothing is found when the thing genuinely is not on the page — jdsi's home carousel marks
|
|
160
|
+
* only its active slide — and nothing then happens, which is the honest outcome.
|
|
161
|
+
*/
|
|
162
|
+
const revealField = (field: string): void => {
|
|
163
|
+
let target: HTMLElement | null = null
|
|
164
|
+
for (const selector of revealSelectors(field)) {
|
|
165
|
+
target = document.querySelector<HTMLElement>(selector)
|
|
166
|
+
if (target) break
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!target) return
|
|
170
|
+
|
|
171
|
+
/*
|
|
172
|
+
* Already on screen: leave the page exactly where it is.
|
|
173
|
+
*
|
|
174
|
+
* `u90:select` also arrives when the client clicks an element *in* this frame — the site
|
|
175
|
+
* reports it and the dashboard echoes it back — so scrolling unconditionally would
|
|
176
|
+
* re-centre the page under every click, which reads as the page fighting you.
|
|
177
|
+
*/
|
|
178
|
+
if (isSettledInView(target.getBoundingClientRect(), window.innerHeight)) return
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* Instant, not smooth. These sites run Lenis, which owns the scroll position and animates
|
|
182
|
+
* it itself — a smooth native scroll competes with it and can land somewhere neither
|
|
183
|
+
* intended. An instant jump is one position change Lenis picks up on its next frame, and
|
|
184
|
+
* "take me to the thing I asked to edit" wants to arrive rather than travel.
|
|
185
|
+
*/
|
|
186
|
+
target.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What the client has typed, as the dashboard's text control would hold it.
|
|
191
|
+
*
|
|
192
|
+
* `innerText` rather than `textContent`, because it is the one that respects layout:
|
|
193
|
+
* a rich text field's paragraphs come back separated by blank lines, which is exactly
|
|
194
|
+
* what `textToLexical` splits on. `textContent` would run them together and three
|
|
195
|
+
* paragraphs would save as one.
|
|
196
|
+
*
|
|
197
|
+
* Non-breaking spaces are put back to ordinary ones. Chromium's `plaintext-only` does not
|
|
198
|
+
* produce them, but the `contenteditable="true"` fallback above and other engines do — for
|
|
199
|
+
* a trailing or repeated space — and one saved into the client's copy is a stray character
|
|
200
|
+
* on the published page that nothing on screen explains.
|
|
201
|
+
*/
|
|
202
|
+
const readEditedText = (element: HTMLElement): string =>
|
|
203
|
+
element.innerText.replace(/ /g, ' ').replace(/\r\n?/g, '\n')
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* Formatting in the frame is `contentEditable`'s own -- ⌘B, ⌘I, and nothing else.
|
|
207
|
+
*
|
|
208
|
+
* The design said these should drive Lexical's commands, which cannot be done here: this kit
|
|
209
|
+
* is vendored into every client site and takes no dependencies, so `lexical` is not available
|
|
210
|
+
* on this side of the bridge and should not become available.
|
|
211
|
+
*
|
|
212
|
+
* That is a smaller loss than it sounds. The browser gives bold and italic for free, the
|
|
213
|
+
* serialiser reads both spellings of each, and everything structural -- headings, lists, links --
|
|
214
|
+
* is a control in the panel where the client can see what they are choosing. What is not
|
|
215
|
+
* acceptable is a keystroke that looks applied and does not survive a reload, which is why the
|
|
216
|
+
* round trip asserts `b` and `i` rather than assuming them.
|
|
217
|
+
*/
|
|
218
|
+
|
|
219
|
+
/** A real element as the structural node `toRichText` walks. See `lexicalDom.ts`. */
|
|
220
|
+
const asDomLike = (node: Node): DomLike => {
|
|
221
|
+
if (node.nodeType === 3) return { text: node.textContent ?? '' }
|
|
222
|
+
const element = node as HTMLElement
|
|
223
|
+
const href = element.getAttribute?.('href')
|
|
224
|
+
const singleKey = element.getAttribute?.('data-single-key')
|
|
225
|
+
return {
|
|
226
|
+
attributes: { ...(href ? { href } : {}), ...(singleKey ? { 'data-single-key': singleKey } : {}) },
|
|
227
|
+
children: [...(element.childNodes ?? [])].map(asDomLike),
|
|
228
|
+
tag: element.tagName?.toLowerCase(),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The viewport-height units, and the ones that depend on height through a comparison.
|
|
234
|
+
*
|
|
235
|
+
* `vw` is deliberately absent: a full-length frame is exactly as wide as the device it is
|
|
236
|
+
* standing in for, so a width unit already measures against the right thing.
|
|
237
|
+
*/
|
|
238
|
+
const VIEWPORT_UNIT = /(-?\d*\.?\d+)(svh|lvh|dvh|vh|vmin|vmax)\b/gi
|
|
239
|
+
|
|
240
|
+
/** What a locked declaration said before it was pinned, so it can be given back. */
|
|
241
|
+
type PinnedDeclaration = {
|
|
242
|
+
priority: string
|
|
243
|
+
property: string
|
|
244
|
+
rule: CSSStyleRule
|
|
245
|
+
value: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let pinned: PinnedDeclaration[] = []
|
|
249
|
+
|
|
250
|
+
/** Put every pinned declaration back exactly as it was. */
|
|
251
|
+
const releaseViewport = (): void => {
|
|
252
|
+
for (const entry of pinned) {
|
|
253
|
+
try {
|
|
254
|
+
entry.rule.style.setProperty(entry.property, entry.value, entry.priority)
|
|
255
|
+
} catch {
|
|
256
|
+
// The stylesheet was replaced underneath us — there is nothing to restore to.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
pinned = []
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Rewrite the viewport units in one declaration to the pixels they would be at `height`.
|
|
264
|
+
*
|
|
265
|
+
* The whole value is rewritten rather than matched against a known shape, because these
|
|
266
|
+
* sites reach for them inside `min()` and `calc()` — jdsi's hero is
|
|
267
|
+
* `min-height: min(100svh, 2000px)` — and a rule that only understood a bare `100vh`
|
|
268
|
+
* would walk straight past the one declaration that matters.
|
|
269
|
+
*/
|
|
270
|
+
const pinValue = (value: string, height: number, width: number): string =>
|
|
271
|
+
value.replace(VIEWPORT_UNIT, (_whole, amount: string, unit: string) => {
|
|
272
|
+
const lower = unit.toLowerCase()
|
|
273
|
+
const basis =
|
|
274
|
+
lower === 'vmin'
|
|
275
|
+
? Math.min(width, height)
|
|
276
|
+
: lower === 'vmax'
|
|
277
|
+
? Math.max(width, height)
|
|
278
|
+
: height
|
|
279
|
+
return `${(Number(amount) * basis) / 100}px`
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Pin every viewport unit on the page to `height`, or release them all when it is null.
|
|
284
|
+
*
|
|
285
|
+
* The declarations are edited **in place**, in the site's own stylesheets, rather than
|
|
286
|
+
* overridden from a stylesheet of our own. An override has to win the cascade against a
|
|
287
|
+
* rule it cannot see — matching its specificity, its layer, and its position — and Tailwind
|
|
288
|
+
* puts these in `@layer utilities` under generated class names like
|
|
289
|
+
* `.min-h-\[min\(100svh\,2000px\)\]`. Editing the declaration itself sidesteps all of it:
|
|
290
|
+
* same rule, same specificity, same layer, so whatever won before still wins, at a value
|
|
291
|
+
* that no longer moves when the frame does. The originals are kept and restored on release,
|
|
292
|
+
* and this only ever runs inside the editor's frame.
|
|
293
|
+
*
|
|
294
|
+
* A stylesheet the browser will not open — one served from another origin, a font service —
|
|
295
|
+
* throws on `cssRules` and is skipped. Its `vh` keeps stretching; there is no reading it
|
|
296
|
+
* from here, and the alternative is failing on every page that loads a webfont.
|
|
297
|
+
*/
|
|
298
|
+
const pinViewport = (height: null | number): void => {
|
|
299
|
+
releaseViewport()
|
|
300
|
+
if (height === null) return
|
|
301
|
+
|
|
302
|
+
const width = document.documentElement.clientWidth
|
|
303
|
+
|
|
304
|
+
const walk = (rules: CSSRuleList): void => {
|
|
305
|
+
for (const rule of Array.from(rules)) {
|
|
306
|
+
/*
|
|
307
|
+
* Declarations first, children second, and **never one instead of the other**.
|
|
308
|
+
*
|
|
309
|
+
* Since CSS nesting, a plain style rule is itself a grouping rule: it carries a
|
|
310
|
+
* `cssRules` list, empty in the ordinary case. Treating "has `cssRules`" as "is a
|
|
311
|
+
* container, so has no declarations of its own" therefore walks straight past every
|
|
312
|
+
* rule in the document — which is exactly what it did, silently, pinning nothing.
|
|
313
|
+
*/
|
|
314
|
+
const style = (rule as CSSStyleRule).style
|
|
315
|
+
if (style) {
|
|
316
|
+
for (const property of Array.from(style)) {
|
|
317
|
+
const value = style.getPropertyValue(property)
|
|
318
|
+
if (!VIEWPORT_UNIT.test(value)) continue
|
|
319
|
+
// `test` on a `g` regex carries `lastIndex` between calls; left alone, every
|
|
320
|
+
// second declaration would be skipped.
|
|
321
|
+
VIEWPORT_UNIT.lastIndex = 0
|
|
322
|
+
|
|
323
|
+
const priority = style.getPropertyPriority(property)
|
|
324
|
+
try {
|
|
325
|
+
style.setProperty(property, pinValue(value, height, width), priority)
|
|
326
|
+
} catch {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
pinned.push({ priority, property, rule: rule as CSSStyleRule, value })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// `@media`, `@supports`, `@layer`, `@container`, and nested rules — Tailwind puts
|
|
334
|
+
// nearly everything inside at least one of them.
|
|
335
|
+
const grouped = (rule as CSSGroupingRule).cssRules
|
|
336
|
+
if (grouped?.length) walk(grouped)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
341
|
+
try {
|
|
342
|
+
walk(sheet.cssRules)
|
|
343
|
+
} catch {
|
|
344
|
+
// Cross-origin, and not readable from here.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const indexSections = (sections: Section[]): Record<string, number> => {
|
|
350
|
+
const index: Record<string, number> = {}
|
|
351
|
+
sections.forEach((section, position) => {
|
|
352
|
+
if (section.blockType && index[section.blockType] === undefined) {
|
|
353
|
+
index[section.blockType] = position
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
return index
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The chrome from a content push, or null when the push said nothing about it.
|
|
361
|
+
*
|
|
362
|
+
* Read as untrusted, for the reason `readLayout` in `./content` gives about the server
|
|
363
|
+
* fetch: nothing between the two halves enforces this shape, and a platform running ahead
|
|
364
|
+
* of this kit could send anything under the key. Null on anything unusable, which is also
|
|
365
|
+
* what "not pushed at all" reads as — and the caller turns both into "keep the chrome the
|
|
366
|
+
* server rendered", so a malformed layout costs a client nothing.
|
|
367
|
+
*
|
|
368
|
+
* Restated here rather than imported from `./content`, which is the server module that
|
|
369
|
+
* holds the API key: this file is a client component, and pulling that module in to share
|
|
370
|
+
* nine lines would carry the fetch layer into every visitor's bundle.
|
|
371
|
+
*/
|
|
372
|
+
const toLayout = (value: unknown): ContentResult['layout'] => {
|
|
373
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
374
|
+
const entries = (value as { entries?: unknown }).entries
|
|
375
|
+
if (!Array.isArray(entries)) return null
|
|
376
|
+
const rows = entries as (Record<string, unknown> & { blockType: string })[]
|
|
377
|
+
/*
|
|
378
|
+
* The id is the half a site cannot work out for itself, and the half it may be handed
|
|
379
|
+
* nothing for. Kept when it is there — as a string, since the address it prefixes is text
|
|
380
|
+
* and the platform's row ids are `number | string` — and simply absent when it is not.
|
|
381
|
+
* Refusing the layout over a missing id would blank a visitor's header for a reason only
|
|
382
|
+
* the editor has.
|
|
383
|
+
*/
|
|
384
|
+
const id = (value as { id?: unknown }).id
|
|
385
|
+
return typeof id === 'number' || typeof id === 'string'
|
|
386
|
+
? { entries: rows, id: String(id) }
|
|
387
|
+
: { entries: rows }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Turn the dashboard's working copy into what the components expect.
|
|
392
|
+
*
|
|
393
|
+
* The working copy stores an upload as a bare id — that is what gets saved — and the
|
|
394
|
+
* maps arrive alongside it. Resolving here means the components see the same shape they
|
|
395
|
+
* get from a server fetch, so a just-picked image appears immediately and nothing
|
|
396
|
+
* downstream has to know the editor exists.
|
|
397
|
+
*/
|
|
398
|
+
const toContent = (
|
|
399
|
+
sections: Section[],
|
|
400
|
+
blocks: EditorBlock[],
|
|
401
|
+
media: MediaMap,
|
|
402
|
+
pages: PagesMap,
|
|
403
|
+
layout: ContentResult['layout'],
|
|
404
|
+
): ContentResult => {
|
|
405
|
+
const byId = new Map(
|
|
406
|
+
/*
|
|
407
|
+
Passed through rather than rebuilt key by key. Enumerating them here is what let the
|
|
408
|
+
focal point reach a published page but not the editing frame, and every field added
|
|
409
|
+
after it would have had the same silent gap.
|
|
410
|
+
*/
|
|
411
|
+
Object.entries(media).map(([id, entry]) => [
|
|
412
|
+
id,
|
|
413
|
+
{ ...entry, alt: entry.alt ?? '', url: entry.url ?? null },
|
|
414
|
+
]),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
resolveUploadsIn(sections, blocks, byId)
|
|
418
|
+
/*
|
|
419
|
+
* The layout's blocks are declared in the same manifest as the page's, so the one walk
|
|
420
|
+
* resolves both. It has to reach here as well as the sections: a client who picks a new
|
|
421
|
+
* logo has picked it in the header, and an unresolved id renders as no image at all —
|
|
422
|
+
* the frame would blank the very thing they were looking at.
|
|
423
|
+
*/
|
|
424
|
+
if (layout) resolveUploadsIn(layout.entries, blocks, byId)
|
|
425
|
+
|
|
426
|
+
// Page links are shallow and always named `page`, so a targeted walk is enough.
|
|
427
|
+
const resolvePages = (value: unknown): void => {
|
|
428
|
+
if (Array.isArray(value)) {
|
|
429
|
+
value.forEach(resolvePages)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (!value || typeof value !== 'object') return
|
|
433
|
+
const record = value as Record<string, unknown>
|
|
434
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
435
|
+
if (key === 'page' && (typeof entry === 'number' || typeof entry === 'string')) {
|
|
436
|
+
const found = pages[String(entry)]
|
|
437
|
+
record[key] = found ? { slug: found.slug ?? '', title: found.title ?? '' } : null
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
resolvePages(entry)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
resolvePages(sections)
|
|
444
|
+
if (layout) resolvePages(layout.entries)
|
|
445
|
+
|
|
446
|
+
/*
|
|
447
|
+
* `index` stays the page's own. Layout entries are addressed by their position in the
|
|
448
|
+
* layout — `~12.0.tagline` — never by `blockType`, so indexing them here would build a
|
|
449
|
+
* second, conflicting answer to "where is the hero" for the one field path that has to
|
|
450
|
+
* mean the page.
|
|
451
|
+
*/
|
|
452
|
+
/*
|
|
453
|
+
* `seo` and `title` stay null: the dashboard's working copy has nothing to push for them
|
|
454
|
+
* yet — the editing bridge does not carry SEO fields — and `null` is the same "inherit"
|
|
455
|
+
* answer a real fetch gives a page that has none.
|
|
456
|
+
*/
|
|
457
|
+
return { index: indexSections(sections), layout, seo: null, sections, status: 'ok', title: null }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolve the uploads inside pushed rows, so a just-picked photo appears immediately.
|
|
462
|
+
*
|
|
463
|
+
* Each row is wrapped as a one-section page against its collection's own fields — the
|
|
464
|
+
* same trick the platform uses in `manifest/entrySchema.ts` — so the one upload walker
|
|
465
|
+
* in `./uploads` applies unchanged rather than growing a second traversal for rows.
|
|
466
|
+
*
|
|
467
|
+
* A collection the site didn't declare is passed through untouched: nothing can be said
|
|
468
|
+
* about the shape of rows there are no field definitions for, and guessing is how images
|
|
469
|
+
* get lost.
|
|
470
|
+
*/
|
|
471
|
+
const toEntries = (
|
|
472
|
+
pushed: EntriesByCollection,
|
|
473
|
+
collections: ManifestCollection[],
|
|
474
|
+
media: MediaMap,
|
|
475
|
+
): EntriesByCollection => {
|
|
476
|
+
const byId = new Map(
|
|
477
|
+
/*
|
|
478
|
+
Passed through rather than rebuilt key by key. Enumerating them here is what let the
|
|
479
|
+
focal point reach a published page but not the editing frame, and every field added
|
|
480
|
+
after it would have had the same silent gap.
|
|
481
|
+
*/
|
|
482
|
+
Object.entries(media).map(([id, entry]) => [
|
|
483
|
+
id,
|
|
484
|
+
{ ...entry, alt: entry.alt ?? '', url: entry.url ?? null },
|
|
485
|
+
]),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
const resolved: EntriesByCollection = {}
|
|
489
|
+
|
|
490
|
+
for (const [slug, rows] of Object.entries(pushed)) {
|
|
491
|
+
if (!Array.isArray(rows)) continue
|
|
492
|
+
|
|
493
|
+
const declared = collections.find((entry) => entry.slug === slug)
|
|
494
|
+
const blocks: EditorBlock[] = declared
|
|
495
|
+
? [{ fields: declared.fields, label: declared.label, slug: '__entry' }]
|
|
496
|
+
: []
|
|
497
|
+
|
|
498
|
+
resolved[slug] = rows.map((row) => {
|
|
499
|
+
const data = row?.data && typeof row.data === 'object' ? row.data : {}
|
|
500
|
+
if (blocks.length === 0) return { data, id: row.id }
|
|
501
|
+
|
|
502
|
+
/*
|
|
503
|
+
* A copy, wrapped. `resolveUploadsIn` works in place, and the pushed object is the
|
|
504
|
+
* dashboard's working copy — resolving into it would replace the ids it is about to
|
|
505
|
+
* save with objects. The wrapper comes off so `blockType` never reaches a component.
|
|
506
|
+
*/
|
|
507
|
+
const copy: Record<string, unknown> = { ...data, blockType: '__entry' }
|
|
508
|
+
resolveUploadsIn([copy], blocks, byId)
|
|
509
|
+
delete copy.blockType
|
|
510
|
+
|
|
511
|
+
return { data: copy, id: row.id }
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return resolved
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export const useLiveContent = ({
|
|
519
|
+
blocks,
|
|
520
|
+
collections = [],
|
|
521
|
+
initial = { index: {}, layout: null, seo: null, sections: [], status: 'ok', title: null },
|
|
522
|
+
initialEntries = {},
|
|
523
|
+
platformOrigin,
|
|
524
|
+
language,
|
|
525
|
+
}: {
|
|
526
|
+
/** The site's own schema, so uploads are found precisely rather than by name. */
|
|
527
|
+
blocks: EditorBlock[]
|
|
528
|
+
/**
|
|
529
|
+
* The site's collection declarations, from `defineCollections`.
|
|
530
|
+
*
|
|
531
|
+
* Needed for the same reason `blocks` is: to find the uploads inside a pushed row by
|
|
532
|
+
* what the site actually declares rather than by guessing at field names. Omitted, rows
|
|
533
|
+
* still arrive — their images just won't update until the next save.
|
|
534
|
+
*/
|
|
535
|
+
collections?: ManifestCollection[]
|
|
536
|
+
/**
|
|
537
|
+
* The server-fetched page content. Optional: a route rendered entirely from collection
|
|
538
|
+
* rows — a project's own page — has no page document behind it and passes nothing.
|
|
539
|
+
*/
|
|
540
|
+
initial?: ContentResult
|
|
541
|
+
/** The server-fetched rows, returned as `entries` until the dashboard pushes any. */
|
|
542
|
+
initialEntries?: EntriesByCollection
|
|
543
|
+
platformOrigin: string
|
|
544
|
+
language?: LanguageFrameContext
|
|
545
|
+
}): LiveContent => {
|
|
546
|
+
/*
|
|
547
|
+
* Only the dashboard's pushes are held in state; `initial` is read straight through.
|
|
548
|
+
* Mirroring the prop into state would need an effect to keep the two in step, and
|
|
549
|
+
* would make a server navigation silently lose an unsaved edit.
|
|
550
|
+
*/
|
|
551
|
+
const [override, setOverride] = useState<ContentResult | null>(null)
|
|
552
|
+
/*
|
|
553
|
+
* Held apart from `override` even though both arrive in one message, because they have
|
|
554
|
+
* different fallbacks: a page with no sections is a real answer, while a collection the
|
|
555
|
+
* dashboard has not pushed must fall back to what the server fetched rather than to an
|
|
556
|
+
* empty list. Merged over `initialEntries` on read, below.
|
|
557
|
+
*/
|
|
558
|
+
const [entriesOverride, setEntriesOverride] = useState<EntriesByCollection | null>(null)
|
|
559
|
+
const [selected, setSelected] = useState<null | string>(null)
|
|
560
|
+
const languageLocale = language?.locale
|
|
561
|
+
const languageRevision = language?.revision
|
|
562
|
+
const identity = JSON.stringify([languageLocale, languageRevision])
|
|
563
|
+
const [contextIdentity, setContextIdentity] = useState(identity)
|
|
564
|
+
// Adjust this component before React commits a new context. Old-locale overrides
|
|
565
|
+
// never reach its first paint, and no effect resets a newly received content push.
|
|
566
|
+
if (contextIdentity !== identity) {
|
|
567
|
+
setContextIdentity(identity)
|
|
568
|
+
setOverride(null)
|
|
569
|
+
setEntriesOverride(null)
|
|
570
|
+
setSelected(null)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The field this frame last reported as clicked, so its own echo does not scroll the page.
|
|
575
|
+
*
|
|
576
|
+
* A ref rather than state: it is read once by the message that comes straight back and never
|
|
577
|
+
* rendered, and putting it in state would re-render the page mid-click for no one's benefit.
|
|
578
|
+
*/
|
|
579
|
+
const selfSelected = useRef<null | string>(null)
|
|
580
|
+
/*
|
|
581
|
+
* Links are inert until the operator says otherwise.
|
|
582
|
+
*
|
|
583
|
+
* Held twice, and both are needed. The click listener is registered once and would
|
|
584
|
+
* capture the initial value forever, so it reads the ref — the same problem
|
|
585
|
+
* `isActive` solves with a closure variable. But this one also drives the hover
|
|
586
|
+
* affordances, so it has to be state as well. Both are written together in the
|
|
587
|
+
* message handler, never during render.
|
|
588
|
+
*/
|
|
589
|
+
const [navigation, setNavigation] = useState(false)
|
|
590
|
+
const navigationRef = useRef(false)
|
|
591
|
+
const embedded = useIsFramed()
|
|
592
|
+
|
|
593
|
+
/*
|
|
594
|
+
* The pushed working copy over the server-fetched page — but the chrome falls back the
|
|
595
|
+
* way a collection the dashboard never pushed falls back to its fetched rows.
|
|
596
|
+
*
|
|
597
|
+
* A push carrying no layout means "this push says nothing about the chrome": a platform
|
|
598
|
+
* older than bridge 3, or a page whose layout resolved to nothing. Letting the override's
|
|
599
|
+
* `null` win there would blank a header the server had already rendered, the moment the
|
|
600
|
+
* client touched anything — the one case where the live copy knows *less* than the page
|
|
601
|
+
* it is replacing. Nothing changes outside the editor, where there is no override at all
|
|
602
|
+
* and `initial` is read through untouched.
|
|
603
|
+
*/
|
|
604
|
+
const content = useMemo(
|
|
605
|
+
() => (override ? { ...override, layout: override.layout ?? initial.layout } : initial),
|
|
606
|
+
[initial, override],
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
/*
|
|
610
|
+
* Pushed rows over the server-fetched ones, per collection.
|
|
611
|
+
*
|
|
612
|
+
* Merged rather than replaced: a frame showing a project's own page is pushed every
|
|
613
|
+
* collection the tenant has, but a site that only fetched `projects` server-side should
|
|
614
|
+
* not suddenly gain the others — and, more importantly, a collection the dashboard has
|
|
615
|
+
* *not* pushed must keep the rows this page rendered with rather than becoming empty.
|
|
616
|
+
*/
|
|
617
|
+
const entries = useMemo(
|
|
618
|
+
() => (entriesOverride ? { ...initialEntries, ...entriesOverride } : initialEntries),
|
|
619
|
+
[entriesOverride, initialEntries],
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
const send = useCallback(
|
|
623
|
+
(message: Record<string, unknown>) => {
|
|
624
|
+
if (!platformOrigin || window.parent === window) return
|
|
625
|
+
window.parent.postMessage(
|
|
626
|
+
{
|
|
627
|
+
...message,
|
|
628
|
+
version: BRIDGE_VERSION,
|
|
629
|
+
...(languageLocale
|
|
630
|
+
? { languageVersion: 1, locale: languageLocale, languageRevision }
|
|
631
|
+
: {}),
|
|
632
|
+
},
|
|
633
|
+
platformOrigin,
|
|
634
|
+
)
|
|
635
|
+
},
|
|
636
|
+
[platformOrigin, languageLocale, languageRevision],
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
useEffect(() => {
|
|
640
|
+
if (!platformOrigin || !embedded) return
|
|
641
|
+
|
|
642
|
+
const languageHints = createLanguageHints()
|
|
643
|
+
|
|
644
|
+
/*
|
|
645
|
+
* Whether this frame is the active screen.
|
|
646
|
+
*
|
|
647
|
+
* An active screen owns its scrolling — the wheel scrolls the page, as it would in a
|
|
648
|
+
* tab. Otherwise the wheel is handed back so it pans the canvas: the pointer is
|
|
649
|
+
* nearly always over some screen, so without this a stage of screens couldn't be
|
|
650
|
+
* moved around at all.
|
|
651
|
+
*
|
|
652
|
+
* A closure rather than React state: the wheel listener has to read the current
|
|
653
|
+
* value, and re-registering a non-passive listener on every change is needless churn.
|
|
654
|
+
*/
|
|
655
|
+
let isActive = false
|
|
656
|
+
|
|
657
|
+
/*
|
|
658
|
+
* What this frame's viewport units are currently pinned to, or null while they measure
|
|
659
|
+
* against the frame the way a browser would. Held so a repeated instruction — the
|
|
660
|
+
* dashboard re-sends this whenever the frame reloads — does not tear the pins down and
|
|
661
|
+
* rebuild them for no change.
|
|
662
|
+
*/
|
|
663
|
+
let pinnedHeight: null | number = null
|
|
664
|
+
|
|
665
|
+
/*
|
|
666
|
+
* ── Editing in place ──────────────────────────────────────────────────────
|
|
667
|
+
*
|
|
668
|
+
* Double-clicking a leaf text field makes that very element contentEditable, so the
|
|
669
|
+
* client types into their own heading in their own typeface rather than into a box on
|
|
670
|
+
* the right that happens to drive it.
|
|
671
|
+
*
|
|
672
|
+
* Closure variables rather than state, for the same reason `isActive` is: every one of
|
|
673
|
+
* them is read by listeners registered once, and none of them should re-render the page
|
|
674
|
+
* — the DOM the client is typing into is already showing the truth.
|
|
675
|
+
*/
|
|
676
|
+
let editing: HTMLElement | null = null
|
|
677
|
+
/**
|
|
678
|
+
* The last edit typed but not yet sent, so a blur can never lose the final keystrokes.
|
|
679
|
+
*
|
|
680
|
+
* A discriminated union rather than one shape with both fields optional, so a rich edit
|
|
681
|
+
* carrying a plain string — or a plain edit carrying an element — cannot be constructed.
|
|
682
|
+
*
|
|
683
|
+
* The rich branch carries the **element**, not `toRichText`'s result. Serialising is a
|
|
684
|
+
* full recursive walk of the field's DOM plus a fresh tree allocation, and `onInput` runs
|
|
685
|
+
* once per keystroke; storing the element and calling `toRichText` only from `flushEdit`
|
|
686
|
+
* means a whole document is walked once per *flush*, not once per character typed into
|
|
687
|
+
* it. This is safe to defer: `heldContent` holds back any content push for as long as
|
|
688
|
+
* `editing` is set, so nothing else touches this element's children before the flush
|
|
689
|
+
* runs, and even an element removed from the document in between keeps its `childNodes`.
|
|
690
|
+
*/
|
|
691
|
+
let unsent:
|
|
692
|
+
| null
|
|
693
|
+
| { element: HTMLElement; field: string; rich: true }
|
|
694
|
+
| { field: string; rich: false; value: string } = null
|
|
695
|
+
let editTimer = 0
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* A content push held back while the client is typing.
|
|
699
|
+
*
|
|
700
|
+
* Applying it would re-render the very element the caret is in. React would assign the
|
|
701
|
+
* text node — with the same string it already holds, since the dashboard is echoing back
|
|
702
|
+
* what was just typed — and the browser collapses the selection when it does, so the
|
|
703
|
+
* caret jumps to the start of the field on every keystroke.
|
|
704
|
+
*
|
|
705
|
+
* Held instead, and applied when editing ends. Only the frame being typed in holds back —
|
|
706
|
+
* every other screen on the stage updates live, which is the point of having them.
|
|
707
|
+
*
|
|
708
|
+
* Nothing is lost *while this element is where the copy came from*, which is the premise
|
|
709
|
+
* and the whole of the limit: a client editing the same field from the inspector panel is
|
|
710
|
+
* a second author for it, and against that one the hold is simply a stale frame. So the
|
|
711
|
+
* content handler lets go of the field when this document has no focus — see there.
|
|
712
|
+
*/
|
|
713
|
+
let heldContent: null | Record<string, unknown> = null
|
|
714
|
+
|
|
715
|
+
/** Stop an event reaching anything else, including the page's own scroller. */
|
|
716
|
+
const stopScrolling = (event: Event) => {
|
|
717
|
+
event.preventDefault()
|
|
718
|
+
event.stopPropagation()
|
|
719
|
+
event.stopImmediatePropagation()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const applyContent = (data: Record<string, unknown>) => {
|
|
723
|
+
const media = (data.media as MediaMap | undefined) ?? {}
|
|
724
|
+
|
|
725
|
+
setOverride(
|
|
726
|
+
toContent(
|
|
727
|
+
data.sections as Section[],
|
|
728
|
+
blocks,
|
|
729
|
+
media,
|
|
730
|
+
(data.pages as PagesMap | undefined) ?? {},
|
|
731
|
+
toLayout(data.layout),
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
/*
|
|
736
|
+
* Rows, when the dashboard sends any. Guarded on presence rather than assumed:
|
|
737
|
+
* `entries` arrived with bridge version 2, and a site on this kit talking to an
|
|
738
|
+
* older platform would otherwise wipe its own rows on the first push.
|
|
739
|
+
*/
|
|
740
|
+
const pushed = data.entries
|
|
741
|
+
if (pushed && typeof pushed === 'object' && !Array.isArray(pushed)) {
|
|
742
|
+
setEntriesOverride(toEntries(pushed as EntriesByCollection, collections, media))
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const flushEdit = () => {
|
|
747
|
+
window.clearTimeout(editTimer)
|
|
748
|
+
if (!unsent) return false
|
|
749
|
+
if (unsent.rich) {
|
|
750
|
+
send({
|
|
751
|
+
field: unsent.field,
|
|
752
|
+
type: 'u90:edit-rich',
|
|
753
|
+
value: toRichText(asDomLike(unsent.element)),
|
|
754
|
+
})
|
|
755
|
+
} else {
|
|
756
|
+
send({ field: unsent.field, type: 'u90:edit', value: unsent.value })
|
|
757
|
+
}
|
|
758
|
+
unsent = null
|
|
759
|
+
return true
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Hand the element back to React.
|
|
764
|
+
*
|
|
765
|
+
* The last edit goes out first, so releasing the field can't drop what was typed into
|
|
766
|
+
* it. If something did go out, the held content is dropped rather than applied: it
|
|
767
|
+
* predates that keystroke, and the push answering it is already on its way — applying
|
|
768
|
+
* the older one would flash the previous words back for a frame.
|
|
769
|
+
*/
|
|
770
|
+
const endEditing = () => {
|
|
771
|
+
const element = editing
|
|
772
|
+
if (!element) return
|
|
773
|
+
editing = null
|
|
774
|
+
clearCaret()
|
|
775
|
+
|
|
776
|
+
const flushed = flushEdit()
|
|
777
|
+
|
|
778
|
+
element.removeAttribute('contenteditable')
|
|
779
|
+
element.removeAttribute('data-u90-editing')
|
|
780
|
+
|
|
781
|
+
if (heldContent && !flushed) applyContent(heldContent)
|
|
782
|
+
heldContent = null
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const beginEditing = (element: HTMLElement) => {
|
|
786
|
+
if (editing === element) return
|
|
787
|
+
endEditing()
|
|
788
|
+
editing = element
|
|
789
|
+
|
|
790
|
+
/*
|
|
791
|
+
* The two field kinds want opposite things here, and that asymmetry is deliberate,
|
|
792
|
+
* not a gap.
|
|
793
|
+
*
|
|
794
|
+
* A plain field wants the browser to refuse markup outright: `plaintext-only` is what
|
|
795
|
+
* stops a paste bringing the source page's own markup into a field that has nowhere to
|
|
796
|
+
* store it, and what stops the client bolding half a heading with no way to keep that.
|
|
797
|
+
* Firefox only learned `plaintext-only` recently; where it isn't understood the
|
|
798
|
+
* attribute leaves `isContentEditable` false, so plain `true` takes over there too.
|
|
799
|
+
*
|
|
800
|
+
* A rich field wants the opposite: its whole point is the browser's own formatting
|
|
801
|
+
* commands, ⌘B and ⌘I (see the note above `asDomLike`). `plaintext-only` disables
|
|
802
|
+
* exactly those commands on Chromium and WebKit, which would make a rich field's
|
|
803
|
+
* formatting inert the moment it became editable — so a rich field is always `true`,
|
|
804
|
+
* never `plaintext-only`, and a plain field is never flipped the other way: that
|
|
805
|
+
* paste protection must not change.
|
|
806
|
+
*/
|
|
807
|
+
if (element.hasAttribute(RICH_ATTR)) {
|
|
808
|
+
element.setAttribute('contenteditable', 'true')
|
|
809
|
+
} else {
|
|
810
|
+
element.setAttribute('contenteditable', 'plaintext-only')
|
|
811
|
+
if (!element.isContentEditable) element.setAttribute('contenteditable', 'true')
|
|
812
|
+
}
|
|
813
|
+
element.setAttribute('data-u90-editing', '')
|
|
814
|
+
|
|
815
|
+
/*
|
|
816
|
+
* `preventScroll`, because the element is under the pointer by definition — the client
|
|
817
|
+
* just clicked it — so there is nothing to scroll to, and the default would scroll
|
|
818
|
+
* anyway. These sites run Lenis, which owns the scroll position: a native scroll into
|
|
819
|
+
* view fights it and can land somewhere neither chose. Harmless when editing needed a
|
|
820
|
+
* double-click and the element was already centred by the earlier select; now that one
|
|
821
|
+
* click starts it, this fires on every click into text.
|
|
822
|
+
*/
|
|
823
|
+
element.focus({ preventScroll: true })
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const onMessage = (event: MessageEvent) => {
|
|
827
|
+
if (event.origin !== platformOrigin || event.source !== window.parent) return
|
|
828
|
+
const data = event.data as null | Record<string, unknown>
|
|
829
|
+
if (!data || typeof data.type !== 'string') return
|
|
830
|
+
if (!acceptsLanguageFrame(language, data)) return
|
|
831
|
+
|
|
832
|
+
if (
|
|
833
|
+
data.type === 'u90:flush' &&
|
|
834
|
+
data.version === BRIDGE_VERSION &&
|
|
835
|
+
typeof data.requestId === 'string' &&
|
|
836
|
+
data.requestId.length > 0 &&
|
|
837
|
+
data.requestId.length < 100
|
|
838
|
+
) {
|
|
839
|
+
// postMessage ordering guarantees the parent stages this edit before observing the ack.
|
|
840
|
+
endEditing()
|
|
841
|
+
flushEdit()
|
|
842
|
+
send({ type: 'u90:flushed', requestId: data.requestId })
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (data.type === 'u90:content' && Array.isArray(data.sections)) {
|
|
847
|
+
languageHints.update(data.languageHints)
|
|
848
|
+
/*
|
|
849
|
+
* A push arriving while this document has no focus is the dashboard's panel typing,
|
|
850
|
+
* not this frame's — so let go of the field rather than hold the push back.
|
|
851
|
+
*
|
|
852
|
+
* The hold below exists to protect a caret in *this* frame (see `heldContent`). Its
|
|
853
|
+
* premise is that the element is where the copy came from, which stops being true the
|
|
854
|
+
* moment the client edits the same field from the inspector: every keystroke there
|
|
855
|
+
* was then queued behind an element nobody was typing in, and the frame only caught
|
|
856
|
+
* up when they clicked back into it and `endEditing` flushed. That is the whole of the
|
|
857
|
+
* "it only updates when I click out of the box" report.
|
|
858
|
+
*
|
|
859
|
+
* `endEditing` rather than merely skipping the hold: it flushes anything typed here
|
|
860
|
+
* first, so nothing is lost, and it clears `editing` so the element React is about to
|
|
861
|
+
* replace is not left behind as a detached node this branch keeps consulting.
|
|
862
|
+
*
|
|
863
|
+
* **Asked here and not in `onFocusOut`, which is the important part.** That handler
|
|
864
|
+
* deliberately ignores a focus loss with no `relatedTarget`, and the note there
|
|
865
|
+
* records why `document.hasFocus()` cannot help it: `focusout` fires *while* focus is
|
|
866
|
+
* leaving, and the document can still claim to have it. A message handler runs long
|
|
867
|
+
* after that transition has settled, so the same question has a reliable answer here.
|
|
868
|
+
*
|
|
869
|
+
* The formatting toolbar is unaffected. It also takes focus out of this frame, but it
|
|
870
|
+
* speaks through `u90:format`, and a `u90:content` push means the dashboard's own copy
|
|
871
|
+
* changed — which a press on Heading 1 does not do until the format has already been
|
|
872
|
+
* applied and echoed back.
|
|
873
|
+
*/
|
|
874
|
+
if (editing && !document.hasFocus()) endEditing()
|
|
875
|
+
if (editing) heldContent = data
|
|
876
|
+
else applyContent(data)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (data.type === 'u90:format') {
|
|
880
|
+
applyFormat(data.action, typeof data.url === 'string' ? data.url : undefined)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (data.type === 'u90:select') {
|
|
884
|
+
const field = typeof data.field === 'string' ? data.field : null
|
|
885
|
+
setSelected(field)
|
|
886
|
+
|
|
887
|
+
/*
|
|
888
|
+
* A cleared selection is the one authority that can end an edit from outside.
|
|
889
|
+
*
|
|
890
|
+
* `onFocusOut` deliberately holds the edit open when focus leaves this document,
|
|
891
|
+
* because clicking the formatting toolbar in the parent window looks exactly like
|
|
892
|
+
* clicking away — both report no `relatedTarget`. From in here the two are the same
|
|
893
|
+
* event, so the toolbar's needs win and the field stays live.
|
|
894
|
+
*
|
|
895
|
+
* Clicking the empty canvas is the case that left behind: the frame is told the
|
|
896
|
+
* screen was let go, and until this ran the element kept `contenteditable` and its
|
|
897
|
+
* outline, still taking keystrokes for a screen the dashboard had already dropped.
|
|
898
|
+
* The parent is the only side that can tell the two apart, and `field: null` is it
|
|
899
|
+
* saying so.
|
|
900
|
+
*
|
|
901
|
+
* `endEditing` flushes first, so nothing typed before the click is lost.
|
|
902
|
+
*/
|
|
903
|
+
if (field === null) endEditing()
|
|
904
|
+
|
|
905
|
+
/*
|
|
906
|
+
* One frame later, deliberately. `u90:select` and `u90:content` are separate
|
|
907
|
+
* messages with no ordering guarantee, so a selection arriving with fresh content
|
|
908
|
+
* may name an element React has not laid out yet — measuring it in this tick would
|
|
909
|
+
* read a stale rect, or nothing at all.
|
|
910
|
+
*/
|
|
911
|
+
/*
|
|
912
|
+
* Only travel for a selection made somewhere else — the inspector's rail, a deep link.
|
|
913
|
+
* The echo of a click the client just made must not move the page under their cursor;
|
|
914
|
+
* see the note where `selfSelected` is set.
|
|
915
|
+
*/
|
|
916
|
+
const echo = field !== null && field === selfSelected.current
|
|
917
|
+
selfSelected.current = null
|
|
918
|
+
if (field && !echo) requestAnimationFrame(() => revealField(field))
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/*
|
|
922
|
+
* Back to the top of this page.
|
|
923
|
+
*
|
|
924
|
+
* Instant, not smooth, for `revealField`'s reason: these sites run Lenis, which owns
|
|
925
|
+
* the scroll position and animates it itself, and a smooth native scroll competes with
|
|
926
|
+
* it and can land somewhere neither intended. `scrollTo` with no behaviour is one
|
|
927
|
+
* position change Lenis picks up on its next frame.
|
|
928
|
+
*
|
|
929
|
+
* Unconditional, unlike `revealField`'s settled check. There is no target to be
|
|
930
|
+
* already-looking-at here: the client asked for the top, and a page already at the top
|
|
931
|
+
* is unmoved by being sent there.
|
|
932
|
+
*/
|
|
933
|
+
if (data.type === 'u90:scroll' && data.to === 'top') {
|
|
934
|
+
window.scrollTo({ top: 0 })
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/*
|
|
938
|
+
* Synced scrolling: sit at the same proportion of this page as the screen the client is
|
|
939
|
+
* scrolling. The dashboard has already clamped the fraction, and re-reads it here anyway
|
|
940
|
+
* — it arrives from another window, and a NaN would resolve to the top and make every
|
|
941
|
+
* other screen jump while the one being scrolled stayed put.
|
|
942
|
+
*
|
|
943
|
+
* `scrollTo` with no behaviour for the same reason the top handler uses it: one position
|
|
944
|
+
* change that a smooth-scroll library picks up on its next frame, rather than an animation
|
|
945
|
+
* it fights. Smooth here would be worse than useless anyway — the positions arrive as fast
|
|
946
|
+
* as the client scrolls, and each one would cancel the last.
|
|
947
|
+
*/
|
|
948
|
+
if (data.type === 'u90:scroll' && data.to === 'fraction') {
|
|
949
|
+
const fraction = typeof data.fraction === 'number' ? data.fraction : NaN
|
|
950
|
+
if (Number.isFinite(fraction)) {
|
|
951
|
+
const max = document.documentElement.scrollHeight - window.innerHeight
|
|
952
|
+
window.scrollTo({ top: max > 0 ? Math.min(1, Math.max(0, fraction)) * max : 0 })
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (data.type === 'u90:active') {
|
|
957
|
+
isActive = data.active === true
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/*
|
|
961
|
+
* What a viewport unit measures against, while this frame is drawn full length.
|
|
962
|
+
*
|
|
963
|
+
* Applied before the height is re-reported, and that order is the point: pinning
|
|
964
|
+
* changes how tall the document is, so the dashboard has to be told the pinned
|
|
965
|
+
* height, not the one from a moment ago. `reportHeight` filters an unchanged
|
|
966
|
+
* number, so when nothing moved this costs a measurement and no message.
|
|
967
|
+
*/
|
|
968
|
+
if (data.type === 'u90:viewport') {
|
|
969
|
+
const height = typeof data.height === 'number' && data.height > 0 ? data.height : null
|
|
970
|
+
if (height !== pinnedHeight) {
|
|
971
|
+
pinnedHeight = height
|
|
972
|
+
pinViewport(height)
|
|
973
|
+
reportHeight()
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/*
|
|
978
|
+
* Whether links in this frame behave like links.
|
|
979
|
+
*
|
|
980
|
+
* Off by default while embedded, because inside the editor a click means "edit
|
|
981
|
+
* this", and the alternative is unusable: a button's marker sits on its label,
|
|
982
|
+
* so clicking the padding around the words used to navigate, and a card whose
|
|
983
|
+
* whole surface is a stretched anchor could not be selected at all.
|
|
984
|
+
*
|
|
985
|
+
* The operator turns it on to walk to another page and edit that. Kept in state
|
|
986
|
+
* rather than a closure — unlike `isActive` — because the affordance styles
|
|
987
|
+
* change with it, so a render is exactly what should happen.
|
|
988
|
+
*/
|
|
989
|
+
if (data.type === 'u90:navigation') {
|
|
990
|
+
const enabled = data.enabled === true
|
|
991
|
+
navigationRef.current = enabled
|
|
992
|
+
setNavigation(enabled)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
window.addEventListener('message', onMessage)
|
|
997
|
+
|
|
998
|
+
// Announce readiness, then report which fields this page actually renders, so the
|
|
999
|
+
// dashboard can tell modelled fields from ones that reach the page.
|
|
1000
|
+
send({ type: 'u90:ready' })
|
|
1001
|
+
|
|
1002
|
+
/*
|
|
1003
|
+
* Tell the dashboard which route this frame is showing, and keep telling it.
|
|
1004
|
+
*
|
|
1005
|
+
* A client browsing their own site inside a screen — clicking "Who We Are" in the
|
|
1006
|
+
* real nav — leaves the dashboard's page readout showing whatever the screen was
|
|
1007
|
+
* opened on, because the dashboard cannot see inside a cross-origin frame and
|
|
1008
|
+
* nothing reported the change.
|
|
1009
|
+
*
|
|
1010
|
+
* `popstate` covers the back button. A client-side push does not fire an event of
|
|
1011
|
+
* its own, so `pushState` and `replaceState` are wrapped — the alternative is
|
|
1012
|
+
* polling `location.pathname`, which spends a timer forever to catch something
|
|
1013
|
+
* that happens a few times a session.
|
|
1014
|
+
*
|
|
1015
|
+
* Both originals are restored on cleanup, so a site that navigates after the
|
|
1016
|
+
* editor closes is left exactly as it was.
|
|
1017
|
+
*/
|
|
1018
|
+
const reportPath = () => send({ path: window.location.pathname, type: 'u90:path' })
|
|
1019
|
+
|
|
1020
|
+
reportPath()
|
|
1021
|
+
|
|
1022
|
+
const { pushState, replaceState } = window.history
|
|
1023
|
+
|
|
1024
|
+
window.history.pushState = function patchedPushState(...args) {
|
|
1025
|
+
pushState.apply(this, args)
|
|
1026
|
+
reportPath()
|
|
1027
|
+
}
|
|
1028
|
+
window.history.replaceState = function patchedReplaceState(...args) {
|
|
1029
|
+
replaceState.apply(this, args)
|
|
1030
|
+
reportPath()
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
window.addEventListener('popstate', reportPath)
|
|
1034
|
+
|
|
1035
|
+
const raf = requestAnimationFrame(() => {
|
|
1036
|
+
const fields = [...document.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`)]
|
|
1037
|
+
.map((element) => element.getAttribute(FIELD_ATTR))
|
|
1038
|
+
.filter((value): value is string => Boolean(value))
|
|
1039
|
+
send({ fields: [...new Set(fields)], type: 'u90:fields' })
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
/*
|
|
1043
|
+
* How tall this page is, so a screen can be shown as one whole strip.
|
|
1044
|
+
*
|
|
1045
|
+
* The dashboard cannot measure it — the frame is cross-origin — so a screen set to
|
|
1046
|
+
* "whole page" is drawn at whatever this last reported. It has to keep reporting:
|
|
1047
|
+
* images settle, fonts swap, and every content push from the editor can change the
|
|
1048
|
+
* height as the client types.
|
|
1049
|
+
*
|
|
1050
|
+
* `scrollHeight` of the root element rather than `body`, because a body with collapsed
|
|
1051
|
+
* margins or a fixed-position layout reports its own box and not the document's.
|
|
1052
|
+
*
|
|
1053
|
+
* Reported only when it has actually moved by more than a pixel. A `ResizeObserver`
|
|
1054
|
+
* fires on sub-pixel reflows, and each report crosses an origin boundary and resizes
|
|
1055
|
+
* an element on the stage — so an unfiltered stream would have the artboard shivering
|
|
1056
|
+
* for the whole of an animation.
|
|
1057
|
+
*/
|
|
1058
|
+
let lastHeight = 0
|
|
1059
|
+
const reportHeight = () => {
|
|
1060
|
+
const height = document.documentElement.scrollHeight
|
|
1061
|
+
if (Math.abs(height - lastHeight) < 1) return
|
|
1062
|
+
lastHeight = height
|
|
1063
|
+
send({ height, type: 'u90:height' })
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
reportHeight()
|
|
1067
|
+
const heights = new ResizeObserver(reportHeight)
|
|
1068
|
+
heights.observe(document.documentElement)
|
|
1069
|
+
|
|
1070
|
+
/*
|
|
1071
|
+
* Wheel: capture phase on `window`, and the event is stopped outright rather than
|
|
1072
|
+
* merely default-prevented.
|
|
1073
|
+
*
|
|
1074
|
+
* A smooth-scroll library listens for the wheel itself and then moves the page
|
|
1075
|
+
* *programmatically*, so `preventDefault` doesn't touch it and an inactive screen
|
|
1076
|
+
* kept scrolling. Those libraries bind non-capture, so a capture-phase listener
|
|
1077
|
+
* always runs first and `stopImmediatePropagation` means they never see it.
|
|
1078
|
+
*/
|
|
1079
|
+
const onWheel = (event: WheelEvent) => {
|
|
1080
|
+
if (event.ctrlKey || event.metaKey) {
|
|
1081
|
+
event.preventDefault()
|
|
1082
|
+
send({ deltaY: event.deltaY, type: 'u90:zoom', x: event.clientX, y: event.clientY })
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
if (isActive) return
|
|
1086
|
+
stopScrolling(event)
|
|
1087
|
+
send({ deltaX: event.deltaX, deltaY: event.deltaY, type: 'u90:wheel' })
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
window.addEventListener('wheel', onWheel, { capture: true, passive: false })
|
|
1091
|
+
|
|
1092
|
+
/*
|
|
1093
|
+
* Where this page is scrolled, for the dashboard to fan out to the other screens showing
|
|
1094
|
+
* it. See `u90:scrolled` in the platform's `bridge.ts`.
|
|
1095
|
+
*
|
|
1096
|
+
* **Only while this frame is the active screen.** That is the whole feedback guard: a
|
|
1097
|
+
* frame the relay has just scrolled is not the active one, so it says nothing back, and
|
|
1098
|
+
* the loop that would otherwise run round the stage never starts. Cheaper than any
|
|
1099
|
+
* echo-suppression scheme, and it cannot drift out of step with itself.
|
|
1100
|
+
*
|
|
1101
|
+
* Coalesced to one report per frame. `scroll` fires far faster than paint on a trackpad,
|
|
1102
|
+
* and every one of these crosses a window boundary and moves other documents; sending
|
|
1103
|
+
* them all would spend the client's frame budget on positions already superseded.
|
|
1104
|
+
*
|
|
1105
|
+
* Passive, because this never wants to block the scroll it is watching.
|
|
1106
|
+
*/
|
|
1107
|
+
let scrollPending = 0
|
|
1108
|
+
const reportScroll = () => {
|
|
1109
|
+
if (!isActive || scrollPending) return
|
|
1110
|
+
scrollPending = requestAnimationFrame(() => {
|
|
1111
|
+
scrollPending = 0
|
|
1112
|
+
if (!isActive) return
|
|
1113
|
+
send({
|
|
1114
|
+
max: document.documentElement.scrollHeight - window.innerHeight,
|
|
1115
|
+
top: window.scrollY,
|
|
1116
|
+
type: 'u90:scrolled',
|
|
1117
|
+
})
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
window.addEventListener('scroll', reportScroll, { passive: true })
|
|
1122
|
+
|
|
1123
|
+
/*
|
|
1124
|
+
* A middle-button pan, streamed until release.
|
|
1125
|
+
*
|
|
1126
|
+
* The press lands here, so the dashboard receives no pointer events until it ends —
|
|
1127
|
+
* the canvas would stick and then jump into place. Capturing the pointer and
|
|
1128
|
+
* streaming fixes it at the source.
|
|
1129
|
+
*
|
|
1130
|
+
* Screen coordinates, not client: panning moves this very frame under the pointer,
|
|
1131
|
+
* so a client coordinate would stop changing and the pan would stall after one step.
|
|
1132
|
+
*
|
|
1133
|
+
* Only the middle button. A left press stays with the page — it is how the client
|
|
1134
|
+
* selects what to edit.
|
|
1135
|
+
*/
|
|
1136
|
+
let panning: null | number = null
|
|
1137
|
+
|
|
1138
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1139
|
+
/*
|
|
1140
|
+
* A press anywhere else releases the field being edited.
|
|
1141
|
+
*
|
|
1142
|
+
* `focusout` alone is not enough: pressing on ordinary page copy moves focus to
|
|
1143
|
+
* nothing in particular, and in that case some browsers leave the editable focused —
|
|
1144
|
+
* so the client clicks away, sees no caret, and the element is still editable.
|
|
1145
|
+
*/
|
|
1146
|
+
if (editing && !editing.contains(event.target as Node)) endEditing()
|
|
1147
|
+
|
|
1148
|
+
/*
|
|
1149
|
+
* A left press makes this the active screen.
|
|
1150
|
+
*
|
|
1151
|
+
* The dashboard cannot see it any other way — this is a cross-origin frame, so
|
|
1152
|
+
* it consumes its own clicks and the canvas never learns one happened. Without
|
|
1153
|
+
* this message a screen can never become active: no selection outline, and
|
|
1154
|
+
* `isActive` below stays false forever, so the wheel is handed back to the
|
|
1155
|
+
* canvas and the page inside never scrolls.
|
|
1156
|
+
*
|
|
1157
|
+
* Left button only, and the press is not consumed — selecting the element under
|
|
1158
|
+
* the pointer is the whole point of framing the real site.
|
|
1159
|
+
*/
|
|
1160
|
+
if (event.button === 0) send({ type: 'u90:focus' })
|
|
1161
|
+
|
|
1162
|
+
if (event.button !== 1 || panning !== null) return
|
|
1163
|
+
event.preventDefault()
|
|
1164
|
+
panning = event.pointerId
|
|
1165
|
+
try {
|
|
1166
|
+
document.documentElement.setPointerCapture(event.pointerId)
|
|
1167
|
+
} catch {
|
|
1168
|
+
// Capture is a nicety; the window listeners still see the moves.
|
|
1169
|
+
}
|
|
1170
|
+
send({ screenX: event.screenX, screenY: event.screenY, type: 'u90:grab' })
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
1174
|
+
if (event.pointerId !== panning) return
|
|
1175
|
+
send({ screenX: event.screenX, screenY: event.screenY, type: 'u90:drag' })
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
1179
|
+
if (event.pointerId !== panning) return
|
|
1180
|
+
panning = null
|
|
1181
|
+
try {
|
|
1182
|
+
document.documentElement.releasePointerCapture(event.pointerId)
|
|
1183
|
+
} catch {
|
|
1184
|
+
// Already released.
|
|
1185
|
+
}
|
|
1186
|
+
send({ type: 'u90:drop' })
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Chrome fires this on middle-button release; without it the frame can still open a
|
|
1190
|
+
// link in a new tab as the pan ends.
|
|
1191
|
+
const onAuxClick = (event: MouseEvent) => {
|
|
1192
|
+
if (event.button === 1) event.preventDefault()
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/*
|
|
1196
|
+
* The canvas's own shortcuts. Clicking into this frame moves keyboard focus here, so
|
|
1197
|
+
* the dashboard stops receiving key events — its tool shortcuts and space-to-pan
|
|
1198
|
+
* would work only until the client touched their own page.
|
|
1199
|
+
*
|
|
1200
|
+
* `KeyN` — turn the frame's links on and off — is the clearest case for why this list
|
|
1201
|
+
* exists at all, and was missing from it: you decide you want to follow a link while
|
|
1202
|
+
* you are looking at the page, having just clicked something on it, which is precisely
|
|
1203
|
+
* when this frame owns the keyboard and the dashboard hears nothing.
|
|
1204
|
+
*
|
|
1205
|
+
* `KeyT` — send this page back to the top — is the same shape. `Home` already does it and
|
|
1206
|
+
* only from in here; T does it from either side. Safe as a plain letter for the reason
|
|
1207
|
+
* every key in this set is: the `isTyping` guard below silences all of them while the
|
|
1208
|
+
* client is typing, and in-frame editing is a `contenteditable`, which is exactly what
|
|
1209
|
+
* that guard reads.
|
|
1210
|
+
*/
|
|
1211
|
+
const CANVAS_KEYS = new Set(['KeyF', 'KeyG', 'KeyH', 'KeyN', 'KeyT', 'KeyV', 'Space'])
|
|
1212
|
+
const SCROLL_KEYS = new Set([
|
|
1213
|
+
'ArrowDown',
|
|
1214
|
+
'ArrowLeft',
|
|
1215
|
+
'ArrowRight',
|
|
1216
|
+
'ArrowUp',
|
|
1217
|
+
'End',
|
|
1218
|
+
'Home',
|
|
1219
|
+
'PageDown',
|
|
1220
|
+
'PageUp',
|
|
1221
|
+
])
|
|
1222
|
+
|
|
1223
|
+
const isTyping = (target: EventTarget | null): boolean => {
|
|
1224
|
+
const element = target as HTMLElement | null
|
|
1225
|
+
const tag = element?.tagName
|
|
1226
|
+
return (
|
|
1227
|
+
tag === 'INPUT' ||
|
|
1228
|
+
tag === 'TEXTAREA' ||
|
|
1229
|
+
tag === 'SELECT' ||
|
|
1230
|
+
Boolean(element?.isContentEditable)
|
|
1231
|
+
)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const onKey = (event: KeyboardEvent) => {
|
|
1235
|
+
/*
|
|
1236
|
+
* Escape leaves the field. Ahead of the `isTyping` guard on purpose — that guard is
|
|
1237
|
+
* what stops the canvas shortcuts firing while the client types, and an editable
|
|
1238
|
+
* element is precisely the case it silences, so the way out has to be read first.
|
|
1239
|
+
*/
|
|
1240
|
+
if (event.type === 'keydown' && event.key === 'Escape' && editing) {
|
|
1241
|
+
event.preventDefault()
|
|
1242
|
+
endEditing()
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return
|
|
1247
|
+
|
|
1248
|
+
if (CANVAS_KEYS.has(event.code)) {
|
|
1249
|
+
// Space would otherwise scroll this page while the client is panning.
|
|
1250
|
+
if (event.code === 'Space') event.preventDefault()
|
|
1251
|
+
send({
|
|
1252
|
+
code: event.code as 'KeyF' | 'KeyH' | 'KeyN' | 'KeyT' | 'KeyV' | 'Space',
|
|
1253
|
+
down: event.type === 'keydown',
|
|
1254
|
+
type: 'u90:key',
|
|
1255
|
+
})
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// The keys that scroll, blocked while this screen isn't the active one.
|
|
1260
|
+
if (event.type === 'keydown' && !isActive && SCROLL_KEYS.has(event.key)) {
|
|
1261
|
+
stopScrolling(event)
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/*
|
|
1266
|
+
* Any click on an annotated element selects it. Capture phase, so a link or button
|
|
1267
|
+
* wrapping the text can't swallow it first — inside the editor, clicking is for
|
|
1268
|
+
* selecting, not navigating.
|
|
1269
|
+
*
|
|
1270
|
+
* With navigation off, a click on *any* link or button is swallowed too, even
|
|
1271
|
+
* where nothing is annotated. That covers the two cases the marker alone misses:
|
|
1272
|
+
* a button whose marker is on its inner label, so the padding around the words
|
|
1273
|
+
* was a live link; and a card whose entire surface is a stretched anchor, which
|
|
1274
|
+
* carried no marker at all and could only ever navigate. If such a click lands
|
|
1275
|
+
* inside something annotated, that gets selected; if not, the click simply does
|
|
1276
|
+
* nothing, which is the honest outcome for a link with no editable content.
|
|
1277
|
+
*/
|
|
1278
|
+
const onClick = (event: MouseEvent) => {
|
|
1279
|
+
const from = event.target as HTMLElement | null
|
|
1280
|
+
if (navigationRef.current) {
|
|
1281
|
+
const link = from?.closest?.('a[href]') as HTMLAnchorElement | null
|
|
1282
|
+
const href =
|
|
1283
|
+
link && !link.hasAttribute('download') && (!link.target || link.target === '_self')
|
|
1284
|
+
? languageFrameNavigation(link.href, window.location.href, language)
|
|
1285
|
+
: null
|
|
1286
|
+
if (href && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
|
|
1287
|
+
endEditing()
|
|
1288
|
+
event.preventDefault()
|
|
1289
|
+
event.stopPropagation()
|
|
1290
|
+
const target = new URL(href)
|
|
1291
|
+
send({
|
|
1292
|
+
type: 'u90:navigate',
|
|
1293
|
+
path: target.pathname + target.search + target.hash,
|
|
1294
|
+
targetLocale: link?.hreflang || languageLocale,
|
|
1295
|
+
})
|
|
1296
|
+
return
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const target = from?.closest?.(`[${FIELD_ATTR}]`)
|
|
1301
|
+
|
|
1302
|
+
/*
|
|
1303
|
+
* A drag that left words highlighted inside the field being edited.
|
|
1304
|
+
*
|
|
1305
|
+
* The marker is on the prose, and the box a client sees around it is wider — a
|
|
1306
|
+
* container's padding, a section's vertical band. Dragging past the end of a line, which
|
|
1307
|
+
* is simply how you select to the end of a sentence, releases the pointer in that gutter.
|
|
1308
|
+
* Without this the release reads as a click on nothing, the field is deselected, and the
|
|
1309
|
+
* formatting toolbar disappears in the middle of the gesture that was about to use it.
|
|
1310
|
+
*/
|
|
1311
|
+
const isHighlighting = (): boolean => {
|
|
1312
|
+
if (!editing) return false
|
|
1313
|
+
const selection = document.getSelection()
|
|
1314
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false
|
|
1315
|
+
return editing.contains(selection.getRangeAt(0).commonAncestorContainer)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (!target) {
|
|
1319
|
+
// Words are highlighted in the field: this is the end of a selection, not a click off it.
|
|
1320
|
+
if (isHighlighting()) return
|
|
1321
|
+
|
|
1322
|
+
/*
|
|
1323
|
+
* Clicking off an element is the other half of clicking one.
|
|
1324
|
+
*
|
|
1325
|
+
* This used to return here and say nothing, which meant a selection could only ever be
|
|
1326
|
+
* replaced, never let go of: click a heading, click the paragraph gap beside it, and the
|
|
1327
|
+
* heading stayed outlined, stayed in the inspector, and stayed the thing ⌘L quoted.
|
|
1328
|
+
* Escape and V both cleared it, and neither is a gesture anybody arrives with — every
|
|
1329
|
+
* editor a client has used deselects when you click the background, so the absence read
|
|
1330
|
+
* as the canvas being stuck rather than as a shortcut they had not found.
|
|
1331
|
+
*
|
|
1332
|
+
* Before the branches below, deliberately: all three of them are "the press landed on
|
|
1333
|
+
* nothing editable", and the one that swallows a link is no less a deselect than the two
|
|
1334
|
+
* that let the click through. Sent even when nothing was selected — `setSelected` bails
|
|
1335
|
+
* on an unchanged value, and the dashboard's store compares before it publishes, so the
|
|
1336
|
+
* quiet case costs one postMessage and re-renders nothing.
|
|
1337
|
+
*/
|
|
1338
|
+
setSelected(null)
|
|
1339
|
+
send({ field: null, type: 'u90:select' })
|
|
1340
|
+
|
|
1341
|
+
if (navigationRef.current) return
|
|
1342
|
+
// `[href]` rather than `a`, so a bare anchor used as a scroll target is left
|
|
1343
|
+
// alone — it navigates nowhere and swallowing it would be pointless.
|
|
1344
|
+
if (!from?.closest?.('a[href], button')) return
|
|
1345
|
+
event.preventDefault()
|
|
1346
|
+
event.stopPropagation()
|
|
1347
|
+
return
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
event.preventDefault()
|
|
1351
|
+
event.stopPropagation()
|
|
1352
|
+
const selectedField = target.getAttribute(FIELD_ATTR)
|
|
1353
|
+
if (selectedField) {
|
|
1354
|
+
setSelected(selectedField)
|
|
1355
|
+
/*
|
|
1356
|
+
* The rendered words travel with the field, so the dashboard's ⌘L can quote what is
|
|
1357
|
+
* on the page rather than only naming the field. Additive and optional — an older
|
|
1358
|
+
* dashboard ignores a key it does not read.
|
|
1359
|
+
*/
|
|
1360
|
+
/*
|
|
1361
|
+
* Remembered so the echo of this very click cannot move the page.
|
|
1362
|
+
*
|
|
1363
|
+
* The dashboard stores the selection and sends `u90:select` straight back, and the
|
|
1364
|
+
* handler for it reveals the field. For anything shorter than the screen
|
|
1365
|
+
* `isSettledInView` absorbs that — but a field taller than the viewport is never
|
|
1366
|
+
* "in view" by that test, so a legal document scrolled to its own middle every time
|
|
1367
|
+
* the client clicked the top of it.
|
|
1368
|
+
*/
|
|
1369
|
+
selfSelected.current = selectedField
|
|
1370
|
+
send({
|
|
1371
|
+
field: selectedField,
|
|
1372
|
+
text: quotable(target as HTMLElement),
|
|
1373
|
+
type: 'u90:select',
|
|
1374
|
+
})
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/*
|
|
1378
|
+
* One click into words is a caret in those words.
|
|
1379
|
+
*
|
|
1380
|
+
* This used to need a double-click, with a single click only selecting the field and
|
|
1381
|
+
* lighting up the panel on the right. Which meant the obvious gesture — click the
|
|
1382
|
+
* heading, type — did nothing visible, and the way to edit text in place was
|
|
1383
|
+
* undiscoverable unless somebody told you. Every editor a client has used puts a caret
|
|
1384
|
+
* in text on one click, and the frame already looks like the page rather than like a
|
|
1385
|
+
* form, so it is the gesture they arrive with.
|
|
1386
|
+
*
|
|
1387
|
+
* Only text leaves. A marked image, or a container holding several marked children, has
|
|
1388
|
+
* nowhere to put a caret — those still select and are edited from the panel.
|
|
1389
|
+
*
|
|
1390
|
+
* **Not while navigation is on.** That mode exists so the client can walk their own
|
|
1391
|
+
* site, following links to reach the page they want, and the toolbar names it "click
|
|
1392
|
+
* one to open that page". A caret appearing in every heading passed on the way is
|
|
1393
|
+
* noise, and on an annotated link it would swallow the very click meant to follow it.
|
|
1394
|
+
* Double-click still edits in that mode, because a double-click is unambiguous.
|
|
1395
|
+
*/
|
|
1396
|
+
if (!navigationRef.current && isEditableLeaf(target as HTMLElement)) {
|
|
1397
|
+
// Resolved before the element is editable, applied after — see `caretTargetIn`, where
|
|
1398
|
+
// the measurements behind that order are written down.
|
|
1399
|
+
const caret = caretTargetIn(target as HTMLElement, event.clientX, event.clientY)
|
|
1400
|
+
beginEditing(target as HTMLElement)
|
|
1401
|
+
applyCaret(caret)
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/*
|
|
1406
|
+
* Double-click to edit the words where they are.
|
|
1407
|
+
*
|
|
1408
|
+
* Capture phase, like `onClick` and for the same reason: a link or button wrapping the
|
|
1409
|
+
* text would otherwise get the event first. The double-click has already selected the
|
|
1410
|
+
* word under the pointer, and making the element editable keeps that selection — so
|
|
1411
|
+
* typing replaces the word you aimed at, which is what a double-click means everywhere
|
|
1412
|
+
* else.
|
|
1413
|
+
*
|
|
1414
|
+
* Nothing happens on anything that isn't a text leaf: a marked image, or a container
|
|
1415
|
+
* holding several marked children. Those stay a single click and the panel on the right.
|
|
1416
|
+
*/
|
|
1417
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
1418
|
+
const from = event.target as HTMLElement | null
|
|
1419
|
+
const target = from?.closest?.(`[${FIELD_ATTR}]`) as HTMLElement | null
|
|
1420
|
+
if (!target || !target.getAttribute(FIELD_ATTR) || !isEditableLeaf(target)) return
|
|
1421
|
+
|
|
1422
|
+
/*
|
|
1423
|
+
* Already editing this element — which is now the common case, since the first click of
|
|
1424
|
+
* the double started it. Hand the event to the browser untouched: selecting the word
|
|
1425
|
+
* under the pointer is its default action inside a contenteditable, and that is exactly
|
|
1426
|
+
* what a double-click should do. Suppressing it here, as the branch below must, would
|
|
1427
|
+
* turn a double-click into an ordinary caret placement and lose the one thing the
|
|
1428
|
+
* gesture is for.
|
|
1429
|
+
*/
|
|
1430
|
+
if (editing === target) return
|
|
1431
|
+
|
|
1432
|
+
/*
|
|
1433
|
+
* Not editing yet, so this is the path that still matters: navigation is on, where a
|
|
1434
|
+
* single click deliberately does not start editing. A double-click is unambiguous enough
|
|
1435
|
+
* to mean "edit this" even while the client is browsing, so it is honoured — and the
|
|
1436
|
+
* default has to be suppressed, or the link under the words navigates away from the
|
|
1437
|
+
* caret that was just placed.
|
|
1438
|
+
*/
|
|
1439
|
+
event.preventDefault()
|
|
1440
|
+
event.stopPropagation()
|
|
1441
|
+
const caret = caretTargetIn(target, event.clientX, event.clientY)
|
|
1442
|
+
beginEditing(target)
|
|
1443
|
+
applyCaret(caret)
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/*
|
|
1447
|
+
* Debounced, so the dashboard's autosave sees a burst of typing as one edit rather than
|
|
1448
|
+
* a save per character — the same shape as the panel's own control, which is a React
|
|
1449
|
+
* input driving the identical change path.
|
|
1450
|
+
*/
|
|
1451
|
+
const EDIT_DEBOUNCE_MS = 150
|
|
1452
|
+
|
|
1453
|
+
const onInput = () => {
|
|
1454
|
+
const element = editing
|
|
1455
|
+
if (!element) return
|
|
1456
|
+
const field = element.getAttribute(FIELD_ATTR)
|
|
1457
|
+
if (!field) return
|
|
1458
|
+
|
|
1459
|
+
unsent = element.hasAttribute(RICH_ATTR)
|
|
1460
|
+
? { element, field, rich: true }
|
|
1461
|
+
: { field, rich: false, value: readEditedText(element) }
|
|
1462
|
+
window.clearTimeout(editTimer)
|
|
1463
|
+
editTimer = window.setTimeout(flushEdit, EDIT_DEBOUNCE_MS)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/*
|
|
1467
|
+
* ── Formatting from a toolbar that lives in another window ────────────────
|
|
1468
|
+
*
|
|
1469
|
+
* The rich text toolbar sits on the frame's border, drawn by the dashboard. The words it
|
|
1470
|
+
* formats are in here. Nothing in the dashboard can touch this selection — `execCommand`
|
|
1471
|
+
* runs in the document that owns the selection, and that document is this one — so the
|
|
1472
|
+
* press arrives as `u90:format` and is applied here.
|
|
1473
|
+
*
|
|
1474
|
+
* **`execCommand`, deprecated, and still correct.** This package ships no dependencies, so
|
|
1475
|
+
* Lexical is not available on this side of the bridge and must not become available. For a
|
|
1476
|
+
* `contentEditable` it is the only formatting engine there is. It also emits exactly the
|
|
1477
|
+
* tags `lexicalDom.ts` already inverts — `<b>`, `<i>`, `<h2>`, `<ul><li>`, `<a href>` —
|
|
1478
|
+
* which is why the serialiser accepts the browser's spellings alongside the reader's.
|
|
1479
|
+
*/
|
|
1480
|
+
let lastRange: null | Range = null
|
|
1481
|
+
|
|
1482
|
+
/*
|
|
1483
|
+
* The selection, remembered while it still exists.
|
|
1484
|
+
*
|
|
1485
|
+
* Clicking a button in the parent window blurs this frame, and a blurred document reports a
|
|
1486
|
+
* collapsed selection — so by the time the message arrives there is nothing to format, and
|
|
1487
|
+
* the button reads as broken while the field is plainly focused. Recording the range as it
|
|
1488
|
+
* moves is what makes the toolbar work at all.
|
|
1489
|
+
*
|
|
1490
|
+
* Only ranges inside the element being edited are kept. A selection elsewhere on the page
|
|
1491
|
+
* is not this field's, and restoring it would apply the client's formatting to whatever
|
|
1492
|
+
* they happened to have highlighted last.
|
|
1493
|
+
*/
|
|
1494
|
+
const rememberRange = () => {
|
|
1495
|
+
if (!editing || !editing.hasAttribute(RICH_ATTR)) return
|
|
1496
|
+
const selection = document.getSelection()
|
|
1497
|
+
if (!selection || selection.rangeCount === 0) return
|
|
1498
|
+
const range = selection.getRangeAt(0)
|
|
1499
|
+
if (!editing.contains(range.commonAncestorContainer)) return
|
|
1500
|
+
lastRange = range.cloneRange()
|
|
1501
|
+
reportCaret(range)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/*
|
|
1505
|
+
* ── Where the toolbar should stand ────────────────────────────────────────
|
|
1506
|
+
*
|
|
1507
|
+
* The toolbar is drawn by the dashboard and has no way to see this selection, so the frame
|
|
1508
|
+
* reports where it is. Sent in this document's own viewport coordinates: the dashboard
|
|
1509
|
+
* knows where the frame sits and at what zoom, and that is the only pair of facts needed to
|
|
1510
|
+
* put a floating control beside the caret.
|
|
1511
|
+
*
|
|
1512
|
+
* Collapsed or not — a caret with no selection still wants the toolbar near it, because the
|
|
1513
|
+
* next thing a client does is choose a heading for the line they are on.
|
|
1514
|
+
*/
|
|
1515
|
+
let caretFrame = 0
|
|
1516
|
+
const reportCaret = (range: Range): void => {
|
|
1517
|
+
window.cancelAnimationFrame(caretFrame)
|
|
1518
|
+
caretFrame = window.requestAnimationFrame(() => {
|
|
1519
|
+
const rect = range.getBoundingClientRect()
|
|
1520
|
+
/*
|
|
1521
|
+
* A collapsed range at the start of an empty block measures zero on every axis, and a
|
|
1522
|
+
* zero rect would park the toolbar in the frame's top-left corner. The element being
|
|
1523
|
+
* edited is the honest fallback — the toolbar lands on the field rather than nowhere.
|
|
1524
|
+
*/
|
|
1525
|
+
const box =
|
|
1526
|
+
rect.width || rect.height || rect.top || rect.left
|
|
1527
|
+
? rect
|
|
1528
|
+
: editing?.getBoundingClientRect()
|
|
1529
|
+
if (!box) return
|
|
1530
|
+
|
|
1531
|
+
/*
|
|
1532
|
+
* What kind of block the caret is in, so the toolbar can show it ticked.
|
|
1533
|
+
*
|
|
1534
|
+
* Read here rather than inferred out there: only this document can see the element the
|
|
1535
|
+
* selection sits in, and a toolbar that cannot say what the current style *is* leaves
|
|
1536
|
+
* the client guessing whether their last press did anything.
|
|
1537
|
+
*
|
|
1538
|
+
* A list item reports its list's kind, because that is the choice the client made.
|
|
1539
|
+
*/
|
|
1540
|
+
const from = range.commonAncestorContainer
|
|
1541
|
+
const start = (from.nodeType === 1 ? from : from.parentElement) as HTMLElement | null
|
|
1542
|
+
const node = start?.closest('h1,h2,h3,li,p')
|
|
1543
|
+
const tag = node?.tagName.toLowerCase()
|
|
1544
|
+
const block =
|
|
1545
|
+
tag === 'li'
|
|
1546
|
+
? node?.closest('ol')
|
|
1547
|
+
? 'number'
|
|
1548
|
+
: 'bullet'
|
|
1549
|
+
: tag === 'h1' || tag === 'h2' || tag === 'h3'
|
|
1550
|
+
? tag
|
|
1551
|
+
: 'paragraph'
|
|
1552
|
+
|
|
1553
|
+
send({
|
|
1554
|
+
block,
|
|
1555
|
+
height: box.height,
|
|
1556
|
+
left: box.left,
|
|
1557
|
+
top: box.top,
|
|
1558
|
+
type: 'u90:caret',
|
|
1559
|
+
width: box.width,
|
|
1560
|
+
})
|
|
1561
|
+
})
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** No caret, no toolbar. Sent on release so the dashboard can put it away. */
|
|
1565
|
+
const clearCaret = (): void => {
|
|
1566
|
+
window.cancelAnimationFrame(caretFrame)
|
|
1567
|
+
send({ height: 0, left: 0, top: 0, type: 'u90:caret', width: 0 })
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/** What each button asks the browser for. */
|
|
1571
|
+
const COMMANDS: Record<string, [string, string | undefined]> = {
|
|
1572
|
+
bold: ['bold', undefined],
|
|
1573
|
+
bullet: ['insertUnorderedList', undefined],
|
|
1574
|
+
h1: ['formatBlock', '<h1>'],
|
|
1575
|
+
h2: ['formatBlock', '<h2>'],
|
|
1576
|
+
h3: ['formatBlock', '<h3>'],
|
|
1577
|
+
italic: ['italic', undefined],
|
|
1578
|
+
number: ['insertOrderedList', undefined],
|
|
1579
|
+
paragraph: ['formatBlock', '<p>'],
|
|
1580
|
+
strike: ['strikeThrough', undefined],
|
|
1581
|
+
underline: ['underline', undefined],
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const applyFormat = (action: unknown, url?: string): void => {
|
|
1585
|
+
const element = editing
|
|
1586
|
+
if (!element || !element.hasAttribute(RICH_ATTR)) return
|
|
1587
|
+
if (typeof action !== 'string') return
|
|
1588
|
+
|
|
1589
|
+
/*
|
|
1590
|
+
* Focus first, then restore the range. The order matters: focusing collapses the
|
|
1591
|
+
* selection to wherever the browser thinks the caret is, so restoring first and focusing
|
|
1592
|
+
* second would throw the remembered range away.
|
|
1593
|
+
*/
|
|
1594
|
+
element.focus({ preventScroll: true })
|
|
1595
|
+
const selection = document.getSelection()
|
|
1596
|
+
if (lastRange && selection) {
|
|
1597
|
+
selection.removeAllRanges()
|
|
1598
|
+
selection.addRange(lastRange)
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (action === 'single') {
|
|
1602
|
+
if (!url || !/^[^{}\s]+$/.test(url) || url === 'link') return
|
|
1603
|
+
const chip = document.createElement('span')
|
|
1604
|
+
chip.setAttribute('data-single-key', url)
|
|
1605
|
+
chip.contentEditable = 'false'
|
|
1606
|
+
chip.textContent = `{${url}}`
|
|
1607
|
+
document.execCommand('insertHTML', false, chip.outerHTML)
|
|
1608
|
+
} else if (action === 'link') {
|
|
1609
|
+
// An empty url is the unlink button — the same control, in its other state.
|
|
1610
|
+
if (url) document.execCommand('createLink', false, url)
|
|
1611
|
+
else document.execCommand('unlink')
|
|
1612
|
+
} else {
|
|
1613
|
+
const command = COMMANDS[action]
|
|
1614
|
+
if (!command) return
|
|
1615
|
+
document.execCommand(command[0], false, command[1])
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/*
|
|
1619
|
+
* `execCommand` fires `input` in every browser that still implements it, but the flush is
|
|
1620
|
+
* scheduled explicitly rather than relying on that: a formatting press the client can see
|
|
1621
|
+
* and the dashboard never receives is the worst of the failure modes available here.
|
|
1622
|
+
*/
|
|
1623
|
+
rememberRange()
|
|
1624
|
+
onInput()
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Tabbing or clicking into another focusable thing. A press on plain page copy is
|
|
1628
|
+
// caught by `onPointerDown` instead — see the note there.
|
|
1629
|
+
const onFocusOut = (event: FocusEvent) => {
|
|
1630
|
+
if (event.target !== editing) return
|
|
1631
|
+
|
|
1632
|
+
/*
|
|
1633
|
+
* Leaving the document is not leaving the field.
|
|
1634
|
+
*
|
|
1635
|
+
* Clicking the formatting toolbar, switching applications, clicking the browser's own
|
|
1636
|
+
* chrome — all fire `focusout` here exactly as tabbing to the next field does. Ending
|
|
1637
|
+
* the edit on any of them is what made the toolbar inert: by the time a press on
|
|
1638
|
+
* "Heading 1" arrived, `editing` was null and the command had nothing to act on.
|
|
1639
|
+
*
|
|
1640
|
+
* `relatedTarget` is what separates them, and `document.hasFocus()` — which this used to
|
|
1641
|
+
* ask — is not. `focusout` fires *while* focus is leaving, and at that moment this
|
|
1642
|
+
* document can still report having it, so the guard passed and the edit ended anyway.
|
|
1643
|
+
* That is why leaving the window looked fixed and clicking the toolbar did not.
|
|
1644
|
+
*
|
|
1645
|
+
* Focus moving somewhere outside this document reports no `relatedTarget` at all, which
|
|
1646
|
+
* is precisely the case to hold on to: the client is still in the field, they are just
|
|
1647
|
+
* reaching for a control that lives in another window.
|
|
1648
|
+
*/
|
|
1649
|
+
const leaving = editing
|
|
1650
|
+
const next = event.relatedTarget as Node | null
|
|
1651
|
+
if (!next || !leaving) return
|
|
1652
|
+
if (leaving.contains(next)) return
|
|
1653
|
+
|
|
1654
|
+
endEditing()
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/*
|
|
1658
|
+
* Coming back to the window: say where the caret is again, so the toolbar returns to it.
|
|
1659
|
+
* The selection survived the trip — only the dashboard's knowledge of it did not.
|
|
1660
|
+
*/
|
|
1661
|
+
const onWindowFocus = () => {
|
|
1662
|
+
if (editing) rememberRange()
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/*
|
|
1666
|
+
* ── What the client has highlighted ───────────────────────────────────────
|
|
1667
|
+
*
|
|
1668
|
+
* So the dashboard's ⌘L can attach a *run of words* to the agent, not just the field they
|
|
1669
|
+
* sit in. Asking for "the second sentence" to change is the common case and an
|
|
1670
|
+
* element-level selection cannot say it.
|
|
1671
|
+
*
|
|
1672
|
+
* **On pointer-up and key-up, not on `selectionchange`.** A single click into words puts a
|
|
1673
|
+
* caret in them, so from that moment the selection belongs to the client's typing — and
|
|
1674
|
+
* `selectionchange` fires on every keystroke and every caret nudge. These two fire once
|
|
1675
|
+
* per gesture: a drag that ends, or a shift-arrow that lands. Typing collapses the
|
|
1676
|
+
* selection, which reports `null` once and then repeats itself into the `lastReported`
|
|
1677
|
+
* guard below rather than posting per character.
|
|
1678
|
+
*
|
|
1679
|
+
* Reported *while* editing rather than suppressed, deliberately: a run selected inside a
|
|
1680
|
+
* contentEditable element is exactly the case this exists for — the client has clicked
|
|
1681
|
+
* into their own heading and dragged across the half of it they want rewritten.
|
|
1682
|
+
*/
|
|
1683
|
+
let lastReported: null | string = null
|
|
1684
|
+
|
|
1685
|
+
const reportHighlight = () => {
|
|
1686
|
+
const selection = window.getSelection()
|
|
1687
|
+
const words = selection && !selection.isCollapsed ? collapseSpace(selection.toString()) : ''
|
|
1688
|
+
const text = words || null
|
|
1689
|
+
|
|
1690
|
+
// Only when it changes. See the note above about keystrokes.
|
|
1691
|
+
if (text === lastReported) return
|
|
1692
|
+
lastReported = text
|
|
1693
|
+
|
|
1694
|
+
/*
|
|
1695
|
+
* Which annotated element the run started in, so the dashboard can name the field as
|
|
1696
|
+
* well as quote the words. `anchorNode` is usually a text node, hence the climb to an
|
|
1697
|
+
* element before `closest`. Null is a real answer: page copy that carries no marker is
|
|
1698
|
+
* still worth quoting, it just cannot be attributed to a field.
|
|
1699
|
+
*/
|
|
1700
|
+
const anchor = selection?.anchorNode ?? null
|
|
1701
|
+
const from =
|
|
1702
|
+
anchor instanceof HTMLElement
|
|
1703
|
+
? anchor
|
|
1704
|
+
: ((anchor?.parentElement ?? null) as HTMLElement | null)
|
|
1705
|
+
const host = text ? from?.closest?.(`[${FIELD_ATTR}]`) : null
|
|
1706
|
+
|
|
1707
|
+
send({
|
|
1708
|
+
field: host?.getAttribute(FIELD_ATTR) ?? null,
|
|
1709
|
+
text,
|
|
1710
|
+
type: 'u90:highlight',
|
|
1711
|
+
})
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
window.addEventListener('pointerdown', onPointerDown)
|
|
1715
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
1716
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
1717
|
+
window.addEventListener('pointercancel', onPointerUp)
|
|
1718
|
+
window.addEventListener('auxclick', onAuxClick)
|
|
1719
|
+
window.addEventListener('keydown', onKey, { capture: true })
|
|
1720
|
+
window.addEventListener('keyup', onKey, { capture: true })
|
|
1721
|
+
document.addEventListener('click', onClick, true)
|
|
1722
|
+
document.addEventListener('dblclick', onDoubleClick, true)
|
|
1723
|
+
document.addEventListener('input', onInput)
|
|
1724
|
+
document.addEventListener('selectionchange', rememberRange)
|
|
1725
|
+
window.addEventListener('focus', onWindowFocus)
|
|
1726
|
+
document.addEventListener('focusout', onFocusOut)
|
|
1727
|
+
// After the gesture that made the selection, never during it — see `reportHighlight`.
|
|
1728
|
+
window.addEventListener('pointerup', reportHighlight)
|
|
1729
|
+
window.addEventListener('keyup', reportHighlight)
|
|
1730
|
+
|
|
1731
|
+
return () => {
|
|
1732
|
+
languageHints.dispose()
|
|
1733
|
+
window.removeEventListener('message', onMessage)
|
|
1734
|
+
window.removeEventListener('wheel', onWheel, { capture: true })
|
|
1735
|
+
window.removeEventListener('scroll', reportScroll)
|
|
1736
|
+
if (scrollPending) cancelAnimationFrame(scrollPending)
|
|
1737
|
+
window.removeEventListener('pointerdown', onPointerDown)
|
|
1738
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
1739
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
1740
|
+
window.removeEventListener('pointercancel', onPointerUp)
|
|
1741
|
+
window.removeEventListener('auxclick', onAuxClick)
|
|
1742
|
+
window.removeEventListener('keydown', onKey, { capture: true })
|
|
1743
|
+
window.removeEventListener('keyup', onKey, { capture: true })
|
|
1744
|
+
document.removeEventListener('click', onClick, true)
|
|
1745
|
+
document.removeEventListener('dblclick', onDoubleClick, true)
|
|
1746
|
+
document.removeEventListener('input', onInput)
|
|
1747
|
+
document.removeEventListener('selectionchange', rememberRange)
|
|
1748
|
+
window.removeEventListener('focus', onWindowFocus)
|
|
1749
|
+
document.removeEventListener('focusout', onFocusOut)
|
|
1750
|
+
window.removeEventListener('pointerup', reportHighlight)
|
|
1751
|
+
window.removeEventListener('keyup', reportHighlight)
|
|
1752
|
+
// Send what was typed before tearing down, and leave no element contentEditable
|
|
1753
|
+
// behind — this page may carry on being a page.
|
|
1754
|
+
endEditing()
|
|
1755
|
+
window.removeEventListener('popstate', reportPath)
|
|
1756
|
+
// Put history back the way we found it, so a navigation after the editor
|
|
1757
|
+
// closes doesn't call into a listener that is no longer wanted.
|
|
1758
|
+
window.history.pushState = pushState
|
|
1759
|
+
window.history.replaceState = replaceState
|
|
1760
|
+
heights.disconnect()
|
|
1761
|
+
cancelAnimationFrame(raf)
|
|
1762
|
+
// Hand the stylesheets back unpinned — this page may carry on being a page.
|
|
1763
|
+
releaseViewport()
|
|
1764
|
+
}
|
|
1765
|
+
// `blocks` and `collections` are module-level constants in every real site;
|
|
1766
|
+
// re-subscribing on them would tear the listeners down on each render for no benefit.
|
|
1767
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1768
|
+
}, [embedded, platformOrigin, send])
|
|
1769
|
+
|
|
1770
|
+
return { content, embedded, entries, navigation, selected }
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Editing affordances, rendered only inside the dashboard frame.
|
|
1775
|
+
*
|
|
1776
|
+
* A stylesheet rather than per-element classes, so the page components stay free of
|
|
1777
|
+
* editor concerns — they only have to carry the field attribute.
|
|
1778
|
+
*/
|
|
1779
|
+
export const EditingStyles = ({
|
|
1780
|
+
navigation = false,
|
|
1781
|
+
selected,
|
|
1782
|
+
}: {
|
|
1783
|
+
/**
|
|
1784
|
+
* Whether links in this frame navigate. Optional and defaulting to off, so a site
|
|
1785
|
+
* on an older kit that renders this without the prop still gets the editing
|
|
1786
|
+
* affordances — the safe reading, since an embedded frame is being edited.
|
|
1787
|
+
*/
|
|
1788
|
+
navigation?: boolean
|
|
1789
|
+
selected: null | string
|
|
1790
|
+
}) => {
|
|
1791
|
+
// The values are generated paths (digits, letters, dots), so escaping quotes is
|
|
1792
|
+
// sufficient and avoids depending on CSS.escape.
|
|
1793
|
+
const escaped = selected?.replace(/"/g, '\\"')
|
|
1794
|
+
const sectionKey =
|
|
1795
|
+
selected && !selected.startsWith('@') ? /^(\d+)(?:\.|$)/.exec(selected)?.[1] : undefined
|
|
1796
|
+
|
|
1797
|
+
return (
|
|
1798
|
+
<style
|
|
1799
|
+
data-u90-bridge=""
|
|
1800
|
+
dangerouslySetInnerHTML={{
|
|
1801
|
+
__html: `
|
|
1802
|
+
[${FIELD_ATTR}] { cursor: pointer; }
|
|
1803
|
+
[${FIELD_ATTR}]:hover {
|
|
1804
|
+
outline: 1px dashed #FF5D2E;
|
|
1805
|
+
outline-offset: 4px;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/*
|
|
1809
|
+
* A picture's affordance is drawn INSIDE it.
|
|
1810
|
+
*
|
|
1811
|
+
* Every rule above offsets its outline outwards, which is right for words and wrong
|
|
1812
|
+
* for a photograph: an image sits in a rounded, overflow-hidden frame and fills it
|
|
1813
|
+
* exactly, so an outline painted outside the image lands on the clip edge and is
|
|
1814
|
+
* never drawn at all. Selecting one of pbir's service photos looked like selecting
|
|
1815
|
+
* nothing. A negative offset paints within the element's own box, where no ancestor's
|
|
1816
|
+
* overflow can reach it.
|
|
1817
|
+
*
|
|
1818
|
+
* No backticks anywhere in this comment, like the one further down: it lives inside a
|
|
1819
|
+
* template literal, so one would end the stylesheet mid-sentence.
|
|
1820
|
+
*
|
|
1821
|
+
* The corners do not follow the frame's radius, because the radius is on the wrapper
|
|
1822
|
+
* and this outline is on the image. A square inside a rounded frame is a visible
|
|
1823
|
+
* selection box; a clipped one is not.
|
|
1824
|
+
*/
|
|
1825
|
+
${MEDIA_SELECTOR} { cursor: pointer; }
|
|
1826
|
+
${MEDIA_SELECTOR}:hover {
|
|
1827
|
+
outline: 2px dashed #FF5D2E;
|
|
1828
|
+
outline-offset: -2px;
|
|
1829
|
+
}
|
|
1830
|
+
[${SECTION_ATTR}]:hover {
|
|
1831
|
+
outline: 2px dashed #FF5D2E;
|
|
1832
|
+
outline-offset: 8px;
|
|
1833
|
+
}
|
|
1834
|
+
${
|
|
1835
|
+
sectionKey
|
|
1836
|
+
? `[${SECTION_ATTR}="${sectionKey}"] {
|
|
1837
|
+
outline: 3px solid #FF5D2E !important;
|
|
1838
|
+
outline-offset: 8px;
|
|
1839
|
+
}`
|
|
1840
|
+
: ''
|
|
1841
|
+
}
|
|
1842
|
+
${
|
|
1843
|
+
navigation
|
|
1844
|
+
? /*
|
|
1845
|
+
* Navigation on: links are links again, so say so with the cursor and
|
|
1846
|
+
* draw nothing. Anything annotated keeps its own affordance above.
|
|
1847
|
+
*/
|
|
1848
|
+
`a[href]:not([${FIELD_ATTR}]), button:not([${FIELD_ATTR}]) { cursor: pointer; }`
|
|
1849
|
+
: /*
|
|
1850
|
+
* Navigation off: outline the whole control rather than the words
|
|
1851
|
+
* inside it. A button's marker sits on its label, so without this the
|
|
1852
|
+
* hover outline hugs the text and the padding around it reads as dead
|
|
1853
|
+
* space — when in fact the entire control is what gets selected.
|
|
1854
|
+
*
|
|
1855
|
+
* `:not(:has(...))` keeps the outline on the outermost thing: a link
|
|
1856
|
+
* containing an annotated span is drawn once, around the link, rather
|
|
1857
|
+
* than twice in concentric rectangles.
|
|
1858
|
+
*/
|
|
1859
|
+
`a[href], button { cursor: pointer; }
|
|
1860
|
+
a[href]:not(:has([${FIELD_ATTR}])):hover,
|
|
1861
|
+
button:not(:has([${FIELD_ATTR}])):hover {
|
|
1862
|
+
outline: 1px dashed #FF5D2E;
|
|
1863
|
+
outline-offset: 3px;
|
|
1864
|
+
}
|
|
1865
|
+
a[href]:has([${FIELD_ATTR}]):hover {
|
|
1866
|
+
outline: 1px dashed #FF5D2E;
|
|
1867
|
+
outline-offset: 3px;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/*
|
|
1871
|
+
* An I-beam over anything one click will put a caret in.
|
|
1872
|
+
*
|
|
1873
|
+
* This selector is isEditableLeaf written in CSS - marked, and EITHER
|
|
1874
|
+
* carrying the rich text marker OR (containing no other marked element and
|
|
1875
|
+
* containing none of the non-text tags) - so the two cannot disagree about
|
|
1876
|
+
* what is typable. A rich field is accepted by that first branch precisely
|
|
1877
|
+
* because a real document contains the very things the second branch rules
|
|
1878
|
+
* out, an <a> and a <ul> among them. It sits inside the navigation-off
|
|
1879
|
+
* branch, and after the link and button rule above, because a marked button
|
|
1880
|
+
* label is both a control and a text leaf: with navigation off it is text
|
|
1881
|
+
* and takes a caret, with navigation on it is a link.
|
|
1882
|
+
*
|
|
1883
|
+
* The one thing CSS cannot check is that the element has any text at all. An
|
|
1884
|
+
* empty marked element is a field the client has not filled in, so it offers
|
|
1885
|
+
* an I-beam and the click does nothing - the least interesting way for the
|
|
1886
|
+
* two to fall out of step, and the alternative is a class every site
|
|
1887
|
+
* component would have to carry.
|
|
1888
|
+
*/
|
|
1889
|
+
${EDITABLE_LEAF_SELECTOR} {
|
|
1890
|
+
cursor: text;
|
|
1891
|
+
}`
|
|
1892
|
+
}
|
|
1893
|
+
${
|
|
1894
|
+
escaped
|
|
1895
|
+
? `[${FIELD_ATTR}="${escaped}"] {
|
|
1896
|
+
outline: 2px solid #FF5D2E !important;
|
|
1897
|
+
outline-offset: 5px;
|
|
1898
|
+
}
|
|
1899
|
+
/*
|
|
1900
|
+
* The selected picture, drawn inside itself for the reason its hover rule is.
|
|
1901
|
+
* More specific than the rule above - an attribute plus a type - so it takes
|
|
1902
|
+
* the offset without an !important of its own.
|
|
1903
|
+
*/
|
|
1904
|
+
[${FIELD_ATTR}="${escaped}"]:is(${MEDIA}) {
|
|
1905
|
+
outline: 3px solid #FF5D2E !important;
|
|
1906
|
+
outline-offset: -3px;
|
|
1907
|
+
}`
|
|
1908
|
+
: ''
|
|
1909
|
+
}
|
|
1910
|
+
/*
|
|
1911
|
+
* The field the caret is in. Solid like a selection, because it is one, but with
|
|
1912
|
+
* a text cursor and no focus ring — the browser's own would sit inside the
|
|
1913
|
+
* outline and read as two borders around one word.
|
|
1914
|
+
*
|
|
1915
|
+
* Keyed off the attribute the bridge sets rather than off the selected prop, so
|
|
1916
|
+
* it appears the instant the element becomes editable rather than a round trip
|
|
1917
|
+
* later, and cannot disagree with what is actually contentEditable.
|
|
1918
|
+
*/
|
|
1919
|
+
[${FIELD_ATTR}][data-u90-editing] {
|
|
1920
|
+
cursor: text;
|
|
1921
|
+
outline: 2px solid #FF5D2E !important;
|
|
1922
|
+
outline-offset: 5px;
|
|
1923
|
+
}
|
|
1924
|
+
[${FIELD_ATTR}][data-u90-editing]:focus,
|
|
1925
|
+
[${FIELD_ATTR}][data-u90-editing]:focus-visible {
|
|
1926
|
+
outline: 2px solid #FF5D2E !important;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/*
|
|
1930
|
+
* A page with no sections, said out loud.
|
|
1931
|
+
*
|
|
1932
|
+
* A freshly created page renders its header and footer around an empty main
|
|
1933
|
+
* element, which in a Canvas frame is indistinguishable from a broken render --
|
|
1934
|
+
* the editor sees the chrome and a void and reasonably assumes the page failed.
|
|
1935
|
+
* This names the state instead.
|
|
1936
|
+
*
|
|
1937
|
+
* CSS rather than a component, and that is the point. Every generated site
|
|
1938
|
+
* already renders EditingStyles when embedded and already emits a main element,
|
|
1939
|
+
* so a stylesheet reaches every site that exists today on a kit bump alone: no
|
|
1940
|
+
* regenerated HomeFrame, no new prop, no call site to add. Same reasoning as the
|
|
1941
|
+
* navigation prop defaulting to off above -- the kit has to do something useful
|
|
1942
|
+
* for sites written before it knew how to ask.
|
|
1943
|
+
*
|
|
1944
|
+
* Only inside Canvas, because this whole component renders only when embedded.
|
|
1945
|
+
* A visitor on the live site sees an empty page, not our scaffolding.
|
|
1946
|
+
*
|
|
1947
|
+
* The :not(:has(*)) test rather than :empty, which a single newline of whitespace
|
|
1948
|
+
* between tags defeats -- and whether there is one is a formatting accident of
|
|
1949
|
+
* whatever generated the page.
|
|
1950
|
+
*
|
|
1951
|
+
* The copy does not offer to add anything. Sections and layout are deliberately
|
|
1952
|
+
* not a client's to change: see the note in the dashboard's AgentPanel, which
|
|
1953
|
+
* exists because promising exactly that was the first thing a client tried and
|
|
1954
|
+
* the first thing they were refused. So this reports the state and stops.
|
|
1955
|
+
*
|
|
1956
|
+
* No backticks anywhere in here. This comment lives inside a template literal,
|
|
1957
|
+
* and one would end the stylesheet mid-sentence.
|
|
1958
|
+
*/
|
|
1959
|
+
main:not(:has(*)) {
|
|
1960
|
+
min-height: 50vh;
|
|
1961
|
+
display: grid;
|
|
1962
|
+
place-items: center;
|
|
1963
|
+
padding: 48px 24px;
|
|
1964
|
+
box-sizing: border-box;
|
|
1965
|
+
}
|
|
1966
|
+
main:not(:has(*))::after {
|
|
1967
|
+
/*
|
|
1968
|
+
* Says where the control is rather than pretending to be one. The page is the
|
|
1969
|
+
* client's site in an iframe; it has no access to the dashboard's panel, and a
|
|
1970
|
+
* button here that could not add anything would be worse than a sentence.
|
|
1971
|
+
*/
|
|
1972
|
+
content: 'This page has no sections yet. Add one from the Page panel.';
|
|
1973
|
+
display: block;
|
|
1974
|
+
max-width: 32ch;
|
|
1975
|
+
padding: 28px 32px;
|
|
1976
|
+
border: 1px dashed #FF5D2E;
|
|
1977
|
+
/* Not the site's own type ramp: this is our chrome speaking, not the page. */
|
|
1978
|
+
font: 500 14px/1.5 ui-sans-serif, system-ui, sans-serif;
|
|
1979
|
+
letter-spacing: 0.01em;
|
|
1980
|
+
text-align: center;
|
|
1981
|
+
color: #FF5D2E;
|
|
1982
|
+
}
|
|
1983
|
+
`,
|
|
1984
|
+
}}
|
|
1985
|
+
/>
|
|
1986
|
+
)
|
|
1987
|
+
}
|