@effindomv2/runtime 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.
Files changed (92) hide show
  1. package/LICENSE.md +6 -0
  2. package/dist/bridge.js +4 -0
  3. package/dist/bridge.js.map +7 -0
  4. package/dist/effindom.v2.manifest.json +68 -0
  5. package/dist/fonts/NotoColorEmoji.ttf +0 -0
  6. package/dist/fonts/NotoEmoji-Regular.ttf +0 -0
  7. package/dist/fonts/NotoSans-Bold.ttf +0 -0
  8. package/dist/fonts/NotoSans-BoldItalic.ttf +0 -0
  9. package/dist/fonts/NotoSans-Italic.ttf +0 -0
  10. package/dist/fonts/NotoSans-Regular.ttf +0 -0
  11. package/dist/fonts/NotoSansMono-Bold.ttf +0 -0
  12. package/dist/fonts/NotoSansMono-Regular.ttf +0 -0
  13. package/dist/fonts/NotoSansSymbols2-Regular.ttf +0 -0
  14. package/dist/harness.js +2 -0
  15. package/dist/harness.js.map +7 -0
  16. package/dist/index.html +53 -0
  17. package/dist/runtime/effindom-core-v2.wasm32-simd.JQXIaRaN0-JahfIVFiSLE49WzzCENvef_2EDEm09nJs.wasm +0 -0
  18. package/dist/runtime/effindom-core-v2.wasm32-simd.y7RzpkMARiFeRkpgiqKQsAfv4Hf17NYdpni-6aLNhMs.js.symbols +10079 -0
  19. package/dist/runtime/effindom-core-v2.wasm32-simd.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
  20. package/dist/runtime/effindom-core-v2.wasm32.JSfMkp9ertJzSZxA-_xz3yacrJUhswxlwbqbJLRIuqw.wasm +0 -0
  21. package/dist/runtime/effindom-core-v2.wasm32.xNgsQv7dCwf8Uy-PfJSoRNyk9-q1OSogUwkk5g6ZBjk.js.symbols +10088 -0
  22. package/dist/runtime/effindom-core-v2.wasm32.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
  23. package/dist/runtime/effindom-core-v2.wasm64-simd.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
  24. package/dist/runtime/effindom-core-v2.wasm64-simd.p4P98oRu2wEWxtRRW8RHr27JhGeWvWlziZXDM_z3Nc4.js.symbols +10286 -0
  25. package/dist/runtime/effindom-core-v2.wasm64-simd.y75FYXRwhQrpaDGYbZWrohGDv0AmjTb-EjXwOjBIgnM.wasm +0 -0
  26. package/dist/runtime/effindom-core-v2.wasm64.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
  27. package/dist/runtime/effindom-core-v2.wasm64.emhE1_CJs4_zXp8wiQS_5lYpUQ0OchmXgxksi0ykaBs.js.symbols +10298 -0
  28. package/dist/runtime/effindom-core-v2.wasm64.sO-Yu70cfN8Qs3a5iEp6cbFPaiOchqcMKUzryu4npNo.wasm +0 -0
  29. package/dist/runtime/effindom-ui-v2.wasm32-simd.0Mas1XD03eYvemryTioWaZOBuBA5ij7MFlTa8CgEZWs.wasm +0 -0
  30. package/dist/runtime/effindom-ui-v2.wasm32-simd.ThSDClMnSWdwf9d89JZfYor0G1Z6OxR4lOc75rNRuD4.js.symbols +1890 -0
  31. package/dist/runtime/effindom-ui-v2.wasm32-simd.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
  32. package/dist/runtime/effindom-ui-v2.wasm32.H7kYg99bT9ADGh0uUvj6H9Dk1L058nVFLv_4R79IXW8.js.symbols +1900 -0
  33. package/dist/runtime/effindom-ui-v2.wasm32.tp53X7nHfG_EUq29naDyElfnqhMw2D1Tr1T-BJAYO7w.wasm +0 -0
  34. package/dist/runtime/effindom-ui-v2.wasm32.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
  35. package/dist/runtime/effindom-ui-v2.wasm64-simd.86tk9Z3xIpgTOykET_8Nn9iUVJnp1AzOHW4fVQRGtQE.wasm +0 -0
  36. package/dist/runtime/effindom-ui-v2.wasm64-simd.RQaXil22Chu63-vxK9oOuX8wUY044kbo190oYIbBU4M.js.symbols +1918 -0
  37. package/dist/runtime/effindom-ui-v2.wasm64-simd.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
  38. package/dist/runtime/effindom-ui-v2.wasm64.YSwpMFbr-Q1SBe0Ze8mub1u1PqsvSz3QIYuA3eaUMME.js.symbols +1924 -0
  39. package/dist/runtime/effindom-ui-v2.wasm64.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
  40. package/dist/runtime/effindom-ui-v2.wasm64.ioQ9DuM6gR_EjlfRHdF8EvNPBcKCs0PQbbY9-cjTV6Y.wasm +0 -0
  41. package/dist/runtime/icudt_minimal.962CX1q0-Nbv-OqXPaub5piYTOLumUk-nEvemcvvnpw.dat +0 -0
  42. package/package.json +62 -0
  43. package/scripts/build.sh +279 -0
  44. package/scripts/build_assets.sh +51 -0
  45. package/scripts/font_assets.sh +52 -0
  46. package/scripts/generate_manifest.py +121 -0
  47. package/scripts/stage_package_assets.sh +42 -0
  48. package/src/bridge/commit-policy.ts +10 -0
  49. package/src/bridge/events/canvas-geometry.ts +78 -0
  50. package/src/bridge/events/key-router.ts +187 -0
  51. package/src/bridge/events/pointer-router.ts +619 -0
  52. package/src/bridge/events/semantic-hit-testing.ts +27 -0
  53. package/src/bridge/events.ts +54 -0
  54. package/src/bridge/find-dialog.ts +690 -0
  55. package/src/bridge/find-session.ts +158 -0
  56. package/src/bridge/font-catalog.ts +51 -0
  57. package/src/bridge/google-fonts.ts +63 -0
  58. package/src/bridge/incremental-font-packages.ts +216 -0
  59. package/src/bridge/init.ts +77 -0
  60. package/src/bridge/interaction/editor-model.ts +371 -0
  61. package/src/bridge/interaction/editor-mutations.ts +495 -0
  62. package/src/bridge/interaction/editor-session.ts +628 -0
  63. package/src/bridge/interaction/logs.ts +23 -0
  64. package/src/bridge/interaction/text-encoding.ts +51 -0
  65. package/src/bridge/interaction.ts +86 -0
  66. package/src/bridge/local-types.ts +105 -0
  67. package/src/bridge/platform.ts +68 -0
  68. package/src/bridge/pointer-move-coalescer.ts +41 -0
  69. package/src/bridge/pull-to-refresh.ts +124 -0
  70. package/src/bridge/render-loop.ts +268 -0
  71. package/src/bridge/runtime/asset-manager.ts +202 -0
  72. package/src/bridge/runtime/find-controller.ts +269 -0
  73. package/src/bridge/runtime/font-manager.ts +691 -0
  74. package/src/bridge/runtime/open-canvas-api.ts +72 -0
  75. package/src/bridge/runtime/semantic-controller.ts +133 -0
  76. package/src/bridge/runtime/text-documents.ts +234 -0
  77. package/src/bridge/runtime.ts +315 -0
  78. package/src/bridge/touch-gesture.ts +159 -0
  79. package/src/bridge/utils/assets.ts +572 -0
  80. package/src/bridge/utils/backends.ts +163 -0
  81. package/src/bridge/utils/encoding.ts +128 -0
  82. package/src/bridge/utils/fetch.ts +147 -0
  83. package/src/bridge/utils/heap.ts +118 -0
  84. package/src/bridge.ts +93 -0
  85. package/src/clipboard.ts +139 -0
  86. package/src/core-types.ts +595 -0
  87. package/src/find-on-page.ts +284 -0
  88. package/src/harness.ts +53 -0
  89. package/src/index.ts +40 -0
  90. package/src/open-canvas.ts +108 -0
  91. package/src/runtime-config.ts +96 -0
  92. package/src/semantic.ts +905 -0
