- 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
429 lines
11 KiB
JavaScript
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);
|