@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.
@@ -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,6 @@
1
+ export declare const DIRTY_NONE = 0;
2
+ export declare const DIRTY_RECT: number;
3
+ export declare const DIRTY_STYLE: number;
4
+ export declare const DIRTY_ZINDEX: number;
5
+ export declare const DIRTY_STRUCTURE: number;
6
+ export declare const DIRTY_CONTENT: number;
@@ -0,0 +1,3 @@
1
+ export * from './config';
2
+ export * from './common';
3
+ export * from './flags';
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ declare const _default: import('vite').UserConfig;
2
+ export default _default;
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
@@ -0,0 +1,2 @@
1
+ export { Engine} from "./core/Engine";
2
+ export * from "./types";