update ui webp export
This commit is contained in:
246
app/ui-cube.js
246
app/ui-cube.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user