feat(app): editor engine, markdown, store and domain modules
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>
This commit is contained in:
1 parent
313c41f867
commit
c0587cb267
29 files changed
+5274
-169
No files matched your search
@@ -0,0 +1,106 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in new issue
Block a user