@ccheever/exact-renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +118 -0
- package/src/__tests__/adapter-window-state.test.tsx +190 -0
- package/src/__tests__/attrs.test.ts +157 -0
- package/src/__tests__/classname.test.ts +332 -0
- package/src/__tests__/color.test.ts +169 -0
- package/src/__tests__/dom-mirror.test.ts +682 -0
- package/src/__tests__/dom-shim.test.ts +274 -0
- package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
- package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
- package/src/__tests__/host-config.test.ts +51 -0
- package/src/__tests__/host-ops.test.ts +2234 -0
- package/src/__tests__/image-source.test.ts +135 -0
- package/src/__tests__/liquid-glass.test.ts +72 -0
- package/src/__tests__/multi-root.test.ts +118 -0
- package/src/__tests__/native-view-events.test.ts +102 -0
- package/src/__tests__/nodes.test.ts +399 -0
- package/src/__tests__/normalize.test.ts +576 -0
- package/src/__tests__/paragraph-lowering.test.tsx +144 -0
- package/src/__tests__/props.test.ts +518 -0
- package/src/__tests__/protocol-encoder.test.ts +732 -0
- package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
- package/src/__tests__/reconciler.test.tsx +241 -0
- package/src/__tests__/svelte-adapter.test.ts +166 -0
- package/src/__tests__/svg-source.test.ts +71 -0
- package/src/__tests__/tags.test.ts +354 -0
- package/src/__tests__/toggle.test.ts +441 -0
- package/src/__tests__/transitions.test.ts +106 -0
- package/src/__tests__/web-primitives.test.tsx +454 -0
- package/src/__tests__/window-hooks.test.tsx +447 -0
- package/src/adapter-contract.ts +68 -0
- package/src/attrs.ts +596 -0
- package/src/classname-contract.ts +87 -0
- package/src/classname-resolve.ts +553 -0
- package/src/classname-runtime.ts +29 -0
- package/src/components.ts +214 -0
- package/src/css-variable-context.ts +83 -0
- package/src/dom-hydration.ts +160 -0
- package/src/dom-mirror.ts +1459 -0
- package/src/dom-shim.ts +1736 -0
- package/src/group-context.ts +69 -0
- package/src/host-config.ts +431 -0
- package/src/host-ops.ts +3167 -0
- package/src/image-source.native.ts +703 -0
- package/src/image-source.ts +554 -0
- package/src/index.ts +278 -0
- package/src/inspector-runtime.ts +244 -0
- package/src/inspector.ts +3570 -0
- package/src/jsx-augmentations.ts +54 -0
- package/src/keyboard-avoidance.ts +217 -0
- package/src/native-primitives.ts +43 -0
- package/src/native-view-events.ts +322 -0
- package/src/native-view.ts +60 -0
- package/src/nodes/index.ts +41 -0
- package/src/nodes/node.ts +531 -0
- package/src/peer-context.ts +100 -0
- package/src/primitives.native.ts +8 -0
- package/src/primitives.ts +8 -0
- package/src/props/index.ts +14 -0
- package/src/props/normalize.ts +816 -0
- package/src/protocol/encoder.ts +940 -0
- package/src/protocol/index.ts +33 -0
- package/src/reconciler.ts +581 -0
- package/src/runtime.ts +11 -0
- package/src/safe-area.ts +543 -0
- package/src/solid.ts +490 -0
- package/src/style/color.js +1 -0
- package/src/style/color.ts +15 -0
- package/src/style/index.js +1 -0
- package/src/style/index.ts +22 -0
- package/src/style/normalize.js +1 -0
- package/src/style/normalize.ts +1426 -0
- package/src/svelte.ts +349 -0
- package/src/svg-source.ts +222 -0
- package/src/tags/index.ts +21 -0
- package/src/tags/tag-map.ts +289 -0
- package/src/text/paragraph-lowering.ts +310 -0
- package/src/types.ts +1175 -0
- package/src/vue.ts +535 -0
- package/src/web-host.ts +19 -0
- package/src/web-primitives.ts +1654 -0
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
// @system @ref LLP 0157 — DOM mirror: a web host for host-ops adapters.
|
|
2
|
+
//
|
|
3
|
+
// Framework adapters built on the shared host-ops layer (Solid, Vue,
|
|
4
|
+
// Svelte, Contract) emit binary protocol batches that only a platform host
|
|
5
|
+
// consumes (`exact.dispatch`). On a plain browser tab no host exists, so
|
|
6
|
+
// those surfaces render nothing — only the React-DOM path has pixels.
|
|
7
|
+
//
|
|
8
|
+
// The mirror closes that gap for development surfaces: after every commit
|
|
9
|
+
// it reconciles the JS-side host-ops node tree (ElementNode/TextNode with
|
|
10
|
+
// `originalProps`) into real DOM under the app container, mapping RN-style
|
|
11
|
+
// styles to CSS (browser flexbox does the layout) and wiring DOM events
|
|
12
|
+
// back into the adapters' handler props — including hover, which the
|
|
13
|
+
// protocol path cannot deliver today.
|
|
14
|
+
//
|
|
15
|
+
// Scope: a development-grade mirror for labs and demos on the plain-web
|
|
16
|
+
// path. It deliberately skips the binary protocol (no decode); the node
|
|
17
|
+
// tree is already in process. When a real host is present
|
|
18
|
+
// (`exact.dispatch` is a function), installation is a no-op so the
|
|
19
|
+
// platform keeps ownership of pixels.
|
|
20
|
+
|
|
21
|
+
import { colorToRgba, parseColor } from '@exact/core/style/color';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
isSafeHostDomAttr,
|
|
25
|
+
lowerHostAttrs,
|
|
26
|
+
type DomTagName,
|
|
27
|
+
type HostSemanticAttrs,
|
|
28
|
+
} from './attrs.js';
|
|
29
|
+
import {
|
|
30
|
+
getHostChannelStyleApplier,
|
|
31
|
+
setHostChannelStyleApplier,
|
|
32
|
+
type HostChannelStyleApplier,
|
|
33
|
+
} from './host-ops.js';
|
|
34
|
+
import { getRuntimeImageColorScheme } from './image-source.js';
|
|
35
|
+
import type { ElementNode, RootNode, TextNode } from './nodes/node.js';
|
|
36
|
+
import { NodeKind } from './nodes/node.js';
|
|
37
|
+
|
|
38
|
+
export type DomMirrorHostChild = ElementNode | TextNode;
|
|
39
|
+
type HostChild = DomMirrorHostChild;
|
|
40
|
+
|
|
41
|
+
interface DomMirrorRegistry {
|
|
42
|
+
sync(root: RootNode): void;
|
|
43
|
+
remove(rootId: number): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DomIdentityRegistry {
|
|
47
|
+
elementForViewId(viewId: number): HTMLElement | null;
|
|
48
|
+
viewIdForTarget(target: EventTarget | null): number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
declare global {
|
|
52
|
+
// eslint-disable-next-line no-var
|
|
53
|
+
var __exactDomMirror: DomMirrorRegistry | undefined;
|
|
54
|
+
// eslint-disable-next-line no-var
|
|
55
|
+
var __exactDomIdentity: DomIdentityRegistry | undefined;
|
|
56
|
+
// eslint-disable-next-line no-var
|
|
57
|
+
var __exactDomHydrationAdopterFactory:
|
|
58
|
+
| ((options: DomHydrationAdopterFactoryOptions) => DomHydrationAdopter | null)
|
|
59
|
+
| undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DomMirrorOptions {
|
|
63
|
+
/** Container for root 0 (default: #exact-root, else document.body). */
|
|
64
|
+
container?: HTMLElement;
|
|
65
|
+
/** Install even when a protocol host (exact.dispatch) is present. */
|
|
66
|
+
force?: boolean;
|
|
67
|
+
/** Optional DOM adoption data for a pre-rendered Contract web root. */
|
|
68
|
+
hydrate?: DomHydrationOptions;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DomMirrorHandle {
|
|
72
|
+
dispose(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DomHydrationManifestLike {
|
|
76
|
+
version?: number;
|
|
77
|
+
rootId?: number;
|
|
78
|
+
nodes?: Array<{
|
|
79
|
+
nodeId?: string;
|
|
80
|
+
domPath?: number[];
|
|
81
|
+
kind?: string;
|
|
82
|
+
tag?: string;
|
|
83
|
+
textGuard?: string;
|
|
84
|
+
}>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DomHydrationResult {
|
|
88
|
+
rootId: number;
|
|
89
|
+
adoptedNodes: number;
|
|
90
|
+
recreatedNodes: number;
|
|
91
|
+
mismatches: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DomHydrationOptions {
|
|
95
|
+
rootId: number;
|
|
96
|
+
manifest: DomHydrationManifestLike;
|
|
97
|
+
onResult?: (result: DomHydrationResult) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DomHydrationAdopterFactoryOptions {
|
|
101
|
+
hydrate: DomHydrationOptions;
|
|
102
|
+
markMirrored(node: Node): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface DomHydrationAdopter {
|
|
106
|
+
adoptRootContainer(
|
|
107
|
+
rootId: number,
|
|
108
|
+
base: HTMLElement,
|
|
109
|
+
rootAttribute: string,
|
|
110
|
+
): HTMLElement | null;
|
|
111
|
+
adoptDom(
|
|
112
|
+
child: DomMirrorHostChild,
|
|
113
|
+
rootId: number,
|
|
114
|
+
rootContainer: HTMLElement,
|
|
115
|
+
desiredTag?: DomTagName,
|
|
116
|
+
): Node | null;
|
|
117
|
+
report(rootId: number): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ContractWebHostOptions extends DomMirrorOptions {}
|
|
121
|
+
|
|
122
|
+
export interface ContractWebHostHandle extends DomMirrorHandle {}
|
|
123
|
+
|
|
124
|
+
interface DomHostRuntimeOptions {
|
|
125
|
+
exposeDevIdentity: boolean;
|
|
126
|
+
rootAttribute: 'data-exact-mirror-root' | 'data-exact-contract-host-root';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function asDomNode(target: EventTarget | null): Node | null {
|
|
130
|
+
if (
|
|
131
|
+
target &&
|
|
132
|
+
typeof target === 'object' &&
|
|
133
|
+
'parentNode' in target &&
|
|
134
|
+
'nodeType' in target
|
|
135
|
+
) {
|
|
136
|
+
return target as Node;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasProtocolHost(): boolean {
|
|
142
|
+
const runtime = globalThis as typeof globalThis & {
|
|
143
|
+
exact?: { dispatch?: unknown };
|
|
144
|
+
};
|
|
145
|
+
return typeof runtime.exact?.dispatch === 'function';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Style conversion: RN-ish style objects -> CSS
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const UNITLESS = new Set([
|
|
153
|
+
'flex',
|
|
154
|
+
'flexGrow',
|
|
155
|
+
'flexShrink',
|
|
156
|
+
'opacity',
|
|
157
|
+
'zIndex',
|
|
158
|
+
'fontWeight',
|
|
159
|
+
'aspectRatio',
|
|
160
|
+
'order',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const TRANSITION_PROPERTY_CSS: Record<string, string> = {
|
|
164
|
+
opacity: 'opacity',
|
|
165
|
+
transform: 'transform',
|
|
166
|
+
backgroundColor: 'background-color',
|
|
167
|
+
borderRadius: 'border-radius',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function cssEasing(easing: unknown): string {
|
|
171
|
+
if (Array.isArray(easing) && easing.length === 4) {
|
|
172
|
+
return `cubic-bezier(${easing.join(', ')})`;
|
|
173
|
+
}
|
|
174
|
+
switch (easing) {
|
|
175
|
+
case 'easeIn':
|
|
176
|
+
return 'ease-in';
|
|
177
|
+
case 'easeOut':
|
|
178
|
+
return 'ease-out';
|
|
179
|
+
case 'easeInOut':
|
|
180
|
+
return 'ease-in-out';
|
|
181
|
+
case 'linear':
|
|
182
|
+
default:
|
|
183
|
+
return 'linear';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Shared with web-primitives.tsx so the React-DOM path and the mirror agree
|
|
188
|
+
// on how Exact transition maps / transform arrays become CSS.
|
|
189
|
+
export function cssTransition(value: unknown): string | null {
|
|
190
|
+
if (typeof value === 'string') {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
if (!value || typeof value !== 'object') {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const parts: string[] = [];
|
|
197
|
+
for (const [property, config] of Object.entries(value as Record<string, unknown>)) {
|
|
198
|
+
const cssProperty = TRANSITION_PROPERTY_CSS[property];
|
|
199
|
+
if (!cssProperty || !config || typeof config !== 'object') {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const transition = config as { type?: string; duration?: number; easing?: unknown };
|
|
203
|
+
if (transition.type === 'none') {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (transition.type === 'spring') {
|
|
207
|
+
// Springs approximate to a fast ease-out curve on the mirror.
|
|
208
|
+
parts.push(`${cssProperty} ${transition.duration ?? 300}ms cubic-bezier(0.22, 1, 0.36, 1)`);
|
|
209
|
+
} else {
|
|
210
|
+
parts.push(`${cssProperty} ${transition.duration ?? 200}ms ${cssEasing(transition.easing)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return parts.length > 0 ? parts.join(', ') : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function cssTransform(value: unknown): string | null {
|
|
217
|
+
if (typeof value === 'string') {
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
if (!Array.isArray(value)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const parts: string[] = [];
|
|
224
|
+
for (const entry of value) {
|
|
225
|
+
if (!entry || typeof entry !== 'object') {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
for (const [op, amount] of Object.entries(entry as Record<string, unknown>)) {
|
|
229
|
+
if (op === 'translateX' || op === 'translateY') {
|
|
230
|
+
parts.push(`${op}(${typeof amount === 'number' ? `${amount}px` : String(amount)})`);
|
|
231
|
+
} else if (op === 'scale' || op === 'scaleX' || op === 'scaleY') {
|
|
232
|
+
parts.push(`${op}(${String(amount)})`);
|
|
233
|
+
} else if (op === 'rotate') {
|
|
234
|
+
parts.push(`rotate(${String(amount)})`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return parts.length > 0 ? parts.join(' ') : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function cssValue(name: string, value: unknown): string {
|
|
242
|
+
if (typeof value === 'number') {
|
|
243
|
+
return UNITLESS.has(name) ? String(value) : `${value}px`;
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(value)) {
|
|
246
|
+
// e.g. fontFamily stacks
|
|
247
|
+
return value.join(', ');
|
|
248
|
+
}
|
|
249
|
+
return String(value);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readPositiveLineCount(value: unknown): number | undefined {
|
|
253
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
254
|
+
? Math.trunc(value)
|
|
255
|
+
: undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function applyTextLineClampStyles(
|
|
259
|
+
el: HTMLElement,
|
|
260
|
+
element: ElementNode,
|
|
261
|
+
props: Record<string, unknown>,
|
|
262
|
+
): void {
|
|
263
|
+
if (!TEXT_TAGS.has(element.originalTag)) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const styleObject =
|
|
268
|
+
typeof props.style === 'object' && props.style !== null
|
|
269
|
+
? (props.style as Record<string, unknown>)
|
|
270
|
+
: undefined;
|
|
271
|
+
const numberOfLines =
|
|
272
|
+
readPositiveLineCount(props.numberOfLines) ??
|
|
273
|
+
readPositiveLineCount(styleObject?.numberOfLines) ??
|
|
274
|
+
readPositiveLineCount(element.style.numberOfLines);
|
|
275
|
+
if (numberOfLines == null) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (numberOfLines === 1) {
|
|
280
|
+
el.style.setProperty('display', 'block');
|
|
281
|
+
el.style.setProperty('overflow', 'hidden');
|
|
282
|
+
el.style.setProperty('text-overflow', 'ellipsis');
|
|
283
|
+
el.style.setProperty('white-space', 'nowrap');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
el.style.setProperty('display', '-webkit-box');
|
|
288
|
+
el.style.setProperty('-webkit-line-clamp', String(numberOfLines));
|
|
289
|
+
el.style.setProperty('-webkit-box-orient', 'vertical');
|
|
290
|
+
el.style.setProperty('overflow', 'hidden');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Expands RN-only shorthand/composite props into CSS-compatible entries. */
|
|
294
|
+
function convertStyle(style: Record<string, unknown> | undefined): Record<string, string> {
|
|
295
|
+
const out: Record<string, string> = {};
|
|
296
|
+
if (!style) {
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let shadow: { x?: number; y?: number; blur?: number; color?: string; opacity?: number } | null = null;
|
|
301
|
+
|
|
302
|
+
for (const [name, value] of Object.entries(style)) {
|
|
303
|
+
if (value === undefined || value === null) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
switch (name) {
|
|
307
|
+
case 'transition': {
|
|
308
|
+
const converted = cssTransition(value);
|
|
309
|
+
if (converted) {
|
|
310
|
+
out.transition = converted;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case 'transform': {
|
|
315
|
+
const converted = cssTransform(value);
|
|
316
|
+
if (converted) {
|
|
317
|
+
out.transform = converted;
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case 'paddingHorizontal':
|
|
322
|
+
out.paddingLeft = cssValue('paddingLeft', value);
|
|
323
|
+
out.paddingRight = cssValue('paddingRight', value);
|
|
324
|
+
break;
|
|
325
|
+
case 'paddingVertical':
|
|
326
|
+
out.paddingTop = cssValue('paddingTop', value);
|
|
327
|
+
out.paddingBottom = cssValue('paddingBottom', value);
|
|
328
|
+
break;
|
|
329
|
+
case 'marginHorizontal':
|
|
330
|
+
out.marginLeft = cssValue('marginLeft', value);
|
|
331
|
+
out.marginRight = cssValue('marginRight', value);
|
|
332
|
+
break;
|
|
333
|
+
case 'marginVertical':
|
|
334
|
+
out.marginTop = cssValue('marginTop', value);
|
|
335
|
+
out.marginBottom = cssValue('marginBottom', value);
|
|
336
|
+
break;
|
|
337
|
+
case 'shadowColor':
|
|
338
|
+
shadow = { ...(shadow ?? {}), color: String(value) };
|
|
339
|
+
break;
|
|
340
|
+
case 'shadowOffset': {
|
|
341
|
+
const offset = value as { width?: number; height?: number };
|
|
342
|
+
shadow = { ...(shadow ?? {}), x: offset?.width ?? 0, y: offset?.height ?? 0 };
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case 'shadowRadius':
|
|
346
|
+
shadow = { ...(shadow ?? {}), blur: Number(value) };
|
|
347
|
+
break;
|
|
348
|
+
case 'shadowOpacity':
|
|
349
|
+
// Folded into the color only when no boxShadow string is present.
|
|
350
|
+
shadow = { ...(shadow ?? {}), opacity: Number(value) };
|
|
351
|
+
break;
|
|
352
|
+
case 'textAlignVertical':
|
|
353
|
+
case 'numberOfLines':
|
|
354
|
+
case 'fontVariant':
|
|
355
|
+
break; // no direct CSS equivalent worth approximating here
|
|
356
|
+
case 'borderWidth':
|
|
357
|
+
out.borderWidth = cssValue(name, value);
|
|
358
|
+
out.borderStyle = 'solid';
|
|
359
|
+
break;
|
|
360
|
+
case 'borderBottomWidth':
|
|
361
|
+
out.borderBottomWidth = cssValue(name, value);
|
|
362
|
+
out.borderBottomStyle = 'solid';
|
|
363
|
+
break;
|
|
364
|
+
case 'borderLeftWidth':
|
|
365
|
+
out.borderLeftWidth = cssValue(name, value);
|
|
366
|
+
out.borderLeftStyle = 'solid';
|
|
367
|
+
break;
|
|
368
|
+
default:
|
|
369
|
+
out[name] = cssValue(name, value);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!out.boxShadow && shadow && (shadow.blur ?? 0) > 0 && shadow.color) {
|
|
375
|
+
const opacity = Math.max(0, Math.min(shadow.opacity ?? 1, 1));
|
|
376
|
+
const parsed = parseColor(shadow.color);
|
|
377
|
+
const cssColor = parsed
|
|
378
|
+
? colorToRgba({ ...parsed, a: Math.round(parsed.a * opacity) })
|
|
379
|
+
: shadow.color;
|
|
380
|
+
out.boxShadow = `${shadow.x ?? 0}px ${shadow.y ?? 0}px ${shadow.blur}px ${cssColor}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Tag mapping + per-tag defaults
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
const RN_CONTAINER_TAGS = new Set(['View', 'ScrollView', 'Pressable', 'List']);
|
|
391
|
+
const WEB_CONTAINER_TAGS = new Set([
|
|
392
|
+
'a',
|
|
393
|
+
'div',
|
|
394
|
+
'section',
|
|
395
|
+
'main',
|
|
396
|
+
'article',
|
|
397
|
+
'blockquote',
|
|
398
|
+
'pre',
|
|
399
|
+
'hr',
|
|
400
|
+
'nav',
|
|
401
|
+
'aside',
|
|
402
|
+
'header',
|
|
403
|
+
'footer',
|
|
404
|
+
'form',
|
|
405
|
+
'label',
|
|
406
|
+
]);
|
|
407
|
+
const TEXT_TAGS = new Set(['Text', 'text', 'span', 'p', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
408
|
+
// Semantic elements whose user-agent stylesheet margins must be zeroed so web
|
|
409
|
+
// layout matches the kernel/Taffy layout (which has no UA stylesheet). `tagName`
|
|
410
|
+
// is uppercase. Heading margins (`h1..h6`) were the missing case behind
|
|
411
|
+
// ENG-22092: web headings inherited `margin-block: <factor>em` from the UA
|
|
412
|
+
// sheet while native had none, so vertical spacing diverged. Surfaces that want
|
|
413
|
+
// heading spacing set it explicitly (the blog uses `headingMarginBlock`).
|
|
414
|
+
const UA_MARGIN_RESET_TAGS = new Set(['BLOCKQUOTE', 'PRE', 'HR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
|
415
|
+
|
|
416
|
+
function defaultDomTagFor(node: ElementNode): DomTagName {
|
|
417
|
+
const tag = node.originalTag;
|
|
418
|
+
const props = (node.originalProps ?? {}) as Record<string, unknown>;
|
|
419
|
+
if (tag === 'Pressable' || tag === 'button' || tag === 'a') {
|
|
420
|
+
return 'button';
|
|
421
|
+
}
|
|
422
|
+
if (tag === 'TextInput' || tag === 'input' || tag === 'textarea') {
|
|
423
|
+
return props.multiline ? 'textarea' : 'input';
|
|
424
|
+
}
|
|
425
|
+
if (tag === 'Toggle' || tag === 'Switch' || tag === 'toggle') {
|
|
426
|
+
return 'input';
|
|
427
|
+
}
|
|
428
|
+
if (tag === 'Image' || tag === 'img') {
|
|
429
|
+
if (typeof props.svgSource === 'string' && props.svgSource.trim().length > 0) {
|
|
430
|
+
return 'span';
|
|
431
|
+
}
|
|
432
|
+
return 'img';
|
|
433
|
+
}
|
|
434
|
+
if (TEXT_TAGS.has(tag)) {
|
|
435
|
+
return tag === 'Text' || tag === 'text' || tag === 'p' ? 'span' : (tag as DomTagName);
|
|
436
|
+
}
|
|
437
|
+
if (WEB_CONTAINER_TAGS.has(tag)) {
|
|
438
|
+
return tag as DomTagName;
|
|
439
|
+
}
|
|
440
|
+
return 'div';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function semanticAttrsFromProps(props: Record<string, unknown>): HostSemanticAttrs | undefined {
|
|
444
|
+
const tag = props.__exactSemanticTag;
|
|
445
|
+
return typeof tag === 'string' && tag.length > 0 ? { tag } : undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function loweredAttrsFor(node: ElementNode) {
|
|
449
|
+
const props = (node.originalProps ?? {}) as Record<string, unknown>;
|
|
450
|
+
return lowerHostAttrs(
|
|
451
|
+
{ tag: node.originalTag, props },
|
|
452
|
+
semanticAttrsFromProps(props),
|
|
453
|
+
{ defaultTag: defaultDomTagFor(node) },
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function domTagFor(node: ElementNode): DomTagName {
|
|
458
|
+
return loweredAttrsFor(node).tag;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// True when an interactive node sits directly inside a text element, i.e. it is
|
|
462
|
+
// part of an inline run — a markdown link or footnote reference inside a
|
|
463
|
+
// paragraph's `text`. Such a node must flow inline rather than become a
|
|
464
|
+
// block-level flex container. A standalone link/button (e.g. a footnote
|
|
465
|
+
// back-link inside a `row`) has a non-text parent and keeps its flex layout.
|
|
466
|
+
function isInlineTextFlowChild(node: ElementNode): boolean {
|
|
467
|
+
const parent = node.parent;
|
|
468
|
+
if (!parent || parent.kind !== NodeKind.Element) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return TEXT_TAGS.has((parent as ElementNode).originalTag);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function applyTagDefaults(el: HTMLElement, node: ElementNode): void {
|
|
475
|
+
const tag = node.originalTag;
|
|
476
|
+
const style = el.style as unknown as Record<string, string>;
|
|
477
|
+
style.boxSizing = 'border-box';
|
|
478
|
+
|
|
479
|
+
if (RN_CONTAINER_TAGS.has(tag)) {
|
|
480
|
+
style.display = 'flex';
|
|
481
|
+
style.flexDirection = 'column';
|
|
482
|
+
style.alignItems = 'stretch';
|
|
483
|
+
style.position = 'relative';
|
|
484
|
+
style.minWidth = '0';
|
|
485
|
+
style.minHeight = '0';
|
|
486
|
+
// ENG-22199: RN/Yoga (and the kernel/Taffy path via buildDefaultCanonicalStyle
|
|
487
|
+
// for RN-style tags) default flexShrink to 0 — flex items keep their content
|
|
488
|
+
// size and overflow rather than shrinking. CSS flexbox defaults flexShrink to
|
|
489
|
+
// 1, so a bare `row`/`column` inside an overflowing flex column would shrink
|
|
490
|
+
// below its content (down to min-height:0, set just above) and collapse to
|
|
491
|
+
// height 0 while its children paint at full size, overlapping the next
|
|
492
|
+
// sibling. Matching the RN default keeps the mirror's layout consistent with
|
|
493
|
+
// native and with initial render. Explicit `flex`/`shrink`/`grow` styles are
|
|
494
|
+
// applied after these defaults and still win (e.g. the ScrollView flex below).
|
|
495
|
+
style.flexShrink = '0';
|
|
496
|
+
}
|
|
497
|
+
if (tag === 'ScrollView') {
|
|
498
|
+
style.overflowY = 'auto';
|
|
499
|
+
style.flex = '1 1 0%';
|
|
500
|
+
style.width = '100%';
|
|
501
|
+
style.minHeight = '0';
|
|
502
|
+
}
|
|
503
|
+
if (WEB_CONTAINER_TAGS.has(tag)) {
|
|
504
|
+
style.display = 'flex';
|
|
505
|
+
style.flexDirection = 'row';
|
|
506
|
+
style.position = 'relative';
|
|
507
|
+
}
|
|
508
|
+
if (UA_MARGIN_RESET_TAGS.has(el.tagName)) {
|
|
509
|
+
style.margin = '0';
|
|
510
|
+
}
|
|
511
|
+
if (el.tagName === 'PRE') {
|
|
512
|
+
style.whiteSpace = 'pre-wrap';
|
|
513
|
+
style.font = 'inherit';
|
|
514
|
+
}
|
|
515
|
+
if (el.tagName === 'HR') {
|
|
516
|
+
style.border = 'none';
|
|
517
|
+
style.display = 'block';
|
|
518
|
+
}
|
|
519
|
+
if (TEXT_TAGS.has(tag)) {
|
|
520
|
+
style.whiteSpace = 'pre-wrap';
|
|
521
|
+
}
|
|
522
|
+
if (el.tagName === 'BUTTON') {
|
|
523
|
+
el.setAttribute('type', 'button');
|
|
524
|
+
style.display = 'flex';
|
|
525
|
+
style.flexDirection = 'column';
|
|
526
|
+
style.alignItems = 'stretch';
|
|
527
|
+
style.position = 'relative';
|
|
528
|
+
style.background = 'transparent';
|
|
529
|
+
style.border = 'none';
|
|
530
|
+
style.margin = '0';
|
|
531
|
+
style.padding = '0';
|
|
532
|
+
style.font = 'inherit';
|
|
533
|
+
style.color = 'inherit';
|
|
534
|
+
style.textAlign = 'inherit';
|
|
535
|
+
style.cursor = 'pointer';
|
|
536
|
+
}
|
|
537
|
+
if (el.tagName === 'A') {
|
|
538
|
+
style.display = 'flex';
|
|
539
|
+
style.flexDirection = 'column';
|
|
540
|
+
style.alignItems = 'stretch';
|
|
541
|
+
style.position = 'relative';
|
|
542
|
+
style.color = 'inherit';
|
|
543
|
+
style.textDecoration = 'none';
|
|
544
|
+
style.cursor = 'pointer';
|
|
545
|
+
}
|
|
546
|
+
if (tag === 'Toggle' || tag === 'Switch' || tag === 'toggle') {
|
|
547
|
+
el.setAttribute('type', 'checkbox');
|
|
548
|
+
}
|
|
549
|
+
if ((el.tagName === 'INPUT' && el.getAttribute('type') !== 'checkbox') || el.tagName === 'TEXTAREA') {
|
|
550
|
+
// Form controls do not inherit typography from their parents in the UA
|
|
551
|
+
// stylesheet; without this an unstyled input renders UA-black text on
|
|
552
|
+
// themed dark surfaces (Design Mode panel inputs). Explicit color/font
|
|
553
|
+
// styles still win — node styles are applied after tag defaults.
|
|
554
|
+
style.font = 'inherit';
|
|
555
|
+
style.color = 'inherit';
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ENG-22115: An interactive node (a link or button) that sits inside a text
|
|
559
|
+
// element is part of an inline run — e.g. a markdown link inside a paragraph.
|
|
560
|
+
// The flex-container defaults above (display:flex; flex-direction:column)
|
|
561
|
+
// make it a block-level box, so on web the link broke onto its own line with
|
|
562
|
+
// weird breaks before and after it, while macOS rendered it inline. Native
|
|
563
|
+
// flattens text + link children into a single inline run via static-text
|
|
564
|
+
// composition; the web analog is to let the element flow inline. Applied
|
|
565
|
+
// last so it overrides the BUTTON/A/Pressable flex defaults set above, and
|
|
566
|
+
// node styles (applied after tag defaults) can still override it.
|
|
567
|
+
if ((el.tagName === 'A' || el.tagName === 'BUTTON') && isInlineTextFlowChild(node)) {
|
|
568
|
+
style.display = 'inline';
|
|
569
|
+
style.flexDirection = '';
|
|
570
|
+
style.alignItems = '';
|
|
571
|
+
style.minWidth = '';
|
|
572
|
+
style.minHeight = '';
|
|
573
|
+
style.flexShrink = '';
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// Props -> attributes
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
function isMirrorDevMode(): boolean {
|
|
582
|
+
const runtime = globalThis as typeof globalThis & {
|
|
583
|
+
__DEV__?: boolean;
|
|
584
|
+
process?: { env?: { NODE_ENV?: string } };
|
|
585
|
+
};
|
|
586
|
+
if (typeof runtime.__DEV__ === 'boolean') {
|
|
587
|
+
return runtime.__DEV__;
|
|
588
|
+
}
|
|
589
|
+
return runtime.process?.env?.NODE_ENV !== 'production';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function clearElementAttributes(el: HTMLElement): void {
|
|
593
|
+
for (const name of el.getAttributeNames()) {
|
|
594
|
+
el.removeAttribute(name);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function shouldApplyHostAttr(name: string, value: unknown): boolean {
|
|
599
|
+
if (name === 'style' || name === 'children' || name === 'ref' || name === 'key') {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
if (value === undefined || value === null) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
if (typeof value === 'function') {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
if (typeof value === 'object') {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
if (!isSafeHostDomAttr(name, value)) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function applyHostAttr(el: HTMLElement, name: string, value: unknown): void {
|
|
618
|
+
if (!shouldApplyHostAttr(name, value)) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (name === 'className') {
|
|
622
|
+
el.setAttribute('class', String(value));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (name === 'htmlFor') {
|
|
626
|
+
el.setAttribute('for', String(value));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (name === 'tabIndex') {
|
|
630
|
+
el.tabIndex = Number(value);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (typeof value === 'boolean') {
|
|
634
|
+
if (name.startsWith('aria-')) {
|
|
635
|
+
el.setAttribute(name, String(value));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (value === false) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (name === 'inert') {
|
|
642
|
+
(el as HTMLElement & { inert?: boolean }).inert = true;
|
|
643
|
+
}
|
|
644
|
+
el.setAttribute(name, '');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
el.setAttribute(name, String(value));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let nextInlineSvgTitleId = 1;
|
|
651
|
+
|
|
652
|
+
function svgParserConstructor(): typeof DOMParser | undefined {
|
|
653
|
+
const runtimeParser = (globalThis as typeof globalThis & { DOMParser?: typeof DOMParser }).DOMParser;
|
|
654
|
+
if (runtimeParser) {
|
|
655
|
+
return runtimeParser;
|
|
656
|
+
}
|
|
657
|
+
return (globalThis as typeof globalThis & { window?: { DOMParser?: typeof DOMParser } }).window?.DOMParser;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function parseInlineSvg(markup: string): SVGSVGElement | null {
|
|
661
|
+
const template = document.createElement('template');
|
|
662
|
+
template.innerHTML = markup.trim();
|
|
663
|
+
const templateSvg = template.content.firstElementChild;
|
|
664
|
+
if (templateSvg && templateSvg.tagName.toLowerCase() === 'svg') {
|
|
665
|
+
return document.adoptNode(templateSvg) as unknown as SVGSVGElement;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const Parser = svgParserConstructor();
|
|
669
|
+
if (Parser) {
|
|
670
|
+
const parsed = new Parser().parseFromString(markup, 'image/svg+xml');
|
|
671
|
+
if (parsed.querySelector('parsererror')) {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
const svg = parsed.documentElement;
|
|
675
|
+
if (!svg || svg.tagName.toLowerCase() !== 'svg') {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
return document.adoptNode(svg) as unknown as SVGSVGElement;
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function mapSvgObjectPosition(objectPosition: unknown): string {
|
|
684
|
+
if (typeof objectPosition !== 'string' || objectPosition.trim().length === 0) {
|
|
685
|
+
return 'xMidYMid';
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const tokens = objectPosition.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
689
|
+
let horizontal: 'xMin' | 'xMid' | 'xMax' = 'xMid';
|
|
690
|
+
let vertical: 'YMin' | 'YMid' | 'YMax' = 'YMid';
|
|
691
|
+
|
|
692
|
+
for (const token of tokens) {
|
|
693
|
+
if (token === 'left') {
|
|
694
|
+
horizontal = 'xMin';
|
|
695
|
+
} else if (token === 'right') {
|
|
696
|
+
horizontal = 'xMax';
|
|
697
|
+
} else if (token === 'top') {
|
|
698
|
+
vertical = 'YMin';
|
|
699
|
+
} else if (token === 'bottom') {
|
|
700
|
+
vertical = 'YMax';
|
|
701
|
+
} else if (token === 'center') {
|
|
702
|
+
if (!tokens.some((value) => value === 'left' || value === 'right')) {
|
|
703
|
+
horizontal = 'xMid';
|
|
704
|
+
}
|
|
705
|
+
if (!tokens.some((value) => value === 'top' || value === 'bottom')) {
|
|
706
|
+
vertical = 'YMid';
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return `${horizontal}${vertical}`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function applyInlineSvgSizing(svg: SVGSVGElement, props: Record<string, unknown>): void {
|
|
715
|
+
const fit = props.objectFit ?? 'contain';
|
|
716
|
+
const alignment = mapSvgObjectPosition(props.objectPosition);
|
|
717
|
+
if (fit === 'cover') {
|
|
718
|
+
svg.setAttribute('preserveAspectRatio', `${alignment} slice`);
|
|
719
|
+
} else if (fit === 'fill') {
|
|
720
|
+
svg.setAttribute('preserveAspectRatio', 'none');
|
|
721
|
+
} else if (fit === 'none') {
|
|
722
|
+
svg.setAttribute('preserveAspectRatio', alignment);
|
|
723
|
+
} else {
|
|
724
|
+
svg.setAttribute('preserveAspectRatio', `${alignment} meet`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function applyInlineSvgTint(svg: SVGSVGElement, tintColor: unknown): void {
|
|
729
|
+
if (typeof tintColor !== 'string' || tintColor.length === 0) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
svg.style.color = tintColor;
|
|
734
|
+
const paintedNodes = svg.querySelectorAll<SVGElement>('[fill], [stroke]');
|
|
735
|
+
paintedNodes.forEach((node) => {
|
|
736
|
+
const fill = node.getAttribute('fill');
|
|
737
|
+
if (fill && fill !== 'none' && !fill.startsWith('url(')) {
|
|
738
|
+
node.setAttribute('fill', 'currentColor');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const stroke = node.getAttribute('stroke');
|
|
742
|
+
if (stroke && stroke !== 'none' && !stroke.startsWith('url(')) {
|
|
743
|
+
node.setAttribute('stroke', 'currentColor');
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function applyInlineSvgAccessibility(svg: SVGSVGElement, props: Record<string, unknown>): void {
|
|
749
|
+
if (props.decorative === true) {
|
|
750
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const label = typeof props.alt === 'string' && props.alt.length > 0
|
|
755
|
+
? props.alt
|
|
756
|
+
: typeof props.accessibilityLabel === 'string' && props.accessibilityLabel.length > 0
|
|
757
|
+
? props.accessibilityLabel
|
|
758
|
+
: undefined;
|
|
759
|
+
if (!label) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
svg.setAttribute('role', 'img');
|
|
764
|
+
let title = svg.querySelector<SVGElement>('title');
|
|
765
|
+
if (!title) {
|
|
766
|
+
title = document.createElementNS('http://www.w3.org/2000/svg', 'title') as SVGElement;
|
|
767
|
+
svg.prepend(title);
|
|
768
|
+
}
|
|
769
|
+
title.textContent = label;
|
|
770
|
+
const titleId = `exact-svg-title-${nextInlineSvgTitleId++}`;
|
|
771
|
+
title.setAttribute('id', titleId);
|
|
772
|
+
svg.setAttribute('aria-labelledby', titleId);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function applyInlineSvgHost(el: HTMLElement, props: Record<string, unknown>): void {
|
|
776
|
+
const markup = typeof props.svgSource === 'string' ? props.svgSource : undefined;
|
|
777
|
+
if (!markup || markup.trim().length === 0) {
|
|
778
|
+
while (el.firstChild) {
|
|
779
|
+
el.removeChild(el.firstChild);
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const style = el.style as unknown as Record<string, string>;
|
|
785
|
+
style.display = 'block';
|
|
786
|
+
style.boxSizing = 'border-box';
|
|
787
|
+
style.overflow = 'hidden';
|
|
788
|
+
|
|
789
|
+
while (el.firstChild) {
|
|
790
|
+
el.removeChild(el.firstChild);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const svg = parseInlineSvg(markup);
|
|
794
|
+
if (!svg) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
svg.style.display = 'block';
|
|
799
|
+
svg.style.width = '100%';
|
|
800
|
+
svg.style.height = '100%';
|
|
801
|
+
applyInlineSvgSizing(svg, props);
|
|
802
|
+
applyInlineSvgTint(svg, props.tintColor);
|
|
803
|
+
applyInlineSvgAccessibility(svg, props);
|
|
804
|
+
el.appendChild(svg);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function applyProps(el: HTMLElement, node: ElementNode, runtime: DomHostRuntimeOptions): void {
|
|
808
|
+
const props = (node.originalProps ?? {}) as Record<string, unknown>;
|
|
809
|
+
const lowered = loweredAttrsFor(node);
|
|
810
|
+
|
|
811
|
+
// Design Mode D0 (LLP 0158): dev-only DOM → host-node identity so the
|
|
812
|
+
// overlay can hit-test real pixels back to renderer nodes.
|
|
813
|
+
clearElementAttributes(el);
|
|
814
|
+
if (runtime.exposeDevIdentity && isMirrorDevMode()) {
|
|
815
|
+
el.setAttribute('data-exact-view-id', String(node.id));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Reset previous inline styles, then defaults + converted style.
|
|
819
|
+
applyTagDefaults(el, node);
|
|
820
|
+
const css = convertStyle(props.style as Record<string, unknown> | undefined);
|
|
821
|
+
const style = el.style as unknown as Record<string, string>;
|
|
822
|
+
for (const [name, value] of Object.entries(css)) {
|
|
823
|
+
style[name] = value;
|
|
824
|
+
}
|
|
825
|
+
applyTextLineClampStyles(el, node, props);
|
|
826
|
+
|
|
827
|
+
for (const [name, value] of Object.entries(lowered.attrs)) {
|
|
828
|
+
applyHostAttr(el, name, value);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (props.focusScope === 'trapped') {
|
|
832
|
+
el.setAttribute('data-exact-focus-scope', 'trapped');
|
|
833
|
+
if (props.__exactFocusRestore === true) {
|
|
834
|
+
el.setAttribute('data-exact-focus-restore', 'true');
|
|
835
|
+
}
|
|
836
|
+
if (!el.hasAttribute('tabindex')) {
|
|
837
|
+
el.tabIndex = -1;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (el.tagName === 'BUTTON' || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
842
|
+
(el as HTMLButtonElement).disabled = props.disabled === true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (el.tagName === 'INPUT' && el.getAttribute('type') === 'checkbox') {
|
|
846
|
+
(el as HTMLInputElement).checked = props.value === true || props.checked === true;
|
|
847
|
+
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
848
|
+
const input = el as HTMLInputElement;
|
|
849
|
+
if (props.placeholder != null) {
|
|
850
|
+
input.setAttribute('placeholder', String(props.placeholder));
|
|
851
|
+
}
|
|
852
|
+
if (props.secureTextEntry === true) {
|
|
853
|
+
input.setAttribute('type', 'password');
|
|
854
|
+
}
|
|
855
|
+
const value = props.value;
|
|
856
|
+
if (value !== undefined && input.value !== String(value ?? '')) {
|
|
857
|
+
input.value = String(value ?? '');
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (el.tagName === 'IMG') {
|
|
862
|
+
const img = el as HTMLImageElement;
|
|
863
|
+
applyThemeImageProps(img, props);
|
|
864
|
+
const resolvedSrc = resolveThemeImageSrc(props);
|
|
865
|
+
if (resolvedSrc != null && isSafeHostDomAttr('src', resolvedSrc)) {
|
|
866
|
+
img.setAttribute('src', resolvedSrc);
|
|
867
|
+
} else if (props.src != null && isSafeHostDomAttr('src', props.src)) {
|
|
868
|
+
img.setAttribute('src', String(props.src));
|
|
869
|
+
}
|
|
870
|
+
if (props.decorative === true) {
|
|
871
|
+
img.setAttribute('alt', '');
|
|
872
|
+
} else if (props.alt != null) {
|
|
873
|
+
img.setAttribute('alt', String(props.alt));
|
|
874
|
+
} else if (props.accessibilityLabel != null) {
|
|
875
|
+
img.setAttribute('alt', String(props.accessibilityLabel));
|
|
876
|
+
}
|
|
877
|
+
} else if (el.tagName === 'SPAN' && typeof props.svgSource === 'string') {
|
|
878
|
+
applyInlineSvgHost(el, props);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function applyThemeImageProps(img: HTMLImageElement, props: Record<string, unknown>): void {
|
|
883
|
+
const lightSrc = stringProp(props.lightSrc);
|
|
884
|
+
const darkSrc = stringProp(props.darkSrc);
|
|
885
|
+
const rawTreatment = stringProp(props.themeTreatment);
|
|
886
|
+
const treatment = rawTreatment === 'none' ? undefined : rawTreatment;
|
|
887
|
+
|
|
888
|
+
setOrRemoveAttr(img, 'data-light-src', lightSrc);
|
|
889
|
+
setOrRemoveAttr(img, 'data-dark-src', darkSrc);
|
|
890
|
+
setOrRemoveAttr(img, 'data-exact-theme-treatment', treatment);
|
|
891
|
+
if (lightSrc || darkSrc || treatment) {
|
|
892
|
+
img.setAttribute('data-exact-theme-image', 'color-scheme');
|
|
893
|
+
} else {
|
|
894
|
+
img.removeAttribute('data-exact-theme-image');
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function resolveThemeImageSrc(props: Record<string, unknown>): string | undefined {
|
|
899
|
+
const src = stringProp(props.src);
|
|
900
|
+
const lightSrc = stringProp(props.lightSrc);
|
|
901
|
+
const darkSrc = stringProp(props.darkSrc);
|
|
902
|
+
if (!lightSrc && !darkSrc) {
|
|
903
|
+
return src;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const scheme = getRuntimeImageColorScheme();
|
|
907
|
+
return scheme === 'dark'
|
|
908
|
+
? darkSrc ?? src ?? lightSrc
|
|
909
|
+
: lightSrc ?? src ?? darkSrc;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function stringProp(value: unknown): string | undefined {
|
|
913
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function setOrRemoveAttr(el: HTMLElement, name: string, value: string | undefined): void {
|
|
917
|
+
if (value != null) {
|
|
918
|
+
el.setAttribute(name, value);
|
|
919
|
+
} else {
|
|
920
|
+
el.removeAttribute(name);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// Focus scopes: Contract overlay metadata -> real browser focus behavior
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
const FOCUSABLE_SELECTOR = [
|
|
929
|
+
'a[href]',
|
|
930
|
+
'button:not([disabled])',
|
|
931
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
932
|
+
'select:not([disabled])',
|
|
933
|
+
'textarea:not([disabled])',
|
|
934
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
935
|
+
].join(',');
|
|
936
|
+
|
|
937
|
+
function activeElement(): HTMLElement | null {
|
|
938
|
+
const active = document.activeElement;
|
|
939
|
+
return active && typeof (active as HTMLElement).focus === 'function'
|
|
940
|
+
? (active as HTMLElement)
|
|
941
|
+
: null;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function focusableDescendants(scope: HTMLElement): HTMLElement[] {
|
|
945
|
+
const nodes = scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
946
|
+
const focusables: HTMLElement[] = [];
|
|
947
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
948
|
+
const candidate = nodes.item(index);
|
|
949
|
+
if (
|
|
950
|
+
!candidate.hasAttribute('disabled') &&
|
|
951
|
+
candidate.getAttribute('aria-hidden') !== 'true' &&
|
|
952
|
+
candidate.tabIndex >= 0
|
|
953
|
+
) {
|
|
954
|
+
focusables.push(candidate);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return focusables;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function focusTargetForScope(scope: HTMLElement): HTMLElement {
|
|
961
|
+
return focusableDescendants(scope)[0] ?? scope;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function trapTabKey(scope: HTMLElement, event: KeyboardEvent): void {
|
|
965
|
+
const focusables = focusableDescendants(scope);
|
|
966
|
+
if (focusables.length === 0) {
|
|
967
|
+
event.preventDefault();
|
|
968
|
+
scope.focus();
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const active = activeElement();
|
|
973
|
+
const index = active ? focusables.indexOf(active) : -1;
|
|
974
|
+
if (event.shiftKey) {
|
|
975
|
+
if (index <= 0) {
|
|
976
|
+
event.preventDefault();
|
|
977
|
+
focusables[focusables.length - 1]?.focus();
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (index === -1 || index === focusables.length - 1) {
|
|
983
|
+
event.preventDefault();
|
|
984
|
+
focusables[0]?.focus();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
// Events: DOM -> adapter handler props (read lazily at event time, so
|
|
990
|
+
// updated handlers and interaction behaviors keep working without rewiring)
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
function handler(node: ElementNode, name: string): ((...args: unknown[]) => void) | null {
|
|
994
|
+
const props = (node.originalProps ?? {}) as Record<string, unknown>;
|
|
995
|
+
const candidate = props[name];
|
|
996
|
+
return typeof candidate === 'function' ? (candidate as (...args: unknown[]) => void) : null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function syncControlledInputValue(el: HTMLInputElement | HTMLTextAreaElement, node: ElementNode): void {
|
|
1000
|
+
const props = (node.originalProps ?? {}) as Record<string, unknown>;
|
|
1001
|
+
const value = props.value;
|
|
1002
|
+
if (value === undefined) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const controlledValue = String(value ?? '');
|
|
1006
|
+
if (el.value !== controlledValue) {
|
|
1007
|
+
el.value = controlledValue;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// LLP 0281 stage 0 (LLP 0275 A1): element-local pointer payload for the
|
|
1012
|
+
// Contract pointer event attrs. The envelope carries the pinned sample
|
|
1013
|
+
// (element-local coordinates) plus the element box so a rated publish can
|
|
1014
|
+
// normalize to fractions without a second geometry read.
|
|
1015
|
+
function pointerEnvelope(el: HTMLElement, event: Event, phase: string): Record<string, unknown> {
|
|
1016
|
+
const pointer = event as PointerEvent;
|
|
1017
|
+
const rect = el.getBoundingClientRect();
|
|
1018
|
+
return {
|
|
1019
|
+
sample: {
|
|
1020
|
+
x: (pointer.clientX ?? 0) - rect.left,
|
|
1021
|
+
y: (pointer.clientY ?? 0) - rect.top,
|
|
1022
|
+
pointerId: pointer.pointerId ?? -1,
|
|
1023
|
+
pointerType: pointer.pointerType || 'mouse',
|
|
1024
|
+
buttons: pointer.buttons ?? 0,
|
|
1025
|
+
pressure: pointer.pressure ?? 0,
|
|
1026
|
+
},
|
|
1027
|
+
element: { width: rect.width, height: rect.height },
|
|
1028
|
+
phase,
|
|
1029
|
+
timeStampMs: event.timeStamp,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function dispatchPointerHandler(
|
|
1034
|
+
el: HTMLElement,
|
|
1035
|
+
node: ElementNode,
|
|
1036
|
+
event: Event,
|
|
1037
|
+
phase: string,
|
|
1038
|
+
handlerProp: string,
|
|
1039
|
+
): void {
|
|
1040
|
+
const pointerHandler = handler(node, handlerProp);
|
|
1041
|
+
if (pointerHandler) {
|
|
1042
|
+
pointerHandler(pointerEnvelope(el, event, phase));
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function wireEvents(el: HTMLElement, node: ElementNode): void {
|
|
1047
|
+
el.addEventListener('click', (event) => {
|
|
1048
|
+
if ((node.originalProps as Record<string, unknown>)?.disabled === true) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
handler(node, 'onPress')?.({ type: 'press', nativeEvent: event });
|
|
1052
|
+
});
|
|
1053
|
+
el.addEventListener('pointerdown', (event) => {
|
|
1054
|
+
handler(node, 'onPressIn')?.({ type: 'pressin' });
|
|
1055
|
+
// The capture affordance (LLP 0275 A2): explicit `pointerCapture=true`
|
|
1056
|
+
// captures the pointer on down, web-platform style, so a drag keeps
|
|
1057
|
+
// streaming to this element after the pointer leaves its box.
|
|
1058
|
+
const props = (node.originalProps ?? {}) as Record<string, unknown>;
|
|
1059
|
+
if (props.pointerCapture === true) {
|
|
1060
|
+
const pointerId = (event as PointerEvent).pointerId;
|
|
1061
|
+
if (typeof pointerId === 'number' && typeof el.setPointerCapture === 'function') {
|
|
1062
|
+
try {
|
|
1063
|
+
el.setPointerCapture(pointerId);
|
|
1064
|
+
} catch {
|
|
1065
|
+
// Capture is best-effort (the pointer may already be gone).
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
dispatchPointerHandler(el, node, event, 'pointerdown', 'onPointerDown');
|
|
1070
|
+
});
|
|
1071
|
+
el.addEventListener('pointerup', (event) => {
|
|
1072
|
+
handler(node, 'onPressOut')?.({ type: 'pressout' });
|
|
1073
|
+
dispatchPointerHandler(el, node, event, 'pointerup', 'onPointerUp');
|
|
1074
|
+
});
|
|
1075
|
+
el.addEventListener('pointermove', (event) => {
|
|
1076
|
+
dispatchPointerHandler(el, node, event, 'pointermove', 'onPointerMove');
|
|
1077
|
+
});
|
|
1078
|
+
el.addEventListener('pointercancel', (event) => {
|
|
1079
|
+
dispatchPointerHandler(el, node, event, 'pointercancel', 'onPointerCancel');
|
|
1080
|
+
});
|
|
1081
|
+
el.addEventListener('pointerenter', (event) => {
|
|
1082
|
+
handler(node, 'onHoverIn')?.({ type: 'hoverin' });
|
|
1083
|
+
handler(node, 'onMouseEnter')?.({ type: 'mouseenter' });
|
|
1084
|
+
dispatchPointerHandler(el, node, event, 'pointerenter', 'onPointerEnter');
|
|
1085
|
+
});
|
|
1086
|
+
el.addEventListener('pointerleave', (event) => {
|
|
1087
|
+
handler(node, 'onPressOut')?.({ type: 'pressout' });
|
|
1088
|
+
handler(node, 'onHoverOut')?.({ type: 'hoverout' });
|
|
1089
|
+
handler(node, 'onMouseLeave')?.({ type: 'mouseleave' });
|
|
1090
|
+
dispatchPointerHandler(el, node, event, 'pointerleave', 'onPointerLeave');
|
|
1091
|
+
});
|
|
1092
|
+
el.addEventListener('focus', () => {
|
|
1093
|
+
handler(node, 'onFocus')?.({ type: 'focus' });
|
|
1094
|
+
});
|
|
1095
|
+
el.addEventListener('blur', () => {
|
|
1096
|
+
handler(node, 'onBlur')?.({ type: 'blur' });
|
|
1097
|
+
});
|
|
1098
|
+
el.addEventListener('keydown', (event) => {
|
|
1099
|
+
if ((node.originalProps as Record<string, unknown>)?.focusScope === 'trapped') {
|
|
1100
|
+
const key = (event as KeyboardEvent).key;
|
|
1101
|
+
if (key === 'Tab') {
|
|
1102
|
+
trapTabKey(el, event as KeyboardEvent);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
handler(node, 'onKeyDown')?.({ key: (event as KeyboardEvent).key });
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
1109
|
+
el.addEventListener('input', () => {
|
|
1110
|
+
const input = el as HTMLInputElement;
|
|
1111
|
+
if (input.getAttribute('type') === 'checkbox') {
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
handler(node, 'onChangeText')?.(input.value);
|
|
1115
|
+
handler(node, 'onChange')?.({ value: input.value, nativeEvent: { text: input.value } });
|
|
1116
|
+
syncControlledInputValue(input, node);
|
|
1117
|
+
});
|
|
1118
|
+
el.addEventListener('change', () => {
|
|
1119
|
+
const input = el as HTMLInputElement;
|
|
1120
|
+
if (input.getAttribute('type') !== 'checkbox') {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
handler(node, 'onChange')?.({ value: input.checked, nativeEvent: { value: input.checked } });
|
|
1124
|
+
handler(node, 'onValueChange')?.(input.checked);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
// Reconciliation
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
|
|
1133
|
+
const MIRRORED = Symbol('exactDomMirror');
|
|
1134
|
+
|
|
1135
|
+
interface MirroredDomNode extends Node {
|
|
1136
|
+
[MIRRORED]?: true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function installDomHost(
|
|
1140
|
+
options: DomMirrorOptions,
|
|
1141
|
+
runtime: DomHostRuntimeOptions,
|
|
1142
|
+
): DomMirrorHandle | null {
|
|
1143
|
+
if (typeof document === 'undefined') {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
if (hasProtocolHost() && options.force !== true) {
|
|
1147
|
+
// A real platform host owns pixels; the mirror stays out of the way.
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const domByNode = new WeakMap<HostChild, MirroredDomNode>();
|
|
1152
|
+
const domByViewId = new Map<number, HTMLElement>();
|
|
1153
|
+
const viewIdByDom = new WeakMap<Node, number>();
|
|
1154
|
+
const appliedProps = new WeakMap<ElementNode, unknown>();
|
|
1155
|
+
const appliedText = new WeakMap<TextNode, string>();
|
|
1156
|
+
// LLP 0281 §3.3 — channel-driven style values (transform/opacity sinks).
|
|
1157
|
+
// Applied straight to the DOM node, bypassing the reactive flush; kept
|
|
1158
|
+
// here so applyProps (which resets inline styles) re-applies them after
|
|
1159
|
+
// any ordinary prop update.
|
|
1160
|
+
const channelStyles = new WeakMap<ElementNode, Record<string, unknown>>();
|
|
1161
|
+
const rootContainers = new Map<number, HTMLElement>();
|
|
1162
|
+
const wiredDomNodes = new WeakSet<HTMLElement>();
|
|
1163
|
+
const focusScopes = new Map<HTMLElement, { previous: HTMLElement | null; restore: boolean }>();
|
|
1164
|
+
const hydrationAdopter = options.hydrate
|
|
1165
|
+
? globalThis.__exactDomHydrationAdopterFactory?.({
|
|
1166
|
+
hydrate: options.hydrate,
|
|
1167
|
+
markMirrored(node) {
|
|
1168
|
+
(node as MirroredDomNode)[MIRRORED] = true;
|
|
1169
|
+
},
|
|
1170
|
+
}) ?? null
|
|
1171
|
+
: null;
|
|
1172
|
+
let disposed = false;
|
|
1173
|
+
|
|
1174
|
+
const baseContainer = (): HTMLElement =>
|
|
1175
|
+
options.container ?? (document.getElementById('exact-root') as HTMLElement | null) ?? document.body;
|
|
1176
|
+
|
|
1177
|
+
const previousIdentity = globalThis.__exactDomIdentity;
|
|
1178
|
+
const identityRegistry: DomIdentityRegistry = {
|
|
1179
|
+
elementForViewId(viewId: number): HTMLElement | null {
|
|
1180
|
+
const element = domByViewId.get(viewId) ?? null;
|
|
1181
|
+
if (element?.isConnected) {
|
|
1182
|
+
return element;
|
|
1183
|
+
}
|
|
1184
|
+
return previousIdentity?.elementForViewId(viewId) ?? null;
|
|
1185
|
+
},
|
|
1186
|
+
viewIdForTarget(target: EventTarget | null): number | null {
|
|
1187
|
+
let node = asDomNode(target);
|
|
1188
|
+
while (node) {
|
|
1189
|
+
const id = viewIdByDom.get(node);
|
|
1190
|
+
if (typeof id === 'number') {
|
|
1191
|
+
return id;
|
|
1192
|
+
}
|
|
1193
|
+
node = node.parentNode;
|
|
1194
|
+
}
|
|
1195
|
+
return previousIdentity?.viewIdForTarget(target) ?? null;
|
|
1196
|
+
},
|
|
1197
|
+
};
|
|
1198
|
+
globalThis.__exactDomIdentity = identityRegistry;
|
|
1199
|
+
|
|
1200
|
+
function containerFor(rootId: number): HTMLElement {
|
|
1201
|
+
let container = rootContainers.get(rootId);
|
|
1202
|
+
if (!container || !container.isConnected) {
|
|
1203
|
+
const adopted = hydrationAdopter?.adoptRootContainer(
|
|
1204
|
+
rootId,
|
|
1205
|
+
baseContainer(),
|
|
1206
|
+
runtime.rootAttribute,
|
|
1207
|
+
);
|
|
1208
|
+
if (adopted) {
|
|
1209
|
+
(adopted as MirroredDomNode)[MIRRORED] = true;
|
|
1210
|
+
rootContainers.set(rootId, adopted);
|
|
1211
|
+
return adopted;
|
|
1212
|
+
}
|
|
1213
|
+
container = document.createElement('div');
|
|
1214
|
+
(container as MirroredDomNode)[MIRRORED] = true;
|
|
1215
|
+
container.setAttribute(runtime.rootAttribute, String(rootId));
|
|
1216
|
+
const style = container.style as unknown as Record<string, string>;
|
|
1217
|
+
style.display = 'flex';
|
|
1218
|
+
style.flexDirection = 'column';
|
|
1219
|
+
style.alignItems = 'stretch';
|
|
1220
|
+
style.width = '100%';
|
|
1221
|
+
style.height = '100%';
|
|
1222
|
+
style.minHeight = '0';
|
|
1223
|
+
// Platform parity: native renders unstyled text in the system font, so
|
|
1224
|
+
// the mirror's default must be the system sans stack — not the
|
|
1225
|
+
// browser's serif fallback. Nodes that set fontFamily still win.
|
|
1226
|
+
style.fontFamily =
|
|
1227
|
+
"-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, 'Helvetica Neue', sans-serif";
|
|
1228
|
+
baseContainer().appendChild(container);
|
|
1229
|
+
rootContainers.set(rootId, container);
|
|
1230
|
+
}
|
|
1231
|
+
return container;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function syncFocusScopes(rootContainer: HTMLElement): void {
|
|
1235
|
+
const currentNodes = rootContainer.querySelectorAll<HTMLElement>('[data-exact-focus-scope="trapped"]');
|
|
1236
|
+
const current = new Set<HTMLElement>();
|
|
1237
|
+
for (let index = 0; index < currentNodes.length; index += 1) {
|
|
1238
|
+
current.add(currentNodes.item(index));
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
for (const [scope, state] of focusScopes) {
|
|
1242
|
+
if (scope.isConnected && current.has(scope)) {
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
focusScopes.delete(scope);
|
|
1246
|
+
if (state.restore && state.previous?.isConnected) {
|
|
1247
|
+
state.previous.focus();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
for (const scope of current) {
|
|
1252
|
+
if (focusScopes.has(scope)) {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const previous = activeElement();
|
|
1256
|
+
focusScopes.set(scope, {
|
|
1257
|
+
previous: previous && !scope.contains(previous) ? previous : null,
|
|
1258
|
+
restore: scope.getAttribute('data-exact-focus-restore') === 'true',
|
|
1259
|
+
});
|
|
1260
|
+
focusTargetForScope(scope).focus();
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function wireEventsOnce(el: HTMLElement, node: ElementNode): void {
|
|
1265
|
+
if (wiredDomNodes.has(el)) {
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
wiredDomNodes.add(el);
|
|
1269
|
+
wireEvents(el, node);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// LLP 0281 §3.3 — write one channel-driven style value onto the DOM node
|
|
1273
|
+
// (compositor-only work: transform/opacity), through the same style
|
|
1274
|
+
// conversion applyProps uses so values mean the same thing on both paths.
|
|
1275
|
+
function writeChannelStyle(el: HTMLElement, styleProp: string, value: unknown): void {
|
|
1276
|
+
const css = convertStyle({ [styleProp]: value });
|
|
1277
|
+
const style = el.style as unknown as Record<string, string>;
|
|
1278
|
+
for (const [name, cssValue] of Object.entries(css)) {
|
|
1279
|
+
style[name] = cssValue;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function reapplyChannelStyles(el: HTMLElement, node: ElementNode): void {
|
|
1284
|
+
const pending = channelStyles.get(node);
|
|
1285
|
+
if (!pending) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
for (const [styleProp, value] of Object.entries(pending)) {
|
|
1289
|
+
writeChannelStyle(el, styleProp, value);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// The host channel-application seam (LLP 0281 stage 0): the Contract
|
|
1294
|
+
// runtime routes channel style sinks here so per-frame pointer motion
|
|
1295
|
+
// never runs a reactive flush or a host-ops prop diff. Values are
|
|
1296
|
+
// recorded per node and re-applied after ordinary prop updates (which
|
|
1297
|
+
// reset inline styles).
|
|
1298
|
+
const channelApplier: HostChannelStyleApplier = (target, styleProp, value) => {
|
|
1299
|
+
const node = target as ElementNode;
|
|
1300
|
+
if (!node || typeof node !== 'object' || node.kind !== NodeKind.Element) {
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
let pending = channelStyles.get(node);
|
|
1304
|
+
if (!pending) {
|
|
1305
|
+
pending = {};
|
|
1306
|
+
channelStyles.set(node, pending);
|
|
1307
|
+
}
|
|
1308
|
+
pending[styleProp] = value;
|
|
1309
|
+
const dom = domByNode.get(node) as HTMLElement | undefined;
|
|
1310
|
+
if (dom) {
|
|
1311
|
+
writeChannelStyle(dom, styleProp, value);
|
|
1312
|
+
}
|
|
1313
|
+
// Recorded even when the node has no DOM yet — ensureDom applies it on
|
|
1314
|
+
// first sync, so early channel writes are never lost.
|
|
1315
|
+
return true;
|
|
1316
|
+
};
|
|
1317
|
+
const previousChannelApplier = getHostChannelStyleApplier();
|
|
1318
|
+
setHostChannelStyleApplier(channelApplier);
|
|
1319
|
+
|
|
1320
|
+
function ensureDom(
|
|
1321
|
+
child: HostChild,
|
|
1322
|
+
rootId: number,
|
|
1323
|
+
rootContainer: HTMLElement,
|
|
1324
|
+
): MirroredDomNode {
|
|
1325
|
+
if (child.kind === NodeKind.Text) {
|
|
1326
|
+
const textNode = child as TextNode;
|
|
1327
|
+
let dom = domByNode.get(textNode);
|
|
1328
|
+
if (!dom) {
|
|
1329
|
+
dom = (hydrationAdopter?.adoptDom(textNode, rootId, rootContainer) as MirroredDomNode | null) ??
|
|
1330
|
+
(document.createTextNode(textNode.text) as MirroredDomNode);
|
|
1331
|
+
dom[MIRRORED] = true;
|
|
1332
|
+
domByNode.set(textNode, dom);
|
|
1333
|
+
appliedText.set(textNode, textNode.text);
|
|
1334
|
+
} else if (appliedText.get(textNode) !== textNode.text) {
|
|
1335
|
+
dom.nodeValue = textNode.text;
|
|
1336
|
+
appliedText.set(textNode, textNode.text);
|
|
1337
|
+
}
|
|
1338
|
+
return dom;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const element = child as ElementNode;
|
|
1342
|
+
let dom = domByNode.get(element) as (HTMLElement & MirroredDomNode) | undefined;
|
|
1343
|
+
const desiredTag = domTagFor(element);
|
|
1344
|
+
if (!dom || dom.localName !== desiredTag) {
|
|
1345
|
+
const previous = dom;
|
|
1346
|
+
dom = (hydrationAdopter?.adoptDom(
|
|
1347
|
+
element,
|
|
1348
|
+
rootId,
|
|
1349
|
+
rootContainer,
|
|
1350
|
+
desiredTag,
|
|
1351
|
+
) as HTMLElement & MirroredDomNode | null) ??
|
|
1352
|
+
(document.createElement(desiredTag) as HTMLElement & MirroredDomNode);
|
|
1353
|
+
dom[MIRRORED] = true;
|
|
1354
|
+
domByNode.set(element, dom);
|
|
1355
|
+
wireEventsOnce(dom, element);
|
|
1356
|
+
if (previous?.parentNode) {
|
|
1357
|
+
previous.parentNode.replaceChild(dom, previous);
|
|
1358
|
+
}
|
|
1359
|
+
applyProps(dom, element, runtime);
|
|
1360
|
+
appliedProps.set(element, element.originalProps);
|
|
1361
|
+
reapplyChannelStyles(dom, element);
|
|
1362
|
+
} else if (appliedProps.get(element) !== element.originalProps) {
|
|
1363
|
+
applyProps(dom, element, runtime);
|
|
1364
|
+
appliedProps.set(element, element.originalProps);
|
|
1365
|
+
reapplyChannelStyles(dom, element);
|
|
1366
|
+
}
|
|
1367
|
+
domByViewId.set(element.id, dom);
|
|
1368
|
+
viewIdByDom.set(dom, element.id);
|
|
1369
|
+
|
|
1370
|
+
reconcileChildren(element.children as HostChild[], dom, rootId, rootContainer);
|
|
1371
|
+
return dom;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function reconcileChildren(
|
|
1375
|
+
children: HostChild[],
|
|
1376
|
+
domParent: HTMLElement,
|
|
1377
|
+
rootId: number,
|
|
1378
|
+
rootContainer: HTMLElement,
|
|
1379
|
+
): void {
|
|
1380
|
+
const desired = children.map((child) => ensureDom(child, rootId, rootContainer));
|
|
1381
|
+
const desiredSet = new Set<Node>(desired);
|
|
1382
|
+
|
|
1383
|
+
// Remove mirror-owned DOM children that no longer correspond to a node.
|
|
1384
|
+
for (const existing of Array.from(domParent.childNodes)) {
|
|
1385
|
+
if ((existing as MirroredDomNode)[MIRRORED] && !desiredSet.has(existing)) {
|
|
1386
|
+
domParent.removeChild(existing);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Put the survivors in order with minimal moves.
|
|
1391
|
+
let cursor = domParent.firstChild;
|
|
1392
|
+
for (const next of desired) {
|
|
1393
|
+
while (cursor && !(cursor as MirroredDomNode)[MIRRORED]) {
|
|
1394
|
+
cursor = cursor.nextSibling;
|
|
1395
|
+
}
|
|
1396
|
+
if (cursor === next) {
|
|
1397
|
+
cursor = cursor.nextSibling;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
domParent.insertBefore(next, cursor);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const registry: DomMirrorRegistry = {
|
|
1405
|
+
sync(root: RootNode): void {
|
|
1406
|
+
if (disposed) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const rootContainer = containerFor(root.rootId);
|
|
1410
|
+
reconcileChildren(root.children as HostChild[], rootContainer, root.rootId, rootContainer);
|
|
1411
|
+
hydrationAdopter?.report(root.rootId);
|
|
1412
|
+
syncFocusScopes(rootContainer);
|
|
1413
|
+
},
|
|
1414
|
+
remove(rootId: number): void {
|
|
1415
|
+
const container = rootContainers.get(rootId);
|
|
1416
|
+
container?.parentNode?.removeChild(container);
|
|
1417
|
+
rootContainers.delete(rootId);
|
|
1418
|
+
syncFocusScopes(document.body);
|
|
1419
|
+
},
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
globalThis.__exactDomMirror = registry;
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
dispose(): void {
|
|
1426
|
+
disposed = true;
|
|
1427
|
+
if (globalThis.__exactDomMirror === registry) {
|
|
1428
|
+
globalThis.__exactDomMirror = undefined;
|
|
1429
|
+
}
|
|
1430
|
+
if (globalThis.__exactDomIdentity === identityRegistry) {
|
|
1431
|
+
globalThis.__exactDomIdentity = previousIdentity;
|
|
1432
|
+
}
|
|
1433
|
+
if (getHostChannelStyleApplier() === channelApplier) {
|
|
1434
|
+
setHostChannelStyleApplier(previousChannelApplier);
|
|
1435
|
+
}
|
|
1436
|
+
for (const container of rootContainers.values()) {
|
|
1437
|
+
container.parentNode?.removeChild(container);
|
|
1438
|
+
}
|
|
1439
|
+
domByViewId.clear();
|
|
1440
|
+
rootContainers.clear();
|
|
1441
|
+
},
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
export function installDomMirror(options: DomMirrorOptions = {}): DomMirrorHandle | null {
|
|
1446
|
+
return installDomHost(options, {
|
|
1447
|
+
exposeDevIdentity: true,
|
|
1448
|
+
rootAttribute: 'data-exact-mirror-root',
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
export function installContractWebHost(
|
|
1453
|
+
options: ContractWebHostOptions = {},
|
|
1454
|
+
): ContractWebHostHandle | null {
|
|
1455
|
+
return installDomHost(options, {
|
|
1456
|
+
exposeDevIdentity: false,
|
|
1457
|
+
rootAttribute: 'data-exact-contract-host-root',
|
|
1458
|
+
});
|
|
1459
|
+
}
|