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:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 313c41f867
commit c0587cb267
29 files changed
+5274 -169

No files matched your search

+106
View File
@@ -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 };
}