Block editor, markdown round trip, formulas/databases/kanban/canvas models, graph simulation (time-budgeted ticks for big vaults), shortcut registry, dialog focus handling, HTML to Markdown for paste and clipping. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
107 lines
4.9 KiB
JavaScript
107 lines
4.9 KiB
JavaScript
// Turns what a canvas shows into a picture or a document: the live canvas is copied (cards, notes, arrows exactly as
|
|
// they look), laid out at its own size with the app's styles, fonts and images inlined, so it stands on its own.
|
|
// html: a full page for the main process to print to PNG or PDF; svg: a vector file (the page inside a foreignObject)
|
|
import { bounds } from './canvas.js';
|
|
|
|
const PAD = 48; // canvas units of space around the items
|
|
const MAX_SIDE = 8000; // longest side in pixels, so a huge canvas still renders
|
|
const XHTML = 'http://www.w3.org/1999/xhtml';
|
|
const OWN = new Set(['--gs', '--gx', '--gy', '--iz']);
|
|
|
|
function dataUrl(url) {
|
|
return fetch(url)
|
|
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.blob(); })
|
|
.then((b) => new Promise((resolve, reject) => {
|
|
const f = new FileReader();
|
|
f.onload = () => resolve(f.result);
|
|
f.onerror = reject;
|
|
f.readAsDataURL(b);
|
|
}));
|
|
}
|
|
|
|
// A stylesheet rule's text with the files it names (fonts) written into it.
|
|
async function inlineUrls(text, base) {
|
|
const found = [...text.matchAll(/url\((["']?)(?!data:)([^"')]+)\1\)/g)];
|
|
for (const m of found) {
|
|
try { text = text.replace(m[0], `url("${await dataUrl(new URL(m[2], base).href)}")`); } catch { /* left as it was */ }
|
|
}
|
|
return text;
|
|
}
|
|
|
|
async function pageCss() {
|
|
let css = '';
|
|
const names = new Set();
|
|
const noteNames = (rule) => {
|
|
if (rule.style) for (const n of rule.style) if (n.startsWith('--')) names.add(n);
|
|
if (rule.cssRules) for (const r of rule.cssRules) noteNames(r);
|
|
};
|
|
for (const sheet of document.styleSheets) {
|
|
let rules;
|
|
try { rules = sheet.cssRules; } catch { continue; }
|
|
const base = sheet.href || document.baseURI;
|
|
for (const r of rules) {
|
|
noteNames(r);
|
|
css += (r instanceof CSSFontFaceRule ? await inlineUrls(r.cssText, base) : r.cssText) + '\n';
|
|
}
|
|
}
|
|
return { css, names };
|
|
}
|
|
|
|
const clear = (c) => !c || c === 'transparent' || /rgba?\([^)]*,\s*0\)$/.test(c);
|
|
function backdrop(el) {
|
|
for (let e = el; e; e = e.parentElement) {
|
|
const c = getComputedStyle(e).backgroundColor;
|
|
if (!clear(c)) return c;
|
|
}
|
|
return '#ffffff';
|
|
}
|
|
|
|
// nodes: the canvas items to show (all of them); scale: pixels per canvas unit (cut down for very large canvases)
|
|
export async function buildCanvasExport(view, nodes, { scale = 1 } = {}) {
|
|
const b = bounds(nodes);
|
|
if (!b) return null;
|
|
const s = Math.max(0.05, Math.min(scale, MAX_SIDE / Math.max(b.width + PAD * 2, b.height + PAD * 2)));
|
|
const W = Math.ceil((b.width + PAD * 2) * s), H = Math.ceil((b.height + PAD * 2) * s);
|
|
|
|
const world = view.querySelector('.cv-world').cloneNode(true);
|
|
world.style.transform = `translate(${(PAD - b.x) * s}px,${(PAD - b.y) * s}px) scale(${s})`;
|
|
for (const e of world.querySelectorAll('.cv-dot,.cv-resize,.cv-box,.cv-guide')) e.remove();
|
|
for (const e of world.querySelectorAll('.sel,.target,.editing,.found,.found-cur')) e.classList.remove('sel', 'target', 'editing', 'found', 'found-cur');
|
|
await Promise.all([...world.querySelectorAll('img')].map(async (img) => {
|
|
const src = img.getAttribute('src');
|
|
if (!src || src.startsWith('data:')) return;
|
|
try { img.setAttribute('src', await dataUrl(src)); } catch { /* shows as the broken image it was */ }
|
|
}));
|
|
|
|
const { css, names } = await pageCss();
|
|
const live = getComputedStyle(view);
|
|
const vars = [...names].filter((n) => !OWN.has(n)).map((n) => [n, live.getPropertyValue(n).trim()]).filter(([, v]) => v).map(([n, v]) => `${n}:${v}`);
|
|
const bg = backdrop(view);
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'cv';
|
|
wrap.setAttribute('style', `${vars.join(';')};--iz:${1 / s};position:relative;width:${W}px;height:${H}px;flex:none;overflow:hidden;background:${bg};` +
|
|
`font-family:${live.fontFamily};font-size:${live.fontSize};color:${live.color}`);
|
|
wrap.append(world);
|
|
|
|
const page = `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style>` +
|
|
`<style>html,body{margin:0;padding:0;width:${W}px;height:${H}px;overflow:hidden;background:${bg}}@page{size:${W}px ${H}px;margin:0}</style>` +
|
|
`</head><body>${wrap.outerHTML}</body></html>`;
|
|
|
|
// The same, as one SVG file: the styles and the copied canvas sit in a foreignObject.
|
|
const style = document.createElement('style');
|
|
style.textContent = css;
|
|
const inner = document.createElementNS(XHTML, 'div');
|
|
inner.append(style, wrap.cloneNode(true));
|
|
const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
|
fo.setAttribute('width', String(W));
|
|
fo.setAttribute('height', String(H));
|
|
fo.append(inner);
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
svg.setAttribute('width', String(W));
|
|
svg.setAttribute('height', String(H));
|
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
svg.append(fo);
|
|
return { html: page, svg: new XMLSerializer().serializeToString(svg), width: W, height: H, scale: s };
|
|
}
|