39 lines
3.3 KiB
JavaScript
39 lines
3.3 KiB
JavaScript
/**
|
|
* hal/ui/public/dom-patch.mjs — 화면 부분 갱신(0.3.17, 개발 지시 v0.3.17 §3). 주기 갱신이 화면 전체를 innerHTML 로 바꾸면 번쩍이고 선택·포커스·스크롤이 깨진다.
|
|
* 새 HTML 을 최상위 노드 단위로 나누어 바뀐 노드만 바꾼다. 순수 함수(splitTopLevel·diffChildren)는 Node 테스트가 검사하고, patchHtml 만 DOM 을 쓴다.
|
|
*/
|
|
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
/** HTML 문자열을 최상위 노드(요소·글) 문자열 배열로 나눈다. 깊이를 세어 요소 경계를 찾는다(스크립트·주석은 쓰지 않는 화면용). */
|
|
export function splitTopLevel(html) {
|
|
const s = String(html || ''); const out = []; let i = 0; let depth = 0; let start = 0; let textStart = 0;
|
|
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g;
|
|
while (i < s.length) {
|
|
tagRe.lastIndex = i; const m = tagRe.exec(s); if (!m) break;
|
|
if (depth === 0 && m.index > textStart) { const t = s.slice(textStart, m.index); if (t.trim()) out.push(t); }
|
|
const closing = m[0][1] === '/'; const name = m[1].toLowerCase(); const selfClose = m[2] === '/' || VOID.has(name);
|
|
if (!closing) { if (depth === 0) start = m.index; if (!selfClose) depth++; if (depth === 0 && selfClose) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
|
else { depth = Math.max(0, depth - 1); if (depth === 0) { out.push(s.slice(start, m.index + m[0].length)); textStart = m.index + m[0].length; } }
|
|
i = m.index + m[0].length;
|
|
}
|
|
if (depth === 0 && textStart < s.length) { const t = s.slice(textStart); if (t.trim()) out.push(t); } else if (depth > 0) out.push(s.slice(start));
|
|
return out;
|
|
}
|
|
/** 옛 노드 문자열 배열 → 새 배열로 바꾸는 최소 작업 목록: replace(오름차순) → append(오름차순) → remove(내림차순). */
|
|
export function diffChildren(oldList, newList) {
|
|
const ops = []; const n = Math.min(oldList.length, newList.length);
|
|
for (let i = 0; i < n; i++) if (oldList[i] !== newList[i]) ops.push({ i, op: 'replace', html: newList[i] });
|
|
for (let i = n; i < newList.length; i++) ops.push({ i, op: 'append', html: newList[i] });
|
|
for (let i = oldList.length - 1; i >= n; i--) ops.push({ i, op: 'remove' });
|
|
return ops;
|
|
}
|
|
const nodeHtml = (n) => (n.nodeType === 1 ? n.outerHTML : n.textContent);
|
|
/** 컨테이너의 자식을 새 HTML 로 맞춘다(바뀐 노드만). 같은 HTML 이면 아무것도 하지 않는다. 브라우저 전용. */
|
|
export function patchHtml(el, html) {
|
|
if (!el) return false; if (el.__lastHtml === html) return false;
|
|
const tpl = document.createElement('template'); tpl.innerHTML = html; const fresh = [...tpl.content.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
|
const cur = [...el.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
|
const ops = diffChildren(cur.map(nodeHtml), fresh.map(nodeHtml));
|
|
for (const o of ops) { if (o.op === 'replace') el.replaceChild(fresh[o.i].cloneNode(true), cur[o.i]); else if (o.op === 'append') el.appendChild(fresh[o.i].cloneNode(true)); else if (o.op === 'remove') cur[o.i].remove(); }
|
|
el.__lastHtml = html; return ops.length > 0;
|
|
}
|