update ui webp export
This commit is contained in:
207
app/styles.css
207
app/styles.css
@@ -18,6 +18,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid #27272a;
|
||||
background: #18181b;
|
||||
min-width: 0;
|
||||
@@ -43,7 +44,9 @@
|
||||
color: #fbbf24;
|
||||
}
|
||||
.topbar-menu-toggle {
|
||||
display: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #3f3f46;
|
||||
@@ -59,55 +62,53 @@
|
||||
background: #3f3f46;
|
||||
}
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 132px;
|
||||
margin-bottom: -130px;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 4px 0 0;
|
||||
border: 1px solid #2f2f39;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(24, 24, 34, 0.98), rgba(12, 12, 18, 0.98));
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
|
||||
gap: 10px;
|
||||
max-height: calc(100svh - 88px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.topbar.is-menu-open .topbar-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.topbar-actions > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.topbar-actions::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.topbar-dropdown {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
}
|
||||
.topbar-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
position: static;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: none;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3f3f46;
|
||||
background: #18181b;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
|
||||
margin-top: 6px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #31313d;
|
||||
background: rgba(10, 10, 16, 0.92);
|
||||
box-shadow: none;
|
||||
z-index: 40;
|
||||
}
|
||||
.topbar-dropdown.is-open .topbar-dropdown-menu {
|
||||
display: grid;
|
||||
}
|
||||
.topbar-dropdown:hover .topbar-dropdown-menu,
|
||||
.topbar-dropdown:focus-within .topbar-dropdown-menu {
|
||||
display: grid;
|
||||
}
|
||||
.topbar-sub-trigger {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.topbar-sub-trigger.is-active {
|
||||
@@ -115,6 +116,12 @@
|
||||
border-color: #52525b;
|
||||
}
|
||||
.settings-trigger {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3f3f46;
|
||||
@@ -146,70 +153,15 @@
|
||||
min-height: 38px;
|
||||
}
|
||||
.topbar-menu-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.topbar-actions {
|
||||
display: none;
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 4px 0 0;
|
||||
border: 1px solid #2f2f39;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(24, 24, 34, 0.98), rgba(12, 12, 18, 0.98));
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
|
||||
overflow: visible;
|
||||
pointer-events: auto;
|
||||
gap: 10px;
|
||||
max-height: calc(100svh - 88px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.topbar.is-menu-open .topbar-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.topbar-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
.topbar-dropdown {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
}
|
||||
.topbar-dropdown-menu {
|
||||
position: static;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
border-color: #31313d;
|
||||
background: rgba(10, 10, 16, 0.92);
|
||||
box-shadow: none;
|
||||
}
|
||||
.topbar-dropdown:hover .topbar-dropdown-menu,
|
||||
.topbar-dropdown:focus-within .topbar-dropdown-menu {
|
||||
display: none;
|
||||
}
|
||||
.topbar-dropdown.is-open .topbar-dropdown-menu {
|
||||
display: grid;
|
||||
}
|
||||
.settings-trigger {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.topbar-sub-trigger {
|
||||
min-height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
@@ -366,9 +318,18 @@
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
#tarot-house-section {
|
||||
height: calc(100vh - 61px);
|
||||
background: #18181b;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
#tarot-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
#tarot-house-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
#planet-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -1172,22 +1133,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#tarot-browse-view.is-house-focus {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
#tarot-house-view.is-house-focus {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
#tarot-browse-view.is-house-focus .tarot-section-house-top {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#tarot-browse-view.is-house-focus .tarot-layout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#tarot-browse-view.is-house-focus .tarot-house-layout {
|
||||
#tarot-house-view.is-house-focus .tarot-house-layout {
|
||||
--tarot-house-card-gap: clamp(4px, 0.6vw, 8px);
|
||||
--tarot-house-row-gap: clamp(6px, 0.9vw, 10px);
|
||||
--tarot-house-section-gap: clamp(12px, 1.4vw, 16px);
|
||||
@@ -1197,7 +1147,7 @@
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
#tarot-browse-view.is-house-focus .tarot-house-trumps {
|
||||
#tarot-house-view.is-house-focus .tarot-house-trumps {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -1802,6 +1752,10 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cube-export-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cube-rotation-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
@@ -2285,6 +2239,33 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kab-export-btn {
|
||||
margin-left: auto;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 6px;
|
||||
background: #18181b;
|
||||
color: #d4d4d8;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.kab-export-btn:hover:not(:disabled) {
|
||||
background: #27272a;
|
||||
border-color: #52525b;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
.kab-export-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.kab-export-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kab-detail-panel {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
@@ -2783,7 +2764,7 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-row: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -2791,6 +2772,28 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#tarot-house-view {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
background: #151520;
|
||||
}
|
||||
|
||||
#tarot-house-view .tarot-section-house-top {
|
||||
max-height: none;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#tarot-house-view .tarot-house-card {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Tarot Spread View ─────────────────────────────── */
|
||||
#tarot-spread-view {
|
||||
display: flex;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
const DETAIL_COLLAPSE_STORAGE_PREFIX = "tarot-detail-collapsed:v2:";
|
||||
const DEFAULT_DATASET_ENTRY_COLLAPSED = false;
|
||||
const DEFAULT_DATASET_DETAIL_COLLAPSED = true;
|
||||
const MOBILE_TOPBAR_MEDIA_QUERY = "(max-width: 900px)";
|
||||
const sidebarControllers = new WeakMap();
|
||||
const detailControllers = new WeakMap();
|
||||
const AUTO_COLLAPSE_ENTRY_SELECTOR = [
|
||||
@@ -354,10 +353,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function isMobileTopbarViewport() {
|
||||
return window.matchMedia(MOBILE_TOPBAR_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function setTopbarMenuOpen(isOpen) {
|
||||
const { topbarEl, menuToggleEl } = getTopbarElements();
|
||||
if (!(topbarEl instanceof HTMLElement) || !(menuToggleEl instanceof HTMLButtonElement)) {
|
||||
@@ -407,18 +402,13 @@
|
||||
|
||||
if (!isDropdownTrigger || isMenuItem) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (isMobileTopbarViewport()) {
|
||||
setTopbarMenuOpen(false);
|
||||
}
|
||||
setTopbarMenuOpen(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const clickTarget = event.target;
|
||||
if (!isMobileTopbarViewport()) {
|
||||
return;
|
||||
}
|
||||
if (clickTarget instanceof Node && topbarEl.contains(clickTarget)) {
|
||||
return;
|
||||
}
|
||||
@@ -426,12 +416,6 @@
|
||||
setTopbarMenuOpen(false);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!isMobileTopbarViewport()) {
|
||||
setTopbarMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setTopbarMenuOpen(false);
|
||||
@@ -463,20 +447,6 @@
|
||||
|
||||
setTopbarDropdownOpen(dropdownEl, false);
|
||||
|
||||
dropdownEl.addEventListener("mouseenter", () => {
|
||||
if (isMobileTopbarViewport()) {
|
||||
return;
|
||||
}
|
||||
setTopbarDropdownOpen(dropdownEl, true);
|
||||
});
|
||||
|
||||
dropdownEl.addEventListener("mouseleave", () => {
|
||||
if (isMobileTopbarViewport()) {
|
||||
return;
|
||||
}
|
||||
setTopbarDropdownOpen(dropdownEl, false);
|
||||
});
|
||||
|
||||
dropdownEl.addEventListener("focusout", (event) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (!(nextTarget instanceof Node) || !dropdownEl.contains(nextTarget)) {
|
||||
|
||||
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;
|
||||
|
||||
@@ -58,11 +58,22 @@
|
||||
showPathNumbers: true,
|
||||
showPathTarotCards: false,
|
||||
selectedSephiraNumber: null,
|
||||
selectedPathNumber: null
|
||||
selectedPathNumber: null,
|
||||
exportInProgress: false,
|
||||
exportFormat: ""
|
||||
};
|
||||
const TREE_EXPORT_FORMATS = {
|
||||
webp: {
|
||||
mimeType: "image/webp",
|
||||
extension: "webp",
|
||||
quality: 0.98
|
||||
}
|
||||
};
|
||||
const TREE_EXPORT_BACKGROUND = "#02030a";
|
||||
|
||||
const kabbalahDetailUi = window.KabbalahDetailUi || {};
|
||||
const kabbalahViewsUi = window.KabbalahViewsUi || {};
|
||||
let webpExportSupported = null;
|
||||
|
||||
if (
|
||||
typeof kabbalahViewsUi.renderTree !== "function"
|
||||
@@ -259,6 +270,7 @@
|
||||
pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"),
|
||||
pathNumberToggleEl: document.getElementById("kab-path-number-toggle"),
|
||||
pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"),
|
||||
treeExportWebpEl: document.getElementById("kab-tree-export-webp"),
|
||||
roseCrossContainerEl: document.getElementById("kab-rose-cross-container"),
|
||||
roseDetailNameEl: document.getElementById("kab-rose-detail-name"),
|
||||
roseDetailSubEl: document.getElementById("kab-rose-detail-sub"),
|
||||
@@ -476,6 +488,233 @@
|
||||
kabbalahViewsUi.renderTree(getViewRenderContext(elements));
|
||||
}
|
||||
|
||||
function isExportFormatSupported(format) {
|
||||
const exportFormat = TREE_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?.treeExportWebpEl instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const supportsWebp = isExportFormatSupported("webp");
|
||||
elements.treeExportWebpEl.hidden = !supportsWebp;
|
||||
elements.treeExportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp;
|
||||
elements.treeExportWebpEl.textContent = state.exportInProgress && state.exportFormat === "webp"
|
||||
? "Exporting..."
|
||||
: "Export WebP";
|
||||
|
||||
if (supportsWebp) {
|
||||
elements.treeExportWebpEl.title = "Download the current Tree of Life 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("Tree 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(
|
||||
470,
|
||||
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("Tree 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(NS, "rect");
|
||||
backgroundRect.setAttribute("x", "0");
|
||||
backgroundRect.setAttribute("y", "0");
|
||||
backgroundRect.setAttribute("width", "100%");
|
||||
backgroundRect.setAttribute("height", "100%");
|
||||
backgroundRect.setAttribute("fill", TREE_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("Tree export renderer could not load the current SVG view."));
|
||||
};
|
||||
image.src = svgUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlobByFormat(canvas, format) {
|
||||
const exportFormat = TREE_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 exportTreeView(format = "webp") {
|
||||
const exportFormat = TREE_EXPORT_FORMATS[format];
|
||||
if (!exportFormat || state.exportInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = getElements();
|
||||
const svgEl = elements.treeContainerEl?.querySelector("svg.kab-svg");
|
||||
if (!(svgEl instanceof SVGSVGElement)) {
|
||||
window.alert("Tree 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 = TREE_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 = `tree-of-life-${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 Tree of Life view.");
|
||||
} finally {
|
||||
state.exportInProgress = false;
|
||||
state.exportFormat = "";
|
||||
syncExportControls(getElements());
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentSelection(elements) {
|
||||
if (!state.tree) {
|
||||
return;
|
||||
@@ -538,6 +777,14 @@
|
||||
bindPathDisplayToggle(elements.pathNumberToggleEl, "showPathNumbers");
|
||||
bindPathDisplayToggle(elements.pathTarotToggleEl, "showPathTarotCards");
|
||||
|
||||
syncExportControls(elements);
|
||||
if (elements.treeExportWebpEl && !elements.treeExportWebpEl.dataset.bound) {
|
||||
elements.treeExportWebpEl.addEventListener("click", () => {
|
||||
void exportTreeView("webp");
|
||||
});
|
||||
elements.treeExportWebpEl.dataset.bound = "true";
|
||||
}
|
||||
|
||||
renderTree(elements);
|
||||
renderCurrentSelection(elements);
|
||||
renderRoseCross(elements);
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
bindClick(elements.openTarotHouseEl, () => {
|
||||
setActiveSection(getActiveSection() === "tarot-house" ? "home" : "tarot-house");
|
||||
});
|
||||
|
||||
bindClick(elements.openAstronomyEl, () => {
|
||||
setActiveSection(getActiveSection() === "astronomy" ? "home" : "astronomy");
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"calendar",
|
||||
"holidays",
|
||||
"tarot",
|
||||
"tarot-house",
|
||||
"astronomy",
|
||||
"planets",
|
||||
"cycles",
|
||||
@@ -88,6 +89,8 @@
|
||||
const isHolidaysOpen = activeSection === "holidays";
|
||||
const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen;
|
||||
const isTarotOpen = activeSection === "tarot";
|
||||
const isTarotHouseOpen = activeSection === "tarot-house";
|
||||
const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen;
|
||||
const isAstronomyOpen = activeSection === "astronomy";
|
||||
const isPlanetOpen = activeSection === "planets";
|
||||
const isCyclesOpen = activeSection === "cycles";
|
||||
@@ -113,6 +116,7 @@
|
||||
setHidden(elements.calendarSectionEl, !isCalendarOpen);
|
||||
setHidden(elements.holidaySectionEl, !isHolidaysOpen);
|
||||
setHidden(elements.tarotSectionEl, !isTarotOpen);
|
||||
setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen);
|
||||
setHidden(elements.astronomySectionEl, !isAstronomyOpen);
|
||||
setHidden(elements.planetSectionEl, !isPlanetOpen);
|
||||
setHidden(elements.cyclesSectionEl, !isCyclesOpen);
|
||||
@@ -137,7 +141,8 @@
|
||||
toggleActive(elements.openCalendarTimelineEl, isTimelineOpen);
|
||||
toggleActive(elements.openCalendarMonthsEl, isCalendarOpen);
|
||||
toggleActive(elements.openHolidaysEl, isHolidaysOpen);
|
||||
setPressed(elements.openTarotEl, isTarotOpen);
|
||||
setPressed(elements.openTarotEl, isTarotMenuOpen);
|
||||
toggleActive(elements.openTarotHouseEl, isTarotHouseOpen);
|
||||
config.tarotSpreadUi?.applyViewState?.();
|
||||
setPressed(elements.openAstronomyEl, isAstronomyMenuOpen);
|
||||
toggleActive(elements.openPlanetsEl, isPlanetOpen);
|
||||
@@ -185,6 +190,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTarotHouseOpen) {
|
||||
ensure.ensureTarotSection?.(referenceData, magickDataset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlanetOpen) {
|
||||
ensure.ensurePlanetSection?.(referenceData, magickDataset);
|
||||
return;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(),
|
||||
selectCardById: () => {},
|
||||
openCardLightbox: () => {},
|
||||
shouldOpenCardLightboxOnSelect: () => false,
|
||||
isHouseFocusMode: () => false,
|
||||
getCards: () => [],
|
||||
getSelectedCardId: () => "",
|
||||
@@ -730,8 +731,11 @@
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const shouldOpenLightbox = Boolean(config.isHouseFocusMode?.())
|
||||
|| Boolean(config.shouldOpenCardLightboxOnSelect?.(elements, card));
|
||||
|
||||
config.selectCardById(card.id, elements);
|
||||
if (config.isHouseFocusMode?.() === true && imageUrl) {
|
||||
if (shouldOpenLightbox && imageUrl) {
|
||||
config.openCardLightbox?.(
|
||||
imageUrl,
|
||||
cardDisplayName || card.name || "Tarot card enlarged image",
|
||||
|
||||
@@ -244,6 +244,8 @@
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
tarotSectionEl: document.getElementById("tarot-section"),
|
||||
tarotHouseSectionEl: document.getElementById("tarot-house-section"),
|
||||
tarotCardListEl: document.getElementById("tarot-card-list"),
|
||||
tarotSearchInputEl: document.getElementById("tarot-search-input"),
|
||||
tarotSearchClearEl: document.getElementById("tarot-search-clear"),
|
||||
@@ -278,6 +280,7 @@
|
||||
tarotKabPathEl: document.getElementById("tarot-kab-path"),
|
||||
tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"),
|
||||
tarotBrowseViewEl: document.getElementById("tarot-browse-view"),
|
||||
tarotHouseViewEl: document.getElementById("tarot-house-view"),
|
||||
tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"),
|
||||
tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"),
|
||||
tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"),
|
||||
@@ -539,8 +542,8 @@
|
||||
}
|
||||
|
||||
function syncHouseControls(elements) {
|
||||
if (elements?.tarotBrowseViewEl) {
|
||||
elements.tarotBrowseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode));
|
||||
if (elements?.tarotHouseViewEl) {
|
||||
elements.tarotHouseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode));
|
||||
}
|
||||
|
||||
if (elements?.tarotHouseTopCardsVisibleEl) {
|
||||
@@ -838,6 +841,10 @@
|
||||
}
|
||||
});
|
||||
},
|
||||
shouldOpenCardLightboxOnSelect: (latestElements) => Boolean(
|
||||
latestElements?.tarotHouseSectionEl instanceof HTMLElement
|
||||
&& latestElements.tarotHouseSectionEl.hidden === false
|
||||
),
|
||||
isHouseFocusMode: () => state.houseFocusMode,
|
||||
getCards: () => state.cards,
|
||||
getSelectedCardId: () => state.selectedCardId,
|
||||
|
||||
Reference in New Issue
Block a user