Lumi/src/web/public/overlay-renderer.js
2026-07-17 22:10:00 +02:00

221 lines
9.6 KiB
JavaScript

(() => {
const anchors = {
"top-left": [0, 0],
"top-center": [-50, 0],
"top-right": [-100, 0],
"center-left": [0, -50],
center: [-50, -50],
"center-right": [-100, -50],
"bottom-left": [0, -100],
"bottom-center": [-50, -100],
"bottom-right": [-100, -100]
};
function finite(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function applyBox(element, config) {
const [translateX, translateY] = anchors[config.anchor] || anchors["top-left"];
element.style.left = `${finite(config.x, 0)}%`;
element.style.top = `${finite(config.y, 0)}%`;
element.style.width = `${finite(config.width, 100)}%`;
element.style.height = `${finite(config.height, 100)}%`;
element.style.opacity = `${finite(config.opacity, 1)}`;
element.style.transform = `translate(${translateX}%, ${translateY}%) translateZ(0)`;
}
function shrinkText(box, content, preferredSize, minimumSize, scale) {
let size = preferredSize;
const fit = () => {
content.style.fontSize = `${size * scale}px`;
if (size <= minimumSize || (content.scrollWidth <= box.clientWidth && content.scrollHeight <= box.clientHeight)) return;
size = Math.max(minimumSize, size - Math.max(1, preferredSize / 24));
requestAnimationFrame(fit);
};
requestAnimationFrame(fit);
}
function restartMediaElement(media) {
if (!media) return false;
const startAt = Math.max(0, finite(media.dataset.startAt, 0));
try { media.currentTime = startAt; } catch {}
const playback = media.play?.();
playback?.catch?.(() => {});
return true;
}
function buildMedia(module, values, options, renderType) {
const media = document.createElement(renderType);
media.className = `lumi-overlay-${renderType}`;
media.src = values.url;
media.dataset.originalUrl = values.url;
media.dataset.moduleId = module.id;
media.dataset.startAt = `${finite(values.startAt, 0)}`;
media.preload = "auto";
media.playsInline = true;
media.loop = values.playBehavior === "loop";
media.controls = values.showControls === true || values.playBehavior === "manual";
media.muted = values.muted === true;
media.volume = Math.min(1, Math.max(0, finite(values.volume, 1)));
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
media.autoplay = !options.editor && values.playBehavior !== "manual";
media.referrerPolicy = "no-referrer";
media.style.pointerEvents = !options.editor && media.controls ? "auto" : "none";
if (renderType === "video") media.style.objectFit = values.fit || "contain";
media.addEventListener("loadedmetadata", () => {
const startAt = Math.max(0, finite(values.startAt, 0));
if (startAt && startAt < finite(media.duration, Infinity)) {
try { media.currentTime = startAt; } catch {}
}
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
if (media.autoplay) media.play().catch(() => {});
}, { once: true });
return media;
}
function webFrameLayout(values) {
const left = Math.min(95, Math.max(0, finite(values.cropLeft, 0)));
const right = Math.min(95, Math.max(0, finite(values.cropRight, 0)));
const top = Math.min(95, Math.max(0, finite(values.cropTop, 0)));
const bottom = Math.min(95, Math.max(0, finite(values.cropBottom, 0)));
const zoom = Math.min(5, Math.max(0.1, finite(values.zoom, 1)));
const visibleWidth = Math.max(5, 100 - left - right);
const visibleHeight = Math.max(5, 100 - top - bottom);
return {
width: (10000 * zoom) / visibleWidth,
height: (10000 * zoom) / visibleHeight,
left: (-100 * left * zoom) / visibleWidth,
top: (-100 * top * zoom) / visibleHeight
};
}
function buildWebsite(module, values, options) {
const frame = document.createElement("iframe");
frame.className = "lumi-overlay-frame";
frame.src = values.url;
frame.dataset.originalUrl = values.url;
frame.dataset.moduleId = module.id;
let sandbox = "allow-scripts allow-forms allow-popups allow-presentation";
try {
if (new URL(values.url, window.location.href).origin !== window.location.origin) sandbox += " allow-same-origin";
} catch {}
frame.sandbox = sandbox;
frame.referrerPolicy = "no-referrer";
frame.setAttribute("allow", "autoplay; fullscreen");
frame.style.pointerEvents = options.editor ? "none" : (values.allowPointerEvents ? "auto" : "none");
const layout = webFrameLayout(values);
const root = document.createElement("div");
root.className = "lumi-overlay-web-root";
const shadow = root.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden; }
.lumi-overlay-frame {
position: absolute; display: block; border: 0; background: transparent;
width: ${layout.width}%; height: ${layout.height}%;
left: ${layout.left}%; top: ${layout.top}%;
}
${values.customCss || ""}
`;
shadow.append(style, frame);
return root;
}
function findSourceContent(root, moduleId, selector) {
const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
return box?.querySelector?.(selector) || box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector) || null;
}
function buildModule(module, options = {}) {
const renderType = module.renderType || module.type;
const values = module.config || {};
const scale = finite(options.scale, 1);
const box = document.createElement("section");
box.className = `lumi-overlay-module lumi-overlay-module-${renderType}`;
if (options.editor) box.classList.add("is-editor-source");
box.dataset.moduleId = module.id;
box.dataset.moduleType = module.type;
box.dataset.renderType = renderType;
box.dataset.moduleName = module.name || "Source";
applyBox(box, values);
if (renderType === "text") {
box.classList.add("lumi-overlay-text", `overflow-${values.overflow || "wrap"}`);
const content = document.createElement("span");
content.className = "lumi-overlay-text-content";
content.textContent = values.text || "";
const horizontal = values.horizontalAlign || values.align || "left";
const vertical = values.verticalAlign || "center";
box.style.textAlign = horizontal;
box.style.justifyContent = horizontal === "center" ? "center" : horizontal === "right" ? "flex-end" : "flex-start";
box.style.alignItems = vertical === "center" ? "center" : vertical === "bottom" ? "flex-end" : "flex-start";
box.style.color = values.color || "#ffffff";
box.style.background = values.background || "transparent";
box.style.padding = `${finite(values.padding, 0) * scale}px`;
content.style.fontWeight = `${finite(values.fontWeight, 700)}`;
content.style.fontSize = `${finite(values.fontSize, 48) * scale}px`;
box.appendChild(content);
if (values.overflow === "shrink") shrinkText(box, content, finite(values.fontSize, 48), 8, scale);
} else if (renderType === "image" && values.url) {
const image = document.createElement("img");
image.className = "lumi-overlay-image";
image.alt = "";
image.src = values.url;
image.referrerPolicy = "no-referrer";
image.style.objectFit = values.fit || "contain";
box.appendChild(image);
} else if ((renderType === "video" || renderType === "audio") && values.url) {
const media = buildMedia(module, values, options, renderType);
box.appendChild(media);
if (renderType === "audio" && options.editor) {
const placeholder = document.createElement("span");
placeholder.className = "lumi-overlay-audio-placeholder";
placeholder.textContent = "Audio source";
box.appendChild(placeholder);
}
} else if (renderType === "web" && values.url) {
box.appendChild(buildWebsite(module, values, options));
}
if (options.editor) {
const tag = document.createElement("span");
tag.className = "overlay-canvas-source-label";
tag.textContent = module.name || "Source";
box.appendChild(tag);
for (const direction of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
const handle = document.createElement("button");
handle.type = "button";
handle.className = `overlay-resize-handle handle-${direction}`;
handle.dataset.resizeHandle = direction;
handle.setAttribute("aria-label", `Resize ${module.name || "source"} from ${direction}`);
box.appendChild(handle);
}
}
return box;
}
function render(root, state, options = {}) {
const canvas = options.canvas || state?.canvas || { width: 1920, height: 1080 };
const widthScale = root.clientWidth ? root.clientWidth / finite(canvas.width, 1920) : 1;
const heightScale = root.clientHeight ? root.clientHeight / finite(canvas.height, 1080) : widthScale;
const scale = options.scale || Math.min(widthScale, heightScale);
const fragment = document.createDocumentFragment();
const elements = new Map();
if (state?.enabled !== false && state?.scene) {
for (const module of state.scene.modules || []) {
if (module.enabled === false) continue;
const element = buildModule(module, { ...options, scale });
elements.set(module.id, element);
fragment.appendChild(element);
}
}
root.replaceChildren(fragment);
return elements;
}
window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, render, restartMediaElement };
})();