/* ============================================================================= * B07_DesignDetail_Node_Dom.ts * 서버 Node 진입점이 쓰는 **최소 DOM** — 덧그림 그리기 코드가 오프스크린 SVG 에 쓰는 만큼만. * 불러오기만 하면 `globalThis.document` 가 선다(`_Cross_Extent_Node` · 인쇄 302-2 `B09_Draft_Print_Drawings_Node`). * ========================================================================== */ /* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */ type Child = FakeElement | string; /** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */ function matches(element: FakeElement, selectors: string): boolean { return selectors.split(",").some((raw) => { const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim()); if (!found) return false; const [, tag, attribute] = found; if (tag && element.tagName !== tag) return false; return !attribute || element.attributes.has(attribute); }); } class FakeElement { readonly attributes = new Map(); readonly childNodes: Child[] = []; parentNode: FakeElement | null = null; readonly dataset: Record = {}; readonly style: Record = { setProperty: () => undefined }; readonly classList = { add: (...names: string[]) => this.setClasses([...this.classes(), ...names]), remove: (...names: string[]) => this.setClasses(this.classes().filter((name) => !names.includes(name))), toggle: (name: string, force?: boolean) => { const on = force ?? !this.classes().includes(name); this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name)); return on; }, contains: (name: string) => this.classes().includes(name), }; constructor(readonly tagName: string) {} get ownerDocument(): typeof fakeDocument { return fakeDocument; } get parentElement(): FakeElement | null { return this.parentNode; } get children(): FakeElement[] { return this.childNodes.filter((child): child is FakeElement => typeof child !== "string"); } get firstChild(): Child | null { return this.childNodes[0] ?? null; } get lastChild(): Child | null { return this.childNodes[this.childNodes.length - 1] ?? null; } get textContent(): string { return this.childNodes .map((child) => (typeof child === "string" ? child : child.textContent)) .join(""); } set textContent(value: string | null) { this.replaceChildren(...(value ? [String(value)] : [])); } private classes(): string[] { return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean); } private setClasses(names: string[]): void { this.attributes.set("class", [...new Set(names)].join(" ")); } private adopt(child: Child): Child { if (typeof child !== "string") { child.parentNode?.removeChild(child); child.parentNode = this; } return child; } setAttribute(name: string, value: unknown): void { this.attributes.set(name, String(value)); } setAttributeNS(_ns: string | null, name: string, value: unknown): void { this.setAttribute(name, value); } getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; } hasAttribute(name: string): boolean { return this.attributes.has(name); } removeAttribute(name: string): void { this.attributes.delete(name); } append(...nodes: Child[]): void { for (const node of nodes) this.childNodes.push(this.adopt(node)); } appendChild(node: T): T { this.append(node); return node; } prepend(...nodes: Child[]): void { this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); } insertBefore(node: T, reference: Child | null): T { const index = reference ? this.childNodes.indexOf(reference) : -1; if (index < 0) return this.appendChild(node); this.childNodes.splice(index, 0, this.adopt(node)); return node; } removeChild(node: T): T { const index = this.childNodes.indexOf(node); if (index >= 0) this.childNodes.splice(index, 1); if (typeof node !== "string") node.parentNode = null; return node; } replaceChildren(...nodes: Child[]): void { for (const child of [...this.childNodes]) this.removeChild(child); this.append(...nodes); } remove(): void { this.parentNode?.removeChild(this); } closest(selectors: string): FakeElement | null { for (let node: FakeElement | null = this; node; node = node.parentNode) { if (matches(node, selectors)) return node; } return null; } querySelectorAll(selectors: string): FakeElement[] { const found: FakeElement[] = []; const walk = (node: FakeElement): void => { for (const child of node.children) { if (matches(child, selectors)) found.push(child); walk(child); } }; walk(this); return found; } querySelector(selectors: string): FakeElement | null { return this.querySelectorAll(selectors)[0] ?? null; } addEventListener(): void {} removeEventListener(): void {} } const fakeDocument = { createElementNS: (_ns: string, tag: string) => new FakeElement(tag), createElement: (tag: string) => new FakeElement(tag), createTextNode: (text: string) => text, // 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름). getElementById: () => null, // 화면 모듈이 불러올 때 거는 문서 이벤트(누름 등) — 서버에는 사람이 없어 빈 함수 addEventListener: () => undefined, removeEventListener: () => undefined, }; (globalThis as Record).document = fakeDocument; export {}; // 모듈로 둠 — 이름이 전역에 새지 않게