@@ -0,0 +1,86 @@
1
+ import type {
2
+ BridgeRuntime,
3
+ ClipboardWritePayload,
4
+ EffinDomCallbacks,
5
+ PointerEventLog,
6
+ ScrollEventLog,
7
+ } from '../core-types';
8
+ import { enrichClipboardPayload, writeClipboardPayload } from '../clipboard';
9
+ import { createEditorSession, type EditorSession } from './interaction/editor-session';
10
+ import { createBridgeLogs } from './interaction/logs';
11
+ import { handleToString } from './utils/encoding';
12
+
13
+ export function installCallbacks(runtimeRef: { current: BridgeRuntime | null }): EditorSession {
14
+ const logs = createBridgeLogs();
15
+ const editorSession = createEditorSession(runtimeRef, logs);
16
+
17
+ const callbacks: EffinDomCallbacks = {
18
+ onPointerEvent: (handle, eventType) => {
19
+ const { x, y } = editorSession.getLastPointerPosition();
20
+ const modifiers = editorSession.getLastPointerModifiers();
21
+ const entry: PointerEventLog = { handle: handleToString(handle), eventType };
22
+ logs.pointerEvents.push(entry);
23
+ window.__effindomCallbacks?.onPointerEventWithCoords?.(
24
+ eventType,
25
+ handle,
26
+ x,
27
+ y,
28
+ modifiers,
29
+ );
30
+ },
31
+ onFocusChanged: editorSession.handleFocusChanged,
32
+ onTextChanged: editorSession.handleTextChanged,
33
+ onRequestSemanticAnnouncement: editorSession.handleRequestSemanticAnnouncement,
34
+ onTextReplaced: editorSession.handleTextReplaced,
35
+ onSelectionChanged: editorSession.handleSelectionChanged,
36
+ onScroll: (handle, offsetX, offsetY, contentWidth, contentHeight, viewportWidth, viewportHeight) => {
37
+ const entry: ScrollEventLog = {
38
+ handle: handleToString(handle),
39
+ offsetX,
40
+ offsetY,
41
+ contentWidth,
42
+ contentHeight,
43
+ viewportWidth,
44
+ viewportHeight,
45
+ };
46
+ logs.scrollEvents.push(entry);
47
+ },
48
+ onCrossSelectionChanged: (areaHandle, text) => {
49
+ logs.crossSelectionChanges.push({ areaHandle: handleToString(areaHandle), text });
50
+ },
51
+ onClipboardWrite: (payload: ClipboardWritePayload) => {
52
+ logs.clipboardWrites.push(payload.plainText);
53
+ const runtime = runtimeRef.current;
54
+ const enrichedPayload =
55
+ runtime === null
56
+ ? payload
57
+ : enrichClipboardPayload(payload, (fontId) => runtime.getClipboardFontUrl(fontId));
58
+ void writeClipboardPayload(enrichedPayload).catch(() => undefined);
59
+ },
60
+ onClipboardRead: editorSession.handleClipboardRead,
61
+ onRequestFontLoad: (fontId, url) => {
62
+ const runtime = runtimeRef.current;
63
+ if (runtime === null || url.length === 0) {
64
+ return;
65
+ }
66
+ void runtime.loadFont(fontId, url).catch((error: unknown) => {
67
+ window.__bridgeError = error instanceof Error ? error.message : String(error);
68
+ });
69
+ },
70
+ onMissingFontCoverage: (fontId, coverageKind, sampleText) => {
71
+ const runtime = runtimeRef.current;
72
+ if (runtime === null) {
73
+ return;
74
+ }
75
+ logs.missingFontCoverageRequests.push({
76
+ fontId,
77
+ coverageKind,
78
+ sampleText,
79
+ });
80
+ runtime.handleMissingFontCoverage(fontId, coverageKind, sampleText);
81
+ },
82
+ };
83
+
84
+ window.__effindomCallbacks = callbacks;
85
+ return editorSession;
86
+ }
@@ -0,0 +1,105 @@
1
+ import type { BridgeLoaderInfo, BridgeLogs } from '../core-types';
2
+
3
+ export type WasmArchitecture = 'auto' | 'wasm32' | 'wasm32-simd' | 'wasm64' | 'wasm64-simd';
4
+ export type RequestedRendererBackend = 'auto' | 'webgpu' | 'webgl2' | 'cpu';
5
+
6
+ export interface BundleAssetDescriptor {
7
+ readonly js: string;
8
+ readonly js_integrity?: string | null;
9
+ readonly wasm: string;
10
+ readonly wasm_integrity?: string | null;
11
+ }
12
+
13
+ export interface SharedAssetDescriptor {
14
+ readonly url: string;
15
+ readonly integrity?: string | null;
16
+ }
17
+
18
+ export interface RuntimeManifest {
19
+ readonly version: string;
20
+ readonly manifest_hash?: string | null;
21
+ readonly architectures: Partial<Record<Exclude<WasmArchitecture, 'auto'>, {
22
+ readonly core: BundleAssetDescriptor;
23
+ readonly ui: BundleAssetDescriptor;
24
+ }>>;
25
+ readonly assets?: {
26
+ readonly icu?: SharedAssetDescriptor;
27
+ };
28
+ }
29
+
30
+ export interface ArchitectureSelection {
31
+ readonly requestedArchitecture: WasmArchitecture;
32
+ readonly selectedArchitecture: Exclude<WasmArchitecture, 'auto'>;
33
+ readonly availableArchitectures: readonly string[];
34
+ readonly memory64Supported: boolean;
35
+ readonly simdSupported: boolean;
36
+ readonly selectionReason: string;
37
+ readonly manifestEntry: {
38
+ readonly core: BundleAssetDescriptor;
39
+ readonly ui: BundleAssetDescriptor;
40
+ };
41
+ }
42
+
43
+ export interface PreparedWasmAsset {
44
+ readonly url: string;
45
+ readonly integrity: string | null;
46
+ readonly bytesPromise: Promise<ArrayBuffer>;
47
+ readonly modulePromise: Promise<WebAssembly.Module>;
48
+ }
49
+
50
+ export interface PreparedBinaryAsset {
51
+ readonly url: string;
52
+ readonly integrity: string | null;
53
+ readonly bytesPromise: Promise<Uint8Array>;
54
+ }
55
+
56
+ export interface PreparedRuntimeAssets {
57
+ readonly manifest: RuntimeManifest;
58
+ readonly selection: ArchitectureSelection;
59
+ readonly loaderInfo: BridgeLoaderInfo;
60
+ readonly coreBundle: BundleAssetDescriptor;
61
+ readonly uiBundle: BundleAssetDescriptor;
62
+ readonly coreWasm: PreparedWasmAsset;
63
+ readonly uiWasm: PreparedWasmAsset;
64
+ readonly icu: PreparedBinaryAsset;
65
+ }
66
+
67
+ export interface BridgeInteractionState {
68
+ readonly logs: BridgeLogs;
69
+ readonly textByHandle: Record<string, string>;
70
+ readonly selectionsByHandle: Record<string, { start: number; end: number }>;
71
+ hasPendingTextMutations(): boolean;
72
+ materializePendingTextMutations(): boolean;
73
+ getActiveTextEditable(): boolean;
74
+ getActiveTextHandle(): bigint | null;
75
+ getActiveTextMultiline(): boolean;
76
+ getCapturedPointerHandle(): bigint | null;
77
+ getLastPointerClientPosition(): { x: number | null; y: number | null };
78
+ getLastPointerPosition(): { x: number; y: number };
79
+ getLastPointerModifiers(): number;
80
+ getLastInteractivePointerHandle(): bigint | null;
81
+ isActiveTextInputFocused(): boolean;
82
+ isPointerInsideCanvas(): boolean;
83
+ applyActiveTextDeletion(forward: boolean): boolean;
84
+ beginTouchTextFocusDeferral(handle: bigint): void;
85
+ cancelTouchTextFocusDeferral(): void;
86
+ commitTouchTextFocusDeferral(handle: bigint): void;
87
+ refocusActiveTextInput(): void;
88
+ resetAppSession(): void;
89
+ consumePendingSemanticAnnouncements(): readonly string[];
90
+ getFocusedHandle(): string | null;
91
+ setCapturedPointerHandle(handle: bigint | null): void;
92
+ setLastPointerClientPosition(x: number, y: number): void;
93
+ setLastPointerModifiers(modifiers: number): void;
94
+ setLastPointerPosition(x: number, y: number): void;
95
+ setLastInteractivePointerHandle(handle: bigint | null): void;
96
+ setPointerInsideCanvas(flag: boolean): void;
97
+ }
98
+
99
+ export interface SoftwarePresenter {
100
+ canvas: HTMLCanvasElement;
101
+ ctx: CanvasRenderingContext2D;
102
+ imageData: ImageData | null;
103
+ width: number;
104
+ height: number;
105
+ }
@@ -0,0 +1,68 @@
1
+ export enum PlatformFamily {
2
+ Unknown = 0,
3
+ Apple = 1,
4
+ Windows = 2,
5
+ Linux = 3,
6
+ }
7
+
8
+ export function isMobileBrowser(): boolean {
9
+ const navigatorWithUserAgentData = navigator as Navigator & {
10
+ userAgentData?: {
11
+ mobile?: boolean;
12
+ };
13
+ };
14
+ if (navigatorWithUserAgentData.userAgentData?.mobile === true) {
15
+ return true;
16
+ }
17
+ if (/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)) {
18
+ return true;
19
+ }
20
+ return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
21
+ }
22
+
23
+ export function detectPlatformFamily(): PlatformFamily {
24
+ const navigatorWithUserAgentData = navigator as Navigator & {
25
+ userAgentData?: {
26
+ platform?: string;
27
+ };
28
+ };
29
+ const platform = (
30
+ navigatorWithUserAgentData.userAgentData?.platform ??
31
+ navigator.platform ??
32
+ navigator.userAgent
33
+ ).toLowerCase();
34
+ if (
35
+ platform.includes('mac') ||
36
+ platform.includes('iphone') ||
37
+ platform.includes('ipad') ||
38
+ platform.includes('ipod') ||
39
+ platform.includes('ios')
40
+ ) {
41
+ return PlatformFamily.Apple;
42
+ }
43
+ if (platform.includes('win')) {
44
+ return PlatformFamily.Windows;
45
+ }
46
+ if (
47
+ platform.includes('linux') ||
48
+ platform.includes('android') ||
49
+ platform.includes('x11') ||
50
+ platform.includes('cros')
51
+ ) {
52
+ return PlatformFamily.Linux;
53
+ }
54
+ return PlatformFamily.Unknown;
55
+ }
56
+
57
+ export function isPlatformShortcutKey(
58
+ event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey'>,
59
+ platformFamily = detectPlatformFamily(),
60
+ ): boolean {
61
+ if (event.altKey) {
62
+ return false;
63
+ }
64
+ if (platformFamily === PlatformFamily.Apple) {
65
+ return event.metaKey && !event.ctrlKey;
66
+ }
67
+ return event.ctrlKey && !event.metaKey;
68
+ }
@@ -0,0 +1,41 @@
1
+ export class PointerMoveCoalescer<T> {
2
+ private pendingMove: T | null = null;
3
+ private frameScheduled = false;
4
+
5
+ constructor(private readonly flushMove: (move: T) => void) {}
6
+
7
+ enqueue(move: T): void {
8
+ this.pendingMove = move;
9
+ this.scheduleFlush();
10
+ }
11
+
12
+ takePending(): T | null {
13
+ const pending = this.pendingMove;
14
+ this.pendingMove = null;
15
+ return pending;
16
+ }
17
+
18
+ clear(): void {
19
+ this.pendingMove = null;
20
+ }
21
+
22
+ private scheduleFlush(): void {
23
+ if (this.frameScheduled) {
24
+ return;
25
+ }
26
+ this.frameScheduled = true;
27
+ requestAnimationFrame(() => {
28
+ this.frameScheduled = false;
29
+ const pending = this.pendingMove;
30
+ this.pendingMove = null;
31
+ if (pending === null) {
32
+ return;
33
+ }
34
+ this.flushMove(pending);
35
+ if (this.pendingMove !== null) {
36
+ this.scheduleFlush();
37
+ }
38
+ });
39
+ }
40
+ }
41
+
@@ -0,0 +1,124 @@
1
+ const PULL_TO_REFRESH_ID = 'effindom-pull-to-refresh';
2
+ const PULL_TO_REFRESH_TRIGGER_DISTANCE = 88;
3
+ const PULL_TO_REFRESH_MAX_TRAVEL = 78;
4
+
5
+ export const PULL_TO_REFRESH_THRESHOLD = PULL_TO_REFRESH_TRIGGER_DISTANCE;
6
+
7
+ interface PullToRefreshElements {
8
+ readonly root: HTMLDivElement;
9
+ readonly icon: HTMLSpanElement;
10
+ }
11
+
12
+ function ensurePullToRefreshElements(): PullToRefreshElements {
13
+ const existing = document.getElementById(PULL_TO_REFRESH_ID);
14
+ if (existing instanceof HTMLDivElement) {
15
+ const icon = existing.firstElementChild;
16
+ if (icon instanceof HTMLSpanElement) {
17
+ return { root: existing, icon };
18
+ }
19
+ existing.remove();
20
+ }
21
+
22
+ const root = document.createElement('div');
23
+ const icon = document.createElement('span');
24
+ root.id = PULL_TO_REFRESH_ID;
25
+ root.hidden = true;
26
+ root.dataset.visible = 'false';
27
+ root.dataset.armed = 'false';
28
+ root.setAttribute('aria-hidden', 'true');
29
+ root.style.position = 'fixed';
30
+ root.style.left = '50%';
31
+ root.style.top = '12px';
32
+ root.style.width = '48px';
33
+ root.style.height = '48px';
34
+ root.style.display = 'flex';
35
+ root.style.alignItems = 'center';
36
+ root.style.justifyContent = 'center';
37
+ root.style.borderRadius = '999px';
38
+ root.style.background = 'rgba(248, 250, 252, 0.96)';
39
+ root.style.color = '#0f172a';
40
+ root.style.boxShadow = '0 10px 28px rgba(15, 23, 42, 0.22)';
41
+ root.style.backdropFilter = 'blur(12px)';
42
+ root.style.pointerEvents = 'none';
43
+ root.style.opacity = '0';
44
+ root.style.transform = 'translate(-50%, -18px)';
45
+ root.style.transition = 'opacity 120ms ease, transform 120ms ease';
46
+ root.style.zIndex = '2147483647';
47
+
48
+ icon.textContent = '↻';
49
+ icon.style.display = 'block';
50
+ icon.style.font = '600 24px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
51
+ icon.style.transform = 'rotate(0deg)';
52
+ icon.style.transition = 'transform 90ms linear';
53
+ root.appendChild(icon);
54
+ (document.body ?? document.documentElement).appendChild(root);
55
+ return { root, icon };
56
+ }
57
+
58
+ export interface PullToRefreshOverlay {
59
+ show(pullDistance: number): void;
60
+ hide(immediate?: boolean): void;
61
+ destroy(): void;
62
+ }
63
+
64
+ export function createPullToRefreshOverlay(): PullToRefreshOverlay {
65
+ const { root, icon } = ensurePullToRefreshElements();
66
+ let visible = false;
67
+ let hideTimer = 0;
68
+
69
+ return {
70
+ show(pullDistance: number): void {
71
+ if (hideTimer !== 0) {
72
+ window.clearTimeout(hideTimer);
73
+ hideTimer = 0;
74
+ }
75
+ visible = true;
76
+ const distance = Math.max(0, pullDistance);
77
+ const normalized = Math.max(0, Math.min(distance / PULL_TO_REFRESH_TRIGGER_DISTANCE, 1));
78
+ const travel = Math.min(distance * 0.75, PULL_TO_REFRESH_MAX_TRAVEL);
79
+ root.hidden = false;
80
+ root.dataset.visible = 'true';
81
+ root.dataset.armed = normalized >= 1 ? 'true' : 'false';
82
+ root.style.transition = 'none';
83
+ root.style.opacity = distance <= 0 ? '0' : `${0.15 + (normalized * 0.85)}`;
84
+ root.style.transform = `translate(-50%, ${travel - 18}px)`;
85
+ root.style.background = normalized >= 1 ? 'rgba(219, 234, 254, 0.98)' : 'rgba(248, 250, 252, 0.96)';
86
+ icon.style.transform = `rotate(${Math.round(normalized * 360)}deg)`;
87
+ },
88
+ hide(immediate = false): void {
89
+ if (!visible && root.hidden) {
90
+ return;
91
+ }
92
+ visible = false;
93
+ root.dataset.visible = 'false';
94
+ root.dataset.armed = 'false';
95
+ root.style.transition = immediate
96
+ ? 'none'
97
+ : 'opacity 90ms ease-out, transform 90ms ease-out, background 90ms ease-out';
98
+ root.style.opacity = '0';
99
+ root.style.transform = 'translate(-50%, -18px)';
100
+ root.style.background = 'rgba(248, 250, 252, 0.96)';
101
+ icon.style.transform = 'rotate(0deg)';
102
+ if (immediate) {
103
+ if (hideTimer !== 0) {
104
+ window.clearTimeout(hideTimer);
105
+ hideTimer = 0;
106
+ }
107
+ root.hidden = true;
108
+ return;
109
+ }
110
+ hideTimer = window.setTimeout(() => {
111
+ if (root.dataset.visible === 'false') {
112
+ root.hidden = true;
113
+ }
114
+ hideTimer = 0;
115
+ }, 100);
116
+ },
117
+ destroy(): void {
118
+ if (hideTimer !== 0) {
119
+ window.clearTimeout(hideTimer);
120
+ }
121
+ root.remove();
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,268 @@
1
+ import { EdBackendType, EdDeviceState } from '../core-types';
2
+ import type { BridgeLoaderInfo, BridgeRuntime, EdBackendType as EdBackendTypeValue, CoreModule } from '../core-types';
3
+ import type { SoftwarePresenter } from './local-types';
4
+ import { normalizeBackendType, normalizeDeviceState, normalizePointerForWasm, pointerToHeapOffset } from './utils/encoding';
5
+ import { backendLabel, DEFAULT_BACKEND_LADDER, setActiveRenderer, tryReviveBackend } from './utils/backends';
6
+ import { delay } from './utils/assets';
7
+
8
+ const DEVICE_LOST_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000] as const;
9
+
10
+ function ensureSoftwarePresenter(
11
+ presenter: SoftwarePresenter | null,
12
+ canvas: HTMLCanvasElement,
13
+ ): SoftwarePresenter {
14
+ if (presenter !== null) {
15
+ return presenter;
16
+ }
17
+ const overlay = document.createElement('canvas');
18
+ overlay.dataset.effindomSoftwareOverlay = 'true';
19
+ overlay.setAttribute('aria-hidden', 'true');
20
+ overlay.style.position = 'absolute';
21
+ overlay.style.pointerEvents = 'none';
22
+ overlay.style.display = 'none';
23
+ overlay.style.zIndex = '1';
24
+
25
+ const parent = canvas.parentElement;
26
+ if (parent !== null) {
27
+ if (getComputedStyle(parent).position === 'static') {
28
+ parent.style.position = 'relative';
29
+ }
30
+ parent.appendChild(overlay);
31
+ } else {
32
+ document.body.appendChild(overlay);
33
+ }
34
+
35
+ const ctx = overlay.getContext('2d');
36
+ if (ctx === null) {
37
+ throw new Error('Canvas 2D context is unavailable for software rendering.');
38
+ }
39
+ return {
40
+ canvas: overlay,
41
+ ctx,
42
+ imageData: null,
43
+ width: 0,
44
+ height: 0,
45
+ };
46
+ }
47
+
48
+ function presentSoftwareFrame(
49
+ core: CoreModule,
50
+ canvas: HTMLCanvasElement,
51
+ presenter: SoftwarePresenter,
52
+ ): void {
53
+ const ptr = normalizePointerForWasm(core, core._ed_get_sw_framebuffer());
54
+ const offset = pointerToHeapOffset(ptr);
55
+ if (offset === 0) {
56
+ return;
57
+ }
58
+ presenter.canvas.style.left = `${String(canvas.offsetLeft)}px`;
59
+ presenter.canvas.style.top = `${String(canvas.offsetTop)}px`;
60
+ presenter.canvas.style.width = canvas.style.width || `${String(canvas.clientWidth)}px`;
61
+ presenter.canvas.style.height = canvas.style.height || `${String(canvas.clientHeight)}px`;
62
+ presenter.canvas.style.borderRadius = getComputedStyle(canvas).borderRadius;
63
+ presenter.canvas.style.display = '';
64
+ if (presenter.imageData === null || presenter.width !== canvas.width || presenter.height !== canvas.height) {
65
+ presenter.canvas.width = canvas.width;
66
+ presenter.canvas.height = canvas.height;
67
+ presenter.imageData = presenter.ctx.createImageData(canvas.width, canvas.height);
68
+ presenter.width = canvas.width;
69
+ presenter.height = canvas.height;
70
+ }
71
+ const byteLength = canvas.width * canvas.height * 4;
72
+ const src = core.HEAPU8.subarray(offset, offset + byteLength);
73
+ presenter.imageData.data.set(src);
74
+ presenter.ctx.putImageData(presenter.imageData, 0, 0);
75
+ }
76
+
77
+ export function installRenderLoop(
78
+ runtime: BridgeRuntime,
79
+ loaderInfo: BridgeLoaderInfo,
80
+ fallbackLadder: readonly EdBackendTypeValue[] = DEFAULT_BACKEND_LADDER,
81
+ ): () => void {
82
+ const { core, canvas } = runtime;
83
+ let activeBackend = normalizeBackendType(core._ed_get_backend_type());
84
+ let softwarePresenter: SoftwarePresenter | null = null;
85
+ let frameScheduled = false;
86
+ let disposed = false;
87
+
88
+ // Recovery state:
89
+ // - lastAttemptedBackend the backend we're trying to revive (set on first loss,
90
+ // kept after a permanent fallback so wake-up can try again)
91
+ // - recoveryAttempts how many retries of lastAttemptedBackend have been tried
92
+ // - recoveryExhausted true after all retries failed and we permanently fell back
93
+ // - recoveryPromise non-null while an async recovery cycle is in progress
94
+ let lastAttemptedBackend: EdBackendTypeValue = activeBackend;
95
+ let recoveryAttempts = 0;
96
+ let recoveryExhausted = false;
97
+ let recoveryPromise: Promise<void> | null = null;
98
+
99
+ // Retry the same backend with exponential backoff, then permanently fall back.
100
+ // Mutates recoveryAttempts, recoveryExhausted, activeBackend.
101
+ async function runRecovery(): Promise<void> {
102
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
103
+
104
+ while (recoveryAttempts < DEVICE_LOST_RETRY_DELAYS_MS.length) {
105
+ const delayMs = DEVICE_LOST_RETRY_DELAYS_MS[recoveryAttempts] as number;
106
+ await delay(delayMs);
107
+
108
+ if (await tryReviveBackend(core, canvas, dpr, lastAttemptedBackend)) {
109
+ recoveryAttempts = 0;
110
+ recoveryExhausted = false;
111
+ activeBackend = lastAttemptedBackend;
112
+ loaderInfo.deviceRecoveryCount += 1;
113
+ setActiveRenderer(loaderInfo, lastAttemptedBackend);
114
+ await runtime.replayLoadedAssets();
115
+ console.info(`RENDERER RECOVERY: ${backendLabel(lastAttemptedBackend)} recovered successfully.`);
116
+ runtime.commitFrame();
117
+ return;
118
+ }
119
+
120
+ recoveryAttempts += 1;
121
+ }
122
+
123
+ // All retries exhausted — permanently fall to the next available backend.
124
+ recoveryExhausted = true;
125
+ const nextIndex = fallbackLadder.indexOf(lastAttemptedBackend) + 1;
126
+ for (let i = nextIndex; i < fallbackLadder.length; i += 1) {
127
+ const fallback = fallbackLadder[i]!;
128
+ if (await tryReviveBackend(core, canvas, dpr, fallback)) {
129
+ activeBackend = fallback;
130
+ loaderInfo.deviceRecoveryCount += 1;
131
+ setActiveRenderer(loaderInfo, fallback);
132
+ await runtime.replayLoadedAssets();
133
+ if (fallback === EdBackendType.CPU) {
134
+ console.error(
135
+ `RENDERER FALLBACK: ${backendLabel(lastAttemptedBackend)} device lost and recovery failed!` +
136
+ ' Fell back to Software/Raster - performance will be painfully slow',
137
+ );
138
+ } else {
139
+ console.warn(
140
+ `RENDERER FALLBACK: ${backendLabel(lastAttemptedBackend)} device lost and recovery failed!` +
141
+ ` Fell back to ${backendLabel(fallback)}`,
142
+ );
143
+ }
144
+ runtime.commitFrame();
145
+ return;
146
+ }
147
+ }
148
+
149
+ throw new Error(
150
+ `Renderer recovery failed: all backends exhausted after ${backendLabel(lastAttemptedBackend)} device loss.`,
151
+ );
152
+ }
153
+
154
+ function scheduleRecovery(lostBackend: EdBackendTypeValue): void {
155
+ if (recoveryPromise !== null) return;
156
+ lastAttemptedBackend = lostBackend;
157
+ recoveryAttempts = 0;
158
+ recoveryExhausted = false;
159
+ setActiveRenderer(loaderInfo, EdBackendType.NONE);
160
+ recoveryPromise = runRecovery()
161
+ .catch((error: unknown) => {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ window.__bridgeError = message;
164
+ })
165
+ .finally(() => {
166
+ recoveryPromise = null;
167
+ });
168
+ }
169
+
170
+ const scheduleFrame = (): void => {
171
+ if (disposed) {
172
+ return;
173
+ }
174
+ if (frameScheduled) {
175
+ return;
176
+ }
177
+ frameScheduled = true;
178
+ requestAnimationFrame(frame);
179
+ };
180
+
181
+ runtime.setFrameRequester(scheduleFrame);
182
+
183
+ const handleWebGlContextLost = (event: Event): void => {
184
+ event.preventDefault();
185
+ core._ed_notify_webgl_context_lost?.();
186
+ scheduleRecovery(activeBackend);
187
+ scheduleFrame();
188
+ };
189
+ canvas.addEventListener('webglcontextlost', handleWebGlContextLost, false);
190
+
191
+ // When the page becomes visible after being hidden (e.g. lid open after sleep),
192
+ // the GPU may have recovered. If a previous recovery cycle exhausted all retries
193
+ // and permanently fell back, optimistically try to revive the original backend.
194
+ const handleVisibilityChange = (): void => {
195
+ if (disposed) return;
196
+ if (document.visibilityState !== 'visible') return;
197
+ if (!recoveryExhausted) return;
198
+ if (recoveryPromise !== null) return;
199
+ console.info(
200
+ `RENDERER RECOVERY: Page visible — attempting to revive ${backendLabel(lastAttemptedBackend)} after sleep/wake.`,
201
+ );
202
+ recoveryAttempts = 0;
203
+ recoveryPromise = runRecovery()
204
+ .catch((error: unknown) => {
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ window.__bridgeError = message;
207
+ })
208
+ .finally(() => {
209
+ recoveryPromise = null;
210
+ });
211
+ };
212
+ document.addEventListener('visibilitychange', handleVisibilityChange);
213
+
214
+ const frame = (now: number): void => {
215
+ if (disposed) {
216
+ frameScheduled = false;
217
+ return;
218
+ }
219
+ frameScheduled = false;
220
+ if (recoveryPromise !== null) {
221
+ scheduleFrame();
222
+ return;
223
+ }
224
+ // Rebuild heap views from the live WebAssembly.Memory buffer each frame.
225
+ // Emscripten updates closure-scoped heap vars on memory growth but not
226
+ // Module['HEAPU8'], leaving it pointing to a detached ArrayBuffer.
227
+ // Reading module.wasmMemory.buffer always returns the current live buffer.
228
+ core.refreshHeapViews?.();
229
+ runtime.ui.refreshHeapViews?.();
230
+ runtime.runAppFrameHandler(now);
231
+ if (!runtime.hasPendingCommit() && runtime.uiNeedsAnimationFrame() && runtime.uiHasPendingVisualWork()) {
232
+ runtime.commitFrame();
233
+ }
234
+ runtime.flushPendingCommit();
235
+ core._ed_render_frame(now);
236
+ core.refreshHeapViews?.(); // Ensure heap views are up‑to‑date after potential memory growth
237
+ const deviceState = normalizeDeviceState(core._ed_get_device_state());
238
+ const backendType = normalizeBackendType(core._ed_get_backend_type());
239
+ if (deviceState === EdDeviceState.LOST) {
240
+ scheduleRecovery(activeBackend);
241
+ scheduleFrame();
242
+ return;
243
+ }
244
+ activeBackend = backendType;
245
+ setActiveRenderer(loaderInfo, backendType);
246
+ if (backendType === EdBackendType.CPU) {
247
+ softwarePresenter = ensureSoftwarePresenter(softwarePresenter, canvas);
248
+ presentSoftwareFrame(core, canvas, softwarePresenter);
249
+ } else if (softwarePresenter !== null) {
250
+ softwarePresenter.canvas.style.display = 'none';
251
+ }
252
+ if (runtime.hasPendingCommit() || runtime.uiNeedsAnimationFrame()) {
253
+ scheduleFrame();
254
+ }
255
+ };
256
+ scheduleFrame();
257
+ return () => {
258
+ disposed = true;
259
+ frameScheduled = false;
260
+ runtime.setFrameRequester(null);
261
+ canvas.removeEventListener('webglcontextlost', handleWebGlContextLost, false);
262
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
263
+ if (softwarePresenter !== null) {
264
+ softwarePresenter.canvas.remove();
265
+ softwarePresenter = null;
266
+ }
267
+ };
268
+ }