Jacob Schmidt e15d4b3066 Introduce shared web UI runtime and migrate org/store bridges
- add common ForgeWebUI runtime, site loader, and SQF WebUI bridge base declarations
- migrate org and store web UIs to src-driven bundles and new bridge/bootstrap flow
- update addon configs/prep hooks and document the shared CT_WEBBROWSER framework
2026-03-14 00:40:34 -05:00

429 lines
11 KiB
JavaScript

(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
const SVG_NS = "http://www.w3.org/2000/svg";
const SVG_TAGS = new Set([
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"g",
"defs",
"use",
"text",
"tspan",
"clipPath",
"mask",
]);
const injectedStyles = new Set();
const scheduledObservers = new Set();
let activeObserver = null;
let batchDepth = 0;
let flushQueued = false;
function queueFlush() {
if (flushQueued || batchDepth > 0) {
return;
}
flushQueued = true;
queueMicrotask(() => {
flushQueued = false;
flushObservers();
});
}
function flushObservers() {
while (scheduledObservers.size > 0) {
const queue = Array.from(scheduledObservers);
scheduledObservers.clear();
queue.forEach((observer) => runObserver(observer));
}
}
function cleanupObserver(observer) {
if (typeof observer.cleanup === "function") {
try {
observer.cleanup();
} catch (error) {
console.error("[ForgeWebUI] Observer cleanup failed.", error);
}
}
observer.cleanup = null;
observer.dependencies.forEach((dependency) => {
dependency.delete(observer);
});
observer.dependencies.clear();
}
function runObserver(observer) {
if (!observer || observer.disposed) {
return;
}
cleanupObserver(observer);
const previousObserver = activeObserver;
activeObserver = observer;
try {
const cleanup = observer.fn();
if (typeof cleanup === "function") {
observer.cleanup = cleanup;
}
} catch (error) {
console.error("[ForgeWebUI] Observer execution failed.", error);
} finally {
activeObserver = previousObserver;
}
}
function scheduleObserver(observer) {
if (!observer || observer.disposed) {
return;
}
scheduledObservers.add(observer);
queueFlush();
}
function trackDependency(dependency) {
if (!activeObserver) {
return;
}
dependency.add(activeObserver);
activeObserver.dependencies.add(dependency);
}
function createSignalValue(initialValue) {
let value = initialValue;
const subscribers = new Set();
function read() {
trackDependency(subscribers);
return value;
}
read.peek = () => value;
read.set = (nextValue) => {
const resolvedValue =
typeof nextValue === "function" ? nextValue(value) : nextValue;
if (Object.is(resolvedValue, value)) {
return value;
}
value = resolvedValue;
subscribers.forEach((observer) => scheduleObserver(observer));
return value;
};
read.update = (updater) => read.set(updater);
read.subscribe = (listener) =>
effect(() => {
listener(read());
});
return read;
}
function createSignal(initialValue) {
const signal = createSignalValue(initialValue);
return [signal, signal.set];
}
function computed(factory) {
const valueSignal = createSignalValue(undefined);
let initialized = false;
effect(() => {
const nextValue = factory();
if (!initialized || !Object.is(nextValue, valueSignal.peek())) {
initialized = true;
valueSignal.set(nextValue);
}
});
return valueSignal;
}
function effect(fn) {
const observer = {
cleanup: null,
dependencies: new Set(),
disposed: false,
fn,
};
observer.dispose = () => {
if (observer.disposed) {
return;
}
observer.disposed = true;
scheduledObservers.delete(observer);
cleanupObserver(observer);
};
runObserver(observer);
return observer.dispose;
}
function batch(fn) {
batchDepth += 1;
try {
return fn();
} finally {
batchDepth = Math.max(0, batchDepth - 1);
if (batchDepth === 0) {
flushObservers();
}
}
}
function appendChild(node, child) {
if (child === null || child === undefined || child === false) {
return;
}
if (Array.isArray(child)) {
child.forEach((entry) => appendChild(node, entry));
return;
}
if (
typeof child === "string" ||
typeof child === "number" ||
typeof child === "bigint"
) {
node.appendChild(document.createTextNode(String(child)));
return;
}
if (child instanceof Node) {
node.appendChild(child);
}
}
function fragment(...children) {
const node = document.createDocumentFragment();
children.forEach((child) => appendChild(node, child));
return node;
}
function text(value) {
return document.createTextNode(String(value ?? ""));
}
function applyProp(node, key, value, isSvg) {
if (key === "key") {
return;
}
if (key === "ref" && typeof value === "function") {
value(node);
return;
}
if (key === "className") {
if (isSvg) {
node.setAttribute("class", value || "");
} else {
node.className = value || "";
}
return;
}
if (key === "style" && value && typeof value === "object") {
Object.assign(node.style, value);
return;
}
if (key === "dataset" && value && typeof value === "object") {
Object.entries(value).forEach(([name, datasetValue]) => {
node.dataset[name] = datasetValue;
});
return;
}
if (key.startsWith("on") && typeof value === "function") {
node.addEventListener(key.slice(2).toLowerCase(), value);
return;
}
if (key === "value" && "value" in node) {
node.value = value ?? "";
return;
}
if (key === "checked" && "checked" in node) {
node.checked = Boolean(value);
return;
}
if (key === "selected" && "selected" in node) {
node.selected = Boolean(value);
return;
}
if (typeof value === "boolean") {
if (value) {
node.setAttribute(key, "");
} else {
node.removeAttribute(key);
}
return;
}
if (value === null || value === undefined) {
node.removeAttribute(key);
return;
}
node.setAttribute(key, value);
}
function h(tag, props = {}, ...children) {
const isSvg = SVG_TAGS.has(tag);
const node = isSvg
? document.createElementNS(SVG_NS, tag)
: document.createElement(tag);
if (props && typeof props === "object") {
Object.entries(props).forEach(([key, value]) => {
applyProp(node, key, value, isSvg);
});
}
children.forEach((child) => appendChild(node, child));
return node;
}
function normalizeNode(node) {
if (node === null || node === undefined || node === false) {
return document.createDocumentFragment();
}
if (Array.isArray(node)) {
return fragment(...node);
}
if (
typeof node === "string" ||
typeof node === "number" ||
typeof node === "bigint"
) {
return text(node);
}
if (node instanceof Node) {
return node;
}
return document.createDocumentFragment();
}
function captureScrollState(container) {
return Array.from(
container.querySelectorAll("[data-preserve-scroll-id]"),
).map((node) => ({
id: node.getAttribute("data-preserve-scroll-id"),
scrollLeft: node.scrollLeft,
scrollTop: node.scrollTop,
}));
}
function restoreScrollState(container, scrollState) {
if (!Array.isArray(scrollState) || scrollState.length === 0) {
return;
}
scrollState.forEach((entry) => {
if (!entry || !entry.id) {
return;
}
const target = container.querySelector(
`[data-preserve-scroll-id="${entry.id}"]`,
);
if (!target) {
return;
}
target.scrollTop = Number(entry.scrollTop || 0);
target.scrollLeft = Number(entry.scrollLeft || 0);
});
}
function mount(container, render, options = {}) {
const preserveScroll = options.preserveScroll !== false;
const dispose = effect(() => {
const scrollState = preserveScroll
? captureScrollState(container)
: [];
const nextNode = normalizeNode(render());
container.replaceChildren(nextNode);
if (preserveScroll && scrollState.length > 0) {
requestAnimationFrame(() => {
restoreScrollState(container, scrollState);
});
}
});
return {
container,
dispose,
rerender() {
container.replaceChildren(normalizeNode(render()));
},
};
}
function render(component, container, options = {}) {
return mount(container, component, options);
}
function unmount(mountHandle) {
if (!mountHandle || typeof mountHandle.dispose !== "function") {
return;
}
mountHandle.dispose();
}
function ensureScopedStyle(id, cssText) {
if (!id || !cssText || injectedStyles.has(id)) {
return;
}
const style = document.createElement("style");
style.setAttribute("data-ui-style", id);
style.textContent = cssText;
document.head.appendChild(style);
injectedStyles.add(id);
}
ForgeWebUI.batch = batch;
ForgeWebUI.computed = computed;
ForgeWebUI.createSignal = createSignal;
ForgeWebUI.effect = effect;
ForgeWebUI.ensureScopedStyle = ensureScopedStyle;
ForgeWebUI.fragment = fragment;
ForgeWebUI.h = h;
ForgeWebUI.mount = mount;
ForgeWebUI.render = render;
ForgeWebUI.signal = createSignalValue;
ForgeWebUI.text = text;
ForgeWebUI.unmount = unmount;
})(window);