update ui webp export

This commit is contained in:
2026-03-12 21:01:32 -07:00
parent 9da3ccf678
commit aa3f23c92c
10 changed files with 741 additions and 240 deletions

View File

@@ -16,9 +16,20 @@
selectedConnectorId: null,
selectedWallId: null,
selectedEdgeId: null,
focusMode: false
focusMode: false,
exportInProgress: false,
exportFormat: ""
};
const CUBE_EXPORT_FORMATS = {
webp: {
mimeType: "image/webp",
extension: "webp",
quality: 0.98
}
};
const CUBE_EXPORT_BACKGROUND = "#111118";
const CUBE_VERTICES = [
[-1, -1, -1],
[1, -1, -1],
@@ -124,6 +135,7 @@
const cubeChassisUi = window.CubeChassisUi || {};
const cubeMathHelpers = window.CubeMathHelpers || {};
const cubeSelectionHelpers = window.CubeSelectionHelpers || {};
let webpExportSupported = null;
function getElements() {
return {
@@ -136,6 +148,7 @@
rotateDownEl: document.getElementById("cube-rotate-down"),
rotateResetEl: document.getElementById("cube-rotate-reset"),
focusToggleEl: document.getElementById("cube-focus-toggle"),
exportWebpEl: document.getElementById("cube-export-webp"),
markerModeEl: document.getElementById("cube-marker-mode"),
connectorToggleEl: document.getElementById("cube-connector-toggle"),
primalToggleEl: document.getElementById("cube-primal-toggle"),
@@ -344,6 +357,233 @@
}
}
function isExportFormatSupported(format) {
const exportFormat = CUBE_EXPORT_FORMATS[format];
if (!exportFormat) {
return false;
}
if (format === "webp" && typeof webpExportSupported === "boolean") {
return webpExportSupported;
}
const probeCanvas = document.createElement("canvas");
const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType);
const isSupported = dataUrl.startsWith(`data:${exportFormat.mimeType}`);
if (format === "webp") {
webpExportSupported = isSupported;
}
return isSupported;
}
function syncExportControls(elements) {
if (!(elements?.exportWebpEl instanceof HTMLButtonElement)) {
return;
}
const supportsWebp = isExportFormatSupported("webp");
elements.exportWebpEl.hidden = !supportsWebp;
elements.exportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp;
elements.exportWebpEl.textContent = state.exportInProgress && state.exportFormat === "webp"
? "Exporting..."
: "Export WebP";
if (supportsWebp) {
elements.exportWebpEl.title = "Download the current cube view as a WebP image.";
}
}
function copyComputedStyles(sourceEl, targetEl) {
if (!(sourceEl instanceof Element) || !(targetEl instanceof Element)) {
return;
}
const computedStyle = window.getComputedStyle(sourceEl);
Array.from(computedStyle).forEach((propertyName) => {
targetEl.style.setProperty(
propertyName,
computedStyle.getPropertyValue(propertyName),
computedStyle.getPropertyPriority(propertyName)
);
});
targetEl.style.setProperty("animation", "none");
targetEl.style.setProperty("transition", "none");
}
function inlineSvgStyles(sourceNode, targetNode) {
if (!(sourceNode instanceof Element) || !(targetNode instanceof Element)) {
return;
}
copyComputedStyles(sourceNode, targetNode);
const sourceChildren = Array.from(sourceNode.children);
const targetChildren = Array.from(targetNode.children);
const childCount = Math.min(sourceChildren.length, targetChildren.length);
for (let index = 0; index < childCount; index += 1) {
inlineSvgStyles(sourceChildren[index], targetChildren[index]);
}
}
function absolutizeSvgImageLinks(svgEl) {
if (!(svgEl instanceof SVGSVGElement)) {
return;
}
svgEl.querySelectorAll("image").forEach((imageEl) => {
const href = imageEl.getAttribute("href")
|| imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (!href) {
return;
}
const absoluteHref = new URL(href, document.baseURI).href;
imageEl.setAttribute("href", absoluteHref);
imageEl.setAttributeNS("http://www.w3.org/1999/xlink", "href", absoluteHref);
});
}
function prepareSvgMarkupForExport(svgEl) {
if (!(svgEl instanceof SVGSVGElement)) {
throw new Error("Cube view is not ready to export yet.");
}
const bounds = svgEl.getBoundingClientRect();
const viewBox = svgEl.viewBox?.baseVal || null;
const width = Math.max(
240,
Math.round(bounds.width),
Number.isFinite(viewBox?.width) ? Math.round(viewBox.width) : 0
);
const height = Math.max(
220,
Math.round(bounds.height),
Number.isFinite(viewBox?.height) ? Math.round(viewBox.height) : 0
);
const clone = svgEl.cloneNode(true);
if (!(clone instanceof SVGSVGElement)) {
throw new Error("Cube export could not clone the current SVG view.");
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
clone.setAttribute("width", String(width));
clone.setAttribute("height", String(height));
clone.setAttribute("preserveAspectRatio", "xMidYMid meet");
inlineSvgStyles(svgEl, clone);
absolutizeSvgImageLinks(clone);
const backgroundRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
backgroundRect.setAttribute("x", "0");
backgroundRect.setAttribute("y", "0");
backgroundRect.setAttribute("width", "100%");
backgroundRect.setAttribute("height", "100%");
backgroundRect.setAttribute("fill", CUBE_EXPORT_BACKGROUND);
backgroundRect.setAttribute("pointer-events", "none");
clone.insertBefore(backgroundRect, clone.firstChild);
return {
width,
height,
markup: new XMLSerializer().serializeToString(clone)
};
}
function loadSvgImage(markup) {
return new Promise((resolve, reject) => {
const svgBlob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
const image = new Image();
image.decoding = "async";
image.onload = () => {
URL.revokeObjectURL(svgUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(svgUrl);
reject(new Error("Cube export renderer could not load the current SVG view."));
};
image.src = svgUrl;
});
}
function canvasToBlobByFormat(canvas, format) {
const exportFormat = CUBE_EXPORT_FORMATS[format];
if (!exportFormat) {
return Promise.reject(new Error("Unsupported export format."));
}
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
}, exportFormat.mimeType, exportFormat.quality);
});
}
async function exportCubeView(format = "webp") {
const exportFormat = CUBE_EXPORT_FORMATS[format];
if (!exportFormat || state.exportInProgress) {
return;
}
const elements = getElements();
const svgEl = elements.viewContainerEl?.querySelector("svg.cube-svg");
if (!(svgEl instanceof SVGSVGElement)) {
window.alert("Cube view is not ready to export yet.");
return;
}
state.exportInProgress = true;
state.exportFormat = format;
syncExportControls(elements);
try {
const { width, height, markup } = prepareSvgMarkupForExport(svgEl);
const image = await loadSvgImage(markup);
const scale = Math.max(2, Math.min(4, Number(window.devicePixelRatio) || 1));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(width * scale));
canvas.height = Math.max(1, Math.ceil(height * scale));
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas context is unavailable.");
}
context.scale(scale, scale);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.fillStyle = CUBE_EXPORT_BACKGROUND;
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlobByFormat(canvas, format);
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 10);
downloadLink.href = blobUrl;
downloadLink.download = `cube-of-space-${stamp}.${exportFormat.extension}`;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Unable to export the current cube view.");
} finally {
state.exportInProgress = false;
state.exportFormat = "";
syncExportControls(getElements());
}
}
function bindRotationControls(elements) {
if (state.controlsBound) {
return;
@@ -358,6 +598,9 @@
state.focusMode = !state.focusMode;
syncFocusControls(getElements());
});
elements.exportWebpEl?.addEventListener("click", () => {
void exportCubeView("webp");
});
elements.markerModeEl?.addEventListener("change", (event) => {
const nextMode = normalizeId(event?.target?.value);
@@ -695,6 +938,7 @@
function render(elements) {
syncFocusControls(elements);
syncExportControls(elements);
if (elements?.markerModeEl) {
elements.markerModeEl.value = state.markerDisplayMode;