@mirage-engine/core 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/dist/mirage-engine.js +487 -0
- package/dist/mirage-engine.umd.js +55 -0
- package/dist/src/core/Engine.d.ts +11 -0
- package/dist/src/core/Syncer.d.ts +20 -0
- package/dist/src/dom/Extractor.d.ts +3 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/renderer/Renderer.d.ts +27 -0
- package/dist/src/types/common.d.ts +20 -0
- package/dist/src/types/config.d.ts +18 -0
- package/dist/src/types/flags.d.ts +6 -0
- package/dist/src/types/index.d.ts +3 -0
- package/dist/src/utils/math.d.ts +13 -0
- package/dist/vite.config.d.ts +2 -0
- package/package.json +26 -0
- package/src/core/Engine.ts +44 -0
- package/src/core/Syncer.ts +148 -0
- package/src/dom/Extractor.ts +153 -0
- package/src/index.ts +2 -0
- package/src/renderer/Renderer.ts +308 -0
- package/src/types/common.ts +28 -0
- package/src/types/config.ts +21 -0
- package/src/types/flags.ts +7 -0
- package/src/types/index.ts +3 -0
- package/src/utils/math.ts +23 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SceneNode, CoreConfig } from '../types';
|
|
2
|
+
|
|
3
|
+
export declare class Renderer {
|
|
4
|
+
readonly canvas: HTMLCanvasElement;
|
|
5
|
+
private readonly scene;
|
|
6
|
+
private readonly camera;
|
|
7
|
+
private readonly renderer;
|
|
8
|
+
private renderOrder;
|
|
9
|
+
private textQualityFactor;
|
|
10
|
+
private mode;
|
|
11
|
+
private customZIndex;
|
|
12
|
+
private target;
|
|
13
|
+
private mountContainer;
|
|
14
|
+
private targetRect;
|
|
15
|
+
private meshMap;
|
|
16
|
+
constructor(target: HTMLElement, config: CoreConfig, mountContainer: HTMLElement);
|
|
17
|
+
private applyTextQuality;
|
|
18
|
+
mount(): void;
|
|
19
|
+
private updateCanvasLayout;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
setSize(width: number, height: number): void;
|
|
22
|
+
syncScene(graphNode: SceneNode): void;
|
|
23
|
+
private reconcileNode;
|
|
24
|
+
private reconcileTextChild;
|
|
25
|
+
private updateMeshProperties;
|
|
26
|
+
render(): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TextStyles, BoxStyles } from '../../packages/painter/src/index.ts';
|
|
2
|
+
|
|
3
|
+
export type NodeType = "BOX" | "TEXT";
|
|
4
|
+
export interface NodeRect {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SceneNode {
|
|
11
|
+
id: string;
|
|
12
|
+
type: NodeType;
|
|
13
|
+
element: HTMLElement;
|
|
14
|
+
rect: NodeRect;
|
|
15
|
+
styles: BoxStyles;
|
|
16
|
+
textContent?: string;
|
|
17
|
+
textStyles?: TextStyles;
|
|
18
|
+
dirtyMask: number;
|
|
19
|
+
children: SceneNode[];
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type TextQuality = "low" | "medium" | "high" | number;
|
|
2
|
+
export type MirageMode = "overlay" | "duplicate";
|
|
3
|
+
interface BaseConfig {
|
|
4
|
+
debug?: boolean;
|
|
5
|
+
textQuality?: TextQuality;
|
|
6
|
+
style?: {
|
|
7
|
+
zIndex?: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export interface OverlayConfig extends BaseConfig {
|
|
11
|
+
mode?: "overlay";
|
|
12
|
+
}
|
|
13
|
+
export interface DuplicateConfig extends BaseConfig {
|
|
14
|
+
mode: "duplicate";
|
|
15
|
+
container?: HTMLElement;
|
|
16
|
+
}
|
|
17
|
+
export type CoreConfig = OverlayConfig | DuplicateConfig;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts DOM coordinates (Top-Left 0,0) to WebGL coordinates (Center 0,0).
|
|
3
|
+
* @param x - The x coordinate of the DOM element (Left)
|
|
4
|
+
* @param y - The y coordinate of the DOM element (Top)
|
|
5
|
+
* @param width - The width of the DOM element
|
|
6
|
+
* @param height - The height of the DOM element
|
|
7
|
+
* @param canvasWidth - The total width of the canvas
|
|
8
|
+
* @param canvasHeight - The total height of the canvas
|
|
9
|
+
*/
|
|
10
|
+
export declare function domToWebGL(x: number, y: number, width: number, height: number, canvasWidth: number, canvasHeight: number): {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mirage-engine/core",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"three": "^0.160.0"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/dltldn333/MirageEngine",
|
|
12
|
+
"directory": "packages/core"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@types/three": "^0.181.0",
|
|
16
|
+
"@mirage-engine/painter": "0.2.3"
|
|
17
|
+
},
|
|
18
|
+
"private": false,
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "vite build"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CoreConfig } from "../types";
|
|
2
|
+
import { Renderer } from "../renderer/Renderer";
|
|
3
|
+
import { Syncer } from "./Syncer";
|
|
4
|
+
|
|
5
|
+
export class Engine {
|
|
6
|
+
private renderer: Renderer;
|
|
7
|
+
private syncer: Syncer;
|
|
8
|
+
private target: HTMLElement;
|
|
9
|
+
|
|
10
|
+
constructor(target: HTMLElement, config: CoreConfig) {
|
|
11
|
+
this.target = target;
|
|
12
|
+
|
|
13
|
+
let mountContainer: HTMLElement | undefined;
|
|
14
|
+
|
|
15
|
+
if (config.mode === "duplicate") {
|
|
16
|
+
mountContainer =
|
|
17
|
+
config.container ?? this.target.parentElement ?? undefined;
|
|
18
|
+
} else {
|
|
19
|
+
mountContainer = this.target.parentElement ?? undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!mountContainer) {
|
|
23
|
+
throw new Error("[Mirage] Cannot find a container (parent or option).");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.renderer = new Renderer(this.target, config, mountContainer);
|
|
27
|
+
|
|
28
|
+
this.renderer.mount();
|
|
29
|
+
|
|
30
|
+
this.syncer = new Syncer(this.target, this.renderer);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public start() {
|
|
34
|
+
this.syncer.start();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public stop() {
|
|
38
|
+
this.syncer.stop();
|
|
39
|
+
}
|
|
40
|
+
public dispose() {
|
|
41
|
+
this.syncer.stop();
|
|
42
|
+
this.renderer.dispose();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Renderer } from "../renderer/Renderer";
|
|
2
|
+
import { extractSceneGraph } from "../dom/Extractor";
|
|
3
|
+
import {
|
|
4
|
+
DIRTY_NONE,
|
|
5
|
+
DIRTY_RECT,
|
|
6
|
+
DIRTY_STRUCTURE,
|
|
7
|
+
DIRTY_STYLE,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
export class Syncer {
|
|
11
|
+
private target: HTMLElement;
|
|
12
|
+
private renderer: Renderer;
|
|
13
|
+
private observer: MutationObserver;
|
|
14
|
+
|
|
15
|
+
private isDomDirty: boolean = false;
|
|
16
|
+
private isRunning: boolean = false;
|
|
17
|
+
|
|
18
|
+
private pendingMask: number = DIRTY_NONE;
|
|
19
|
+
|
|
20
|
+
private mutationTimer: number | null = null;
|
|
21
|
+
private cssTimer: number | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(target: HTMLElement, renderer: Renderer) {
|
|
24
|
+
this.target = target;
|
|
25
|
+
this.renderer = renderer;
|
|
26
|
+
|
|
27
|
+
this.observer = new MutationObserver((mutations) => {
|
|
28
|
+
let currentMask = DIRTY_NONE;
|
|
29
|
+
|
|
30
|
+
for (const mutation of mutations) {
|
|
31
|
+
if (mutation.type === "childList") {
|
|
32
|
+
currentMask |= DIRTY_STRUCTURE;
|
|
33
|
+
} else if (mutation.type === "attributes") {
|
|
34
|
+
if (
|
|
35
|
+
mutation.attributeName === "style" ||
|
|
36
|
+
mutation.attributeName === "class"
|
|
37
|
+
) {
|
|
38
|
+
currentMask |= DIRTY_RECT | DIRTY_STYLE;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (currentMask !== DIRTY_NONE) {
|
|
44
|
+
this.pendingMask |= currentMask;
|
|
45
|
+
|
|
46
|
+
// Structural Change detected
|
|
47
|
+
if (currentMask & DIRTY_STRUCTURE) {
|
|
48
|
+
this.clearTimers();
|
|
49
|
+
console.log("Structural Change detected");
|
|
50
|
+
this.isDomDirty = true;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (this.mutationTimer) {
|
|
54
|
+
clearTimeout(this.mutationTimer);
|
|
55
|
+
}
|
|
56
|
+
this.mutationTimer = window.setTimeout(() => {
|
|
57
|
+
this.mutationTimer = null;
|
|
58
|
+
this.isDomDirty = true;
|
|
59
|
+
}, 200);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public start() {
|
|
65
|
+
if (this.isRunning) return;
|
|
66
|
+
this.isRunning = true;
|
|
67
|
+
|
|
68
|
+
//DOM observing start
|
|
69
|
+
this.observer.observe(this.target, {
|
|
70
|
+
childList: true,
|
|
71
|
+
subtree: true,
|
|
72
|
+
attributes: true,
|
|
73
|
+
characterData: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.target.addEventListener("transitionend", this.onTransitionFinished);
|
|
77
|
+
this.target.addEventListener("animationend", this.onTransitionFinished);
|
|
78
|
+
|
|
79
|
+
window.addEventListener("resize", this.onWindowResize);
|
|
80
|
+
|
|
81
|
+
this.forceUpdateScene();
|
|
82
|
+
this.renderLoop();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public stop() {
|
|
86
|
+
this.isRunning = false;
|
|
87
|
+
this.observer.disconnect();
|
|
88
|
+
|
|
89
|
+
this.clearTimers();
|
|
90
|
+
|
|
91
|
+
this.target.removeEventListener("transitionend", this.onTransitionFinished);
|
|
92
|
+
this.target.removeEventListener("animationend", this.onTransitionFinished);
|
|
93
|
+
window.removeEventListener("resize", this.onWindowResize);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private clearTimers() {
|
|
97
|
+
if (this.mutationTimer) {
|
|
98
|
+
clearTimeout(this.mutationTimer);
|
|
99
|
+
this.mutationTimer = null;
|
|
100
|
+
}
|
|
101
|
+
if (this.cssTimer) {
|
|
102
|
+
clearTimeout(this.cssTimer);
|
|
103
|
+
this.cssTimer = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private onTransitionFinished = (e: Event) => {
|
|
108
|
+
if (!this.target.contains(e.target as Node)) return;
|
|
109
|
+
|
|
110
|
+
if (this.mutationTimer !== null) return;
|
|
111
|
+
|
|
112
|
+
if (this.cssTimer) clearTimeout(this.cssTimer);
|
|
113
|
+
|
|
114
|
+
this.pendingMask |= DIRTY_RECT | DIRTY_STYLE;
|
|
115
|
+
|
|
116
|
+
this.cssTimer = window.setTimeout(() => {
|
|
117
|
+
this.isDomDirty = true;
|
|
118
|
+
this.cssTimer = null;
|
|
119
|
+
}, 50);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
private onWindowResize = () => {
|
|
123
|
+
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
124
|
+
this.isDomDirty = true;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
private forceUpdateScene() {
|
|
128
|
+
this.isDomDirty = false;
|
|
129
|
+
const sceneGraph = extractSceneGraph(this.target, this.pendingMask);
|
|
130
|
+
|
|
131
|
+
if (sceneGraph) {
|
|
132
|
+
this.renderer.syncScene(sceneGraph);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.pendingMask = DIRTY_NONE;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private renderLoop = () => {
|
|
139
|
+
if (!this.isRunning) return;
|
|
140
|
+
|
|
141
|
+
if (this.isDomDirty) {
|
|
142
|
+
this.forceUpdateScene();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.renderer.render();
|
|
146
|
+
requestAnimationFrame(this.renderLoop);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DIRTY_RECT,
|
|
3
|
+
DIRTY_STYLE,
|
|
4
|
+
DIRTY_CONTENT,
|
|
5
|
+
DIRTY_ZINDEX,
|
|
6
|
+
DIRTY_STRUCTURE,
|
|
7
|
+
SceneNode,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
import { BoxStyles, TextStyles } from "@mirage-engine/painter";
|
|
11
|
+
|
|
12
|
+
// Helper function: getTextNodeRect, isValidTextNode, isLeafTextElement, extractTextStyles
|
|
13
|
+
|
|
14
|
+
function getTextNodeRect(textNode: Text) {
|
|
15
|
+
const range = document.createRange();
|
|
16
|
+
range.selectNodeContents(textNode);
|
|
17
|
+
const rect = range.getBoundingClientRect();
|
|
18
|
+
return {
|
|
19
|
+
left: rect.left,
|
|
20
|
+
top: rect.top,
|
|
21
|
+
width: rect.width,
|
|
22
|
+
height: rect.height,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractTextStyles(computed: CSSStyleDeclaration): TextStyles {
|
|
27
|
+
const fontSize = parseFloat(computed.fontSize);
|
|
28
|
+
let lineHeight = parseFloat(computed.lineHeight);
|
|
29
|
+
if (isNaN(lineHeight)) {
|
|
30
|
+
lineHeight = fontSize * 1.2;
|
|
31
|
+
}
|
|
32
|
+
let letterSpacing = parseFloat(computed.letterSpacing);
|
|
33
|
+
if (isNaN(letterSpacing)) {
|
|
34
|
+
letterSpacing = 0;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
font: `${computed.fontStyle} ${computed.fontWeight} ${computed.fontSize} ${computed.fontFamily}`,
|
|
38
|
+
color: computed.color,
|
|
39
|
+
textAlign: (computed.textAlign as CanvasTextAlign) || "start",
|
|
40
|
+
textBaseline: "alphabetic",
|
|
41
|
+
direction: (computed.direction as CanvasDirection) || "inherit",
|
|
42
|
+
lineHeight,
|
|
43
|
+
letterSpacing,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function extractSceneGraph(
|
|
48
|
+
sourceNode: HTMLElement | Node,
|
|
49
|
+
initialMask = DIRTY_RECT |
|
|
50
|
+
DIRTY_STYLE |
|
|
51
|
+
DIRTY_ZINDEX |
|
|
52
|
+
DIRTY_CONTENT |
|
|
53
|
+
DIRTY_STRUCTURE
|
|
54
|
+
): SceneNode | null {
|
|
55
|
+
// Check text node
|
|
56
|
+
if (sourceNode.nodeType === Node.TEXT_NODE) {
|
|
57
|
+
const textNode = sourceNode as Text;
|
|
58
|
+
|
|
59
|
+
// empthy text check
|
|
60
|
+
if (!textNode.textContent || !textNode.textContent.trim()) return null;
|
|
61
|
+
const normalizedText = textNode.textContent.replace(/\s+/g, " ").trim();
|
|
62
|
+
if (normalizedText.length === 0) return null;
|
|
63
|
+
|
|
64
|
+
const rect = getTextNodeRect(textNode);
|
|
65
|
+
|
|
66
|
+
// size check
|
|
67
|
+
if (rect.width === 0 || rect.height === 0) return null;
|
|
68
|
+
|
|
69
|
+
// Cascading Styles
|
|
70
|
+
const parent = textNode.parentElement;
|
|
71
|
+
const computed = parent ? window.getComputedStyle(parent) : null;
|
|
72
|
+
if (!computed) return null;
|
|
73
|
+
|
|
74
|
+
// Create SceneNode for text node
|
|
75
|
+
return {
|
|
76
|
+
id: Math.random().toString(36).substring(2, 9),
|
|
77
|
+
type: "TEXT",
|
|
78
|
+
element: textNode as unknown as HTMLElement,
|
|
79
|
+
rect: {
|
|
80
|
+
x: rect.left + window.scrollX,
|
|
81
|
+
y: rect.top + window.scrollY,
|
|
82
|
+
width: rect.width,
|
|
83
|
+
height: rect.height,
|
|
84
|
+
},
|
|
85
|
+
styles: {
|
|
86
|
+
backgroundColor: "transparent",
|
|
87
|
+
opacity: parseFloat(computed.opacity),
|
|
88
|
+
zIndex: 0,
|
|
89
|
+
borderRadius: "0px",
|
|
90
|
+
borderColor: "transparent",
|
|
91
|
+
borderWidth: "0px",
|
|
92
|
+
},
|
|
93
|
+
textContent: normalizedText,
|
|
94
|
+
textStyles: extractTextStyles(computed),
|
|
95
|
+
dirtyMask: initialMask,
|
|
96
|
+
children: [],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const element = sourceNode as HTMLElement;
|
|
101
|
+
const rect = element.getBoundingClientRect();
|
|
102
|
+
const computed = window.getComputedStyle(element);
|
|
103
|
+
|
|
104
|
+
// Base Case
|
|
105
|
+
if (rect.width === 0 || rect.height === 0 || computed.display === "none") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let id = element.getAttribute("data-mid");
|
|
110
|
+
if (!id) {
|
|
111
|
+
id = Math.random().toString(36).substring(2, 11);
|
|
112
|
+
element.setAttribute("data-mid", id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const zIndex = parseInt(computed.zIndex);
|
|
116
|
+
const styles: BoxStyles = {
|
|
117
|
+
backgroundColor: computed.backgroundColor,
|
|
118
|
+
opacity: parseFloat(computed.opacity),
|
|
119
|
+
zIndex: isNaN(zIndex) ? 0 : zIndex,
|
|
120
|
+
borderRadius: computed.borderRadius,
|
|
121
|
+
borderColor: computed.borderColor,
|
|
122
|
+
borderWidth: computed.borderWidth,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let textContent: string | undefined;
|
|
126
|
+
let textStyles: TextStyles | undefined;
|
|
127
|
+
const children: SceneNode[] = [];
|
|
128
|
+
|
|
129
|
+
Array.from(element.childNodes).forEach((child) => {
|
|
130
|
+
// Recurring
|
|
131
|
+
const childNode = extractSceneGraph(child, initialMask);
|
|
132
|
+
if (childNode) {
|
|
133
|
+
children.push(childNode);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
type: "BOX",
|
|
140
|
+
element,
|
|
141
|
+
rect: {
|
|
142
|
+
x: rect.left + window.scrollX,
|
|
143
|
+
y: rect.top + window.scrollY,
|
|
144
|
+
width: rect.width,
|
|
145
|
+
height: rect.height,
|
|
146
|
+
},
|
|
147
|
+
styles,
|
|
148
|
+
textContent,
|
|
149
|
+
textStyles,
|
|
150
|
+
dirtyMask: initialMask,
|
|
151
|
+
children,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/index.ts
ADDED