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
+5259 -154

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
// Keyboard and screen-reader behaviour shared by every dialog, so the dialogs themselves stay simple:
// a [role=dialog] gets aria-modal, takes focus when it opens, keeps Tab inside it, and gives focus back to what had it.
// (Menus and popovers are not dialogs; they handle their own keys.)
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
const visible = (el) => !el.closest('[inert],[hidden]') && el.getClientRects().length > 0;
const topDialog = () => {
const all = [...document.querySelectorAll('[role="dialog"]')].filter((d) => !d.closest('.closing'));
return all[all.length - 1] || null;
};
export function installDialogFocus() {
if (typeof document === 'undefined') return () => {};
const returnTo = new WeakMap();
const opened = (d) => {
if (d.dataset.a11y) return;
d.dataset.a11y = '1';
d.setAttribute('aria-modal', 'true');
returnTo.set(d, document.activeElement);
if (!d.contains(document.activeElement)) {
// an input in it (a search box) takes focus on its own a moment later; this is for the ones without
requestAnimationFrame(() => {
if (d.contains(document.activeElement)) return;
d.tabIndex = -1;
d.focus({ preventScroll: true });
});
}
};
const closed = (d) => {
const back = returnTo.get(d);
if (back && document.contains(back) && !topDialog()) requestAnimationFrame(() => back.focus?.({ preventScroll: true }));
};
const mo = new MutationObserver((records) => {
for (const r of records) {
for (const n of r.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.('[role="dialog"]')) opened(n);
n.querySelectorAll?.('[role="dialog"]').forEach(opened);
}
for (const n of r.removedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.('[role="dialog"]')) closed(n);
n.querySelectorAll?.('[role="dialog"]').forEach(closed);
}
}
});
mo.observe(document.body, { childList: true, subtree: true });
document.querySelectorAll('[role="dialog"]').forEach(opened);
const onkeydown = (e) => {
if (e.key !== 'Tab') return;
const d = topDialog();
if (!d) return;
const items = [...d.querySelectorAll(FOCUSABLE)].filter(visible);
if (!items.length) { e.preventDefault(); return; }
const first = items[0], last = items[items.length - 1];
if (!d.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && (document.activeElement === first || document.activeElement === d)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onkeydown, true);
return () => { mo.disconnect(); document.removeEventListener('keydown', onkeydown, true); };
}
+12
View File
@@ -0,0 +1,12 @@
// In Electron, window.vault comes from electron/preload.cjs.
// In a plain browser (`npm run dev:web`) an in-memory mock stands in, so the UI can be worked on without Electron.
import { createMock } from './mock.js';
export const isDesktop = typeof window !== 'undefined' && !!window.vault;
export const api = isDesktop ? window.vault : createMock();
export const platform = isDesktop ? window.vault.platform : 'web';
export const errorText = (err) =>
String((err && err.message) || err || 'Something went wrong')
.replace(/^Error invoking remote method '[^']+': /, '')
.replace(/^Error: /, '');
+341
View File
@@ -0,0 +1,341 @@
// Canvases: ".canvas" files in the JSON Canvas format Obsidian uses (https://jsoncanvas.org).
// nodes: { id, type: 'text' | 'file' | 'link' | 'group', x, y, width, height, color?, ...
// text (text) | file, subpath? (file) | url (link) | label?, background? (group) }
// edges: { id, fromNode, fromSide?, toNode, toSide?, fromEnd?, toEnd?, label?, color? }
// Fields this app doesn't know are kept as they are. parse/serialize match electron/canvas.cjs.
export const isCanvasId = (id) => /\.canvas$/i.test(String(id || ''));
export function parseCanvas(text) {
const s = String(text || '').trim();
if (!s) return { nodes: [], edges: [], extra: {} };
const data = JSON.parse(s);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('This is not a canvas file.');
const { nodes, edges, ...extra } = data;
return { nodes: Array.isArray(nodes) ? nodes : [], edges: Array.isArray(edges) ? edges : [], extra };
}
// Written the way Obsidian writes it: one node or edge per line, tabs.
export function serializeCanvas({ nodes = [], edges = [], extra = {} }) {
const list = (arr) => (arr.length ? '[\n' + arr.map((o) => '\t\t' + JSON.stringify(o)).join(',\n') + '\n\t]' : '[]');
const parts = [`\t"nodes":${list(nodes)}`, `\t"edges":${list(edges)}`];
for (const [k, v] of Object.entries(extra)) parts.push(`\t${JSON.stringify(k)}:${JSON.stringify(v)}`);
return '{\n' + parts.join(',\n') + '\n}';
}
// 16 hex digits, like Obsidian's ids.
export const newId = () => Array.from({ length: 16 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join('');
// Obsidian's six preset colours ("1"-"6"); any "#rrggbb" is used as is.
export const PRESETS = { 1: '#e5534b', 2: '#e8883a', 3: '#d9b130', 4: '#44ad6e', 5: '#3aa6c4', 6: '#9a6de0' };
export const colorOf = (c) => (c ? PRESETS[c] || (/^#[0-9a-f]{3,8}$/i.test(c) ? c : null) : null);
export const SIZES = { text: [250, 60], note: [400, 400], file: [400, 300], link: [400, 220], group: [480, 320] };
export const MIN_W = 80, MIN_H = 40;
/* files on a canvas: paths are relative to the notes folder, with extension ("Ideas/Plan.md", "img/a.png") */
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
export const fileKind = (file) => (/\.md$/i.test(file) ? 'note' : isCanvasId(file) ? 'canvas' : IMAGE_EXT.test(file) ? 'image' : 'other');
export const fileToId = (file) => String(file || '').replace(/\.md$/i, '');
export const idToFile = (id) => (isCanvasId(id) ? id : id + '.md');
export const fileName = (file) => String(file || '').slice(String(file || '').lastIndexOf('/') + 1).replace(/\.md$/i, '');
// `file` as seen from the folder `fromDir` ('' = the top), e.g. for an image next to some other page.
export function relPath(fromDir, file) {
const a = fromDir ? fromDir.split('/') : [];
const b = String(file).split('/');
let i = 0;
while (i < a.length && i < b.length - 1 && a[i] === b[i]) i++;
return [...a.slice(i).map(() => '..'), ...b.slice(i)].join('/');
}
export const dirOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '');
/* geometry (canvas coordinates) */
const NORMAL = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
export const SIDES = Object.keys(NORMAL);
export function sidePoint(n, side) {
switch (side) {
case 'top': return [n.x + n.width / 2, n.y];
case 'bottom': return [n.x + n.width / 2, n.y + n.height];
case 'left': return [n.x, n.y + n.height / 2];
default: return [n.x + n.width, n.y + n.height / 2];
}
}
// The side of `n` facing the point (x, y).
export function nearestSide(n, x, y) {
const dx = (x - (n.x + n.width / 2)) / (n.width / 2 || 1);
const dy = (y - (n.y + n.height / 2)) / (n.height / 2 || 1);
return Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top';
}
// Sides for an edge that doesn't say which: the ones facing each other.
export function autoSides(a, b) {
return [nearestSide(a, b.x + b.width / 2, b.y + b.height / 2), nearestSide(b, a.x + a.width / 2, a.y + a.height / 2)];
}
export const SHAPES = ['curved', 'straight', 'orthogonal'];
export const shapeOf = (e) => (SHAPES.includes(e && e.style) ? e.style : 'curved');
// The line of an edge from (p1 on side s1) to (p2 on side s2, or a loose end when s2 is null), as a curve, a
// straight line or right-angled segments (`shape`).
// Returns { d (SVG path), mid ([x, y] for the label), end: { at, dir } for the arrow head, start: { at, dir } },
// plus `pts` (the corners) for right angles.
export function edgePath(p1, s1, p2, s2, shape = 'curved') {
const dist = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
const n1 = NORMAL[s1] || [0, 0];
const n2 = s2 ? NORMAL[s2] : [(p1[0] - p2[0]) / (dist || 1), (p1[1] - p2[1]) / (dist || 1)];
if (shape === 'straight') {
const u = [(p2[0] - p1[0]) / (dist || 1), (p2[1] - p1[1]) / (dist || 1)];
return {
d: `M${p1[0]},${p1[1]} L${p2[0]},${p2[1]}`,
mid: [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2],
end: { at: p2, dir: u },
start: { at: p1, dir: [0 - u[0], 0 - u[1]] }
};
}
if (shape === 'orthogonal') return orthogonal(p1, n1, p2, s2 ? n2 : null);
const k = Math.max(30, Math.min(220, dist * 0.45));
const c1 = [p1[0] + n1[0] * k, p1[1] + n1[1] * k];
const c2 = [p2[0] + n2[0] * k, p2[1] + n2[1] * k];
const f = (a, b, c, d) => 0.125 * a + 0.375 * b + 0.375 * c + 0.125 * d;
return {
d: `M${p1[0]},${p1[1]} C${c1[0]},${c1[1]} ${c2[0]},${c2[1]} ${p2[0]},${p2[1]}`,
mid: [f(p1[0], c1[0], c2[0], p2[0]), f(p1[1], c1[1], c2[1], p2[1])],
end: { at: p2, dir: [0 - n2[0], 0 - n2[1]] },
start: { at: p1, dir: [0 - n1[0], 0 - n1[1]] }
};
}
const STUB = 24; // how far a right-angled line leaves a card before it turns
const RADIUS = 10; // rounded corners
// Right angles: leave p1 along n1, arrive at p2 along n2 (null = a loose end), turning on the way.
function orthogonal(p1, n1, p2, n2) {
const horiz = (n) => Math.abs(n[0]) > Math.abs(n[1]);
const h1 = horiz(n1);
// a loose end arrives from whichever direction is further
const h2 = n2 ? horiz(n2) : Math.abs(p2[0] - p1[0]) >= Math.abs(p2[1] - p1[1]);
const a1 = [p1[0] + n1[0] * STUB, p1[1] + n1[1] * STUB];
const a2 = n2 ? [p2[0] + n2[0] * STUB, p2[1] + n2[1] * STUB] : p2;
let via;
if (h1 && h2) { const mx = (a1[0] + a2[0]) / 2; via = [[mx, a1[1]], [mx, a2[1]]]; }
else if (!h1 && !h2) { const my = (a1[1] + a2[1]) / 2; via = [[a1[0], my], [a2[0], my]]; }
else if (h1) via = [[a2[0], a1[1]]];
else via = [[a1[0], a2[1]]];
const raw = [p1, a1, ...via, ...(n2 ? [a2] : []), p2];
// no repeated points, and no point in the middle of a straight run
const pts = [];
for (const p of raw) {
const last = pts[pts.length - 1];
if (last && Math.abs(last[0] - p[0]) < 0.01 && Math.abs(last[1] - p[1]) < 0.01) continue;
const prev = pts[pts.length - 2];
if (prev && last && ((prev[0] === last[0] && last[0] === p[0]) || (prev[1] === last[1] && last[1] === p[1]))) pts.pop();
pts.push(p);
}
const last = pts[pts.length - 1], before = pts[pts.length - 2] || p1;
const len = Math.hypot(last[0] - before[0], last[1] - before[1]) || 1;
const end = n2 ? [0 - n2[0], 0 - n2[1]] : [(last[0] - before[0]) / len, (last[1] - before[1]) / len];
return { d: roundedPath(pts, RADIUS), mid: middleOf(pts), end: { at: p2, dir: end }, start: { at: p1, dir: [0 - n1[0], 0 - n1[1]] }, pts };
}
// A polyline with its corners rounded off.
function roundedPath(pts, r) {
let d = `M${pts[0][0]},${pts[0][1]}`;
for (let i = 1; i < pts.length - 1; i++) {
const [px, py] = pts[i - 1], [x, y] = pts[i], [nx, ny] = pts[i + 1];
const l1 = Math.hypot(x - px, y - py), l2 = Math.hypot(nx - x, ny - y);
const k = Math.min(r, l1 / 2, l2 / 2);
if (k < 0.5) { d += ` L${x},${y}`; continue; }
d += ` L${x + ((px - x) / l1) * k},${y + ((py - y) / l1) * k} Q${x},${y} ${x + ((nx - x) / l2) * k},${y + ((ny - y) / l2) * k}`;
}
const e = pts[pts.length - 1];
return d + ` L${e[0]},${e[1]}`;
}
// The point half way along a polyline.
function middleOf(pts) {
const lens = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]));
let half = lens.reduce((a, b) => a + b, 0) / 2;
for (let i = 0; i < lens.length; i++) {
if (half <= lens[i] || i === lens.length - 1) {
const t = lens[i] ? half / lens[i] : 0;
return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * t, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * t];
}
half -= lens[i];
}
return pts[0];
}
// An arrow head at `at` pointing along `dir`, as SVG polygon points.
export function arrowHead(at, dir, size = 11) {
const [x, y] = at, [dx, dy] = dir;
const bx = x - dx * size, by = y - dy * size;
const w = size * 0.55;
return `${x},${y} ${bx - dy * w},${by + dx * w} ${bx + dy * w},${by - dx * w}`;
}
export const inside = (n, g) => n !== g && n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height;
export const overlaps = (n, r) => n.x < r.x + r.width && n.x + n.width > r.x && n.y < r.y + r.height && n.y + n.height > r.y;
export function bounds(nodes) {
if (!nodes.length) return null;
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const n of nodes) {
x1 = Math.min(x1, n.x); y1 = Math.min(y1, n.y);
x2 = Math.max(x2, n.x + n.width); y2 = Math.max(y2, n.y + n.height);
}
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
/* lining up and spreading out a selection: each returns { id -> { x, y } } for the nodes that would change */
export const ALIGNS = ['left', 'centerX', 'right', 'top', 'centerY', 'bottom'];
export function alignNodes(nodes, how) {
const b = bounds(nodes);
const out = new Map();
if (!b || nodes.length < 2 || !ALIGNS.includes(how)) return out;
for (const n of nodes) {
const x = how === 'left' ? b.x : how === 'right' ? b.x + b.width - n.width : how === 'centerX' ? Math.round(b.x + (b.width - n.width) / 2) : n.x;
const y = how === 'top' ? b.y : how === 'bottom' ? b.y + b.height - n.height : how === 'centerY' ? Math.round(b.y + (b.height - n.height) / 2) : n.y;
if (x !== n.x || y !== n.y) out.set(n.id, { x, y });
}
return out;
}
// Equal gaps between the nodes along `axis` ('x' or 'y'); the outermost two stay where they are.
export function distributeNodes(nodes, axis) {
const out = new Map();
if (nodes.length < 3) return out;
const [pos, size] = axis === 'y' ? ['y', 'height'] : ['x', 'width'];
const sorted = [...nodes].sort((a, c) => a[pos] + a[size] / 2 - (c[pos] + c[size] / 2));
const first = sorted[0], last = sorted[sorted.length - 1];
const span = last[pos] + last[size] - first[pos];
const gap = (span - sorted.reduce((t, n) => t + n[size], 0)) / (sorted.length - 1);
let at = first[pos];
for (const n of sorted) {
const p = Math.round(at);
if (p !== n[pos]) out.set(n.id, axis === 'y' ? { x: n.x, y: p } : { x: p, y: n.y });
at += n[size] + gap;
}
return out;
}
/* alignment guides while dragging
`use` says which lines of the dragged box count: fractions along the axis (0 = left/top, .5 = centre, 1 = right/bottom). */
export const ALL_LINES = { x: [0, 0.5, 1], y: [0, 0.5, 1] };
const EPS = 0.5;
// How far to nudge `box` so a line of it sits on a line of another node, if one is within `tol`:
// { dx, dy, sx, sy } (sx / sy: whether that axis found something to snap to).
export function guideSnap(box, others, tol, use = ALL_LINES) {
const best = (axis) => {
const [pos, size] = axis === 'x' ? ['x', 'width'] : ['y', 'height'];
let d = null;
for (const f of use[axis]) {
const v = box[pos] + box[size] * f;
for (const n of others) for (const g of ALL_LINES[axis]) {
const diff = n[pos] + n[size] * g - v;
if (Math.abs(diff) <= tol && (d === null || Math.abs(diff) < Math.abs(d))) d = diff;
}
}
return d;
};
const dx = best('x'), dy = best('y');
return { dx: dx ?? 0, dy: dy ?? 0, sx: dx !== null, sy: dy !== null };
}
// The lines to draw for `box` where it is: { axis, at, from, to } for each line of it that sits on a line of another node.
export function guideLines(box, others, use = ALL_LINES) {
const out = [];
for (const axis of ['x', 'y']) {
const [pos, size, cross, crossSize] = axis === 'x' ? ['x', 'width', 'y', 'height'] : ['y', 'height', 'x', 'width'];
for (const f of use[axis]) {
const v = box[pos] + box[size] * f;
let from = box[cross], to = box[cross] + box[crossSize], hit = false;
for (const n of others) {
if (!ALL_LINES[axis].some((g) => Math.abs(n[pos] + n[size] * g - v) <= EPS)) continue;
hit = true;
from = Math.min(from, n[cross]);
to = Math.max(to, n[cross] + n[crossSize]);
}
if (hit && !out.some((l) => l.axis === axis && Math.abs(l.at - v) <= EPS)) out.push({ axis, at: v, from, to });
}
}
return out;
}
/* card <-> note */
const HEADING = /^#{1,3}\s+(.*\S)\s*#*\s*$/;
// A page name for a card's text: its first line with the Markdown taken off.
export function titleFromText(text) {
for (const raw of String(text || '').split(/\r?\n/)) {
const t = raw
.replace(/^\s*(#{1,6}\s+|>\s*|[-*+]\s+(\[[ xX]\]\s+)?|\d+[.)]\s+)+/, '')
.replace(/!?\[\[([^\]|]*\|)?([^\]]*)\]\]/g, '$2') // [[Page|label]] -> label
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) -> label
.replace(/[*_~`]+/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!t || /^[-=_*]{3,}$/.test(t)) continue;
return t.length > 60 ? t.slice(0, 60).replace(/\s+\S*$/, '') || t.slice(0, 60) : t;
}
return 'Untitled';
}
// The page text for a card: all of it, except a heading on the first line, which becomes the page's name.
export function bodyAfterTitle(text) {
const lines = String(text || '').split(/\r?\n/);
const first = lines.findIndex((l) => l.trim());
if (first >= 0 && HEADING.test(lines[first])) lines.splice(0, first + 1);
const body = lines.join('\n').replace(/^\s*\n/, '').trimEnd();
return body ? body + '\n' : '';
}
/* groups: membership is where things sit (a node fully inside a group is in it), so a group can also fold away */
export const COLLAPSED_H = 44;
const area = (n) => n.width * n.height;
// Every node hidden by a collapsed group (the outermost one that holds it): Map id -> group.
export function hiddenMap(nodes) {
const out = new Map();
for (const g of nodes) {
if (g.type !== 'group' || !g.collapsed) continue;
for (const x of nodes) {
if (x === g || !inside(x, g)) continue;
const cur = out.get(x.id);
if (!cur || area(g) > area(cur)) out.set(x.id, g);
}
}
return out;
}
// A node as drawn: a collapsed group is a strip as wide as it was.
export const displayBox = (n) => (n.type === 'group' && n.collapsed ? { x: n.x, y: n.y, width: n.width, height: COLLAPSED_H } : n);
// The group a card was dropped into: the smallest open group holding the middle of it (null if it's in none).
export function groupFor(card, nodes) {
const cx = card.x + card.width / 2, cy = card.y + card.height / 2;
let best = null;
for (const g of nodes) {
if (g.type !== 'group' || g === card || g.collapsed || g.id === card.id) continue;
if (cx < g.x || cx > g.x + g.width || cy < g.y || cy > g.y + g.height) continue;
if (!best || area(g) < area(best)) best = g;
}
return best;
}
// What it takes for `card` to be fully inside the group it was dropped on: null if it already is,
// { x, y } to move the card in, or { group: {x, y, width, height} } when the card is too big and the group grows.
export function fitInto(card, group, pad = 20) {
if (inside(card, group)) return null;
if (card.width + pad * 2 <= group.width && card.height + pad * 2 <= group.height) {
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
return {
x: clamp(card.x, group.x + pad, group.x + group.width - card.width - pad),
y: clamp(card.y, group.y + pad, group.y + group.height - card.height - pad)
};
}
const x1 = Math.min(group.x, card.x - pad), y1 = Math.min(group.y, card.y - pad);
const x2 = Math.max(group.x + group.width, card.x + card.width + pad), y2 = Math.max(group.y + group.height, card.y + card.height + pad);
return { group: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } };
}
export const MIN_ZOOM = 0.1, MAX_ZOOM = 3;
export const clampZoom = (z) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
// The view { x, y, z } that shows `box` in a viewport of w × h (never zooming in past 100%).
export function fitView(box, w, h, pad = 60) {
if (!box) return { x: w / 2, y: h / 2, z: 1 };
const z = clampZoom(Math.min(1, (w - pad * 2) / (box.width || 1), (h - pad * 2) / (box.height || 1)));
return { x: w / 2 - (box.x + box.width / 2) * z, y: h / 2 - (box.y + box.height / 2) * z, z };
}
+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 };
}
+185
View File
@@ -0,0 +1,185 @@
// CSV in and out of a database. Pure functions (no DOM), so they can be tested on their own.
// parseCsv(text) -> { rows: string[][], delimiter } RFC 4180 quoting; , ; or tab
// toCsv(rows) -> text
// databaseCsv(db, records) -> text: a Name column, then one per property
// planImport({...}) -> what to create: new properties, options to add, and the records
import { read, write, keyFor, TYPES, isListType } from './database.js';
export function parseCsv(text) {
let s = String(text || '').replace(/^/, '');
// the delimiter: whichever of , ; tab occurs most outside quotes in the first line
const first = (() => {
let q = false, out = '';
for (const ch of s) {
if (ch === '"') q = !q;
else if (!q && (ch === '\n' || ch === '\r')) break;
if (!q) out += ch;
}
return out;
})();
const count = (d) => first.split(d).length - 1;
const delimiter = [',', ';', '\t'].sort((a, b) => count(b) - count(a))[0];
const rows = [];
let row = [], cell = '', q = false, i = 0;
const endCell = () => { row.push(cell); cell = ''; };
const endRow = () => { endCell(); rows.push(row); row = []; };
for (; i < s.length; i++) {
const ch = s[i];
if (q) {
if (ch === '"') { if (s[i + 1] === '"') { cell += '"'; i++; } else q = false; }
else cell += ch;
} else if (ch === '"' && cell === '') q = true;
else if (ch === delimiter) endCell();
else if (ch === '\n') endRow();
else if (ch === '\r') { if (s[i + 1] === '\n') i++; endRow(); }
else cell += ch;
}
if (cell !== '' || row.length) endRow();
// drop rows that are entirely empty (a trailing blank line)
return { rows: rows.filter((r) => r.some((c) => c.trim() !== '')), delimiter };
}
export function toCsv(rows, delimiter = ',') {
const cell = (v) => {
const s = String(v ?? '');
return s === '' ? '' : new RegExp(`["\\n\\r${delimiter === '\t' ? '\\t' : delimiter}]|^\\s|\\s$`).test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return rows.map((r) => r.map(cell).join(delimiter)).join('\r\n') + '\r\n';
}
// A property value as plain text in a CSV cell (lists are joined with ", ").
const cellOf = (rec, p) => {
const v = read(rec.props ? rec.props[p.key] : undefined, p.type);
return Array.isArray(v) ? v.join(', ') : v === null ? '' : typeof v === 'boolean' ? String(v) : String(v);
};
export function databaseCsv(db, records) {
const rows = [['Name', ...db.properties.map((p) => p.name)]];
for (const r of records) rows.push([r.title, ...db.properties.map((p) => cellOf(r, p))]);
return toCsv(rows);
}
/* import */
const TRUE = /^(true|yes|y|x|1|on|checked|✓|✔)$/i;
const FALSE = /^(false|no|n|0|off|unchecked|)$/i;
// "$1,234.50", "12%", "1 234,5" -> a number, or null
export function parseNumber(s) {
let t = String(s ?? '').trim().replace(/[$€£¥%\s]/g, '');
if (!t || !/\d/.test(t)) return null;
if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(t)) t = t.replace(/\./g, '').replace(',', '.'); // 1.234,5
else if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(t)) t = t.replace(/,/g, ''); // 1,234.5 and 5,200
else if (/^-?\d+,\d{1,2}$/.test(t)) t = t.replace(',', '.'); // 12,5
else t = t.replace(/,/g, '');
return /^-?\d*\.?\d+(e[-+]?\d+)?$/i.test(t) ? Number(t) : null;
}
const pad = (n) => String(n).padStart(2, '0');
const iso = (y, m, d) => {
const t = new Date(Date.UTC(y, m - 1, d));
return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d ? `${y}-${pad(m)}-${pad(d)}` : '';
};
// 2026-03-01, 2026-03-01T10:00, 3/1/2026, 1.3.2026, "Mar 1, 2026" -> YYYY-MM-DD, or ''
export function parseDate(s) {
const t = String(s ?? '').trim();
let m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/);
if (m) return iso(+m[1], +m[2], +m[3]);
m = t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) return iso(+m[3], +m[2], +m[1]);
m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (m) return +m[1] > 12 ? iso(+m[3], +m[2], +m[1]) : iso(+m[3], +m[1], +m[2]);
if (/[a-z]{3}/i.test(t) && /\d{4}/.test(t)) {
const d = new Date(t + ' 12:00');
if (!isNaN(d)) return iso(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
return '';
}
const splitList = (s) => String(s ?? '').split(/[,;]/).map((x) => x.trim()).filter(Boolean);
// The type that suits a column's values.
export function inferType(values) {
const vals = values.map((v) => String(v ?? '').trim()).filter(Boolean);
if (vals.length < 1) return { type: 'text' };
if (vals.every((v) => /^https?:\/\/\S+$/i.test(v))) return { type: 'url' };
if (vals.every((v) => /^(true|false|yes|no|x|y|n|0|1)$/i.test(v)) && vals.some((v) => !/^[01]$/.test(v))) return { type: 'checkbox' };
if (vals.every((v) => parseDate(v))) return { type: 'date' };
if (vals.every((v) => parseNumber(v) !== null)) return { type: 'number' };
const distinct = [...new Set(vals)];
if (vals.length >= 3 && distinct.length <= 12 && distinct.length <= vals.length * 0.6 && distinct.every((v) => v.length <= 30 && !/[,;]/.test(v))) return { type: 'select' };
const parts = [...new Set(vals.flatMap(splitList))];
if (vals.length >= 3 && vals.filter((v) => /[,;]/.test(v)).length >= vals.length * 0.2 && parts.length <= 15 && parts.every((v) => v.length <= 30 && parseNumber(v) === null)) return { type: 'multiselect' };
return { type: 'text' };
}
// Which database property each CSV column goes to.
// returns [{ index, header, target: 'title' | 'skip' | 'existing' | 'new', key?, name, type }]
export function autoMap(headers, rows, existing = []) {
existing = existing.filter((p) => p.type !== 'formula'); // computed: nothing to import into
const used = new Set();
const cols = headers.map((h, index) => ({ index, header: String(h || '').trim() }));
let titleAt = cols.findIndex((c) => /^(name|title|page|record)$/i.test(c.header));
if (titleAt < 0) titleAt = cols.findIndex((c) => c.header !== '' || rows.some((r) => String(r[c.index] ?? '').trim()));
return cols.map((c) => {
const values = rows.map((r) => r[c.index]);
const empty = !values.some((v) => String(v ?? '').trim());
if (c.index === titleAt) return { ...c, target: 'title', name: c.header || 'Name', type: 'text' };
if (empty && !c.header) return { ...c, target: 'skip', name: '', type: 'text' };
const hit = existing.find((p) => p.name.toLowerCase() === c.header.toLowerCase() && !used.has(p.key));
if (hit) { used.add(hit.key); return { ...c, target: 'existing', key: hit.key, name: hit.name, type: hit.type }; }
return { ...c, target: empty ? 'skip' : 'new', name: c.header || `Column ${c.index + 1}`, type: inferType(values).type };
});
}
// What an import creates, from the CSV rows (header row excluded) and the mapping (see autoMap):
// properties: the new properties, with options gathered from the values
// options: { key: [new options] } for existing select properties that gain some
// items: [{ title, meta }] one per row, meta holding stored values (strings or lists)
export function planImport({ rows, columns, existing = [] }) {
const taken = existing.map((p) => p.key);
const writable = existing.filter((p) => p.type !== 'formula');
const properties = [];
const byIndex = new Map();
for (const c of columns) {
if (c.target === 'new') {
const type = TYPES.some((t) => t.type === c.type) ? c.type : 'text';
const p = { key: keyFor(c.name, [...taken, ...properties.map((x) => x.key)]), name: c.name, type };
if (type === 'select' || type === 'multiselect') p.options = [];
properties.push(p);
byIndex.set(c.index, p);
} else if (c.target === 'existing') {
const p = writable.find((x) => x.key === c.key);
if (p) byIndex.set(c.index, p);
}
}
const options = {};
const addOption = (p, o) => {
if (properties.includes(p)) { if (!p.options.includes(o)) p.options.push(o); return; } // a new property collects its own
if ((p.options || []).includes(o)) return;
const list = (options[p.key] ||= []);
if (!list.includes(o)) list.push(o);
};
const titleCol = columns.find((c) => c.target === 'title');
const items = rows.map((r) => {
const meta = {};
for (const [index, p] of byIndex) {
const raw = String(r[index] ?? '').trim();
if (!raw) continue;
let v;
switch (p.type) {
case 'number': { const n = parseNumber(raw); v = n === null ? '' : write(n, 'number'); break; }
case 'checkbox': v = TRUE.test(raw) ? 'true' : FALSE.test(raw) ? 'false' : ''; break;
case 'date': v = parseDate(raw); break;
case 'select': v = raw.replace(/,/g, ' '); break;
default: v = isListType(p.type) ? splitList(raw.replace(/\s*\n\s*/g, ', ')).map((x) => x.replace(/,/g, ' ')) : raw.replace(/\s*\n\s*/g, ' ');
}
if (v === '' || (Array.isArray(v) && !v.length)) continue;
if (p.type === 'select') addOption(p, v);
if (p.type === 'multiselect') for (const o of v) addOption(p, o);
meta[p.key] = v;
}
const title = String(titleCol ? r[titleCol.index] ?? '' : '').replace(/\s+/g, ' ').trim() || 'Untitled';
return { title, meta };
});
return { properties, options, items };
}
+47
View File
@@ -0,0 +1,47 @@
// Where daily notes go: <folder>/YYYY-MM-DD. The folder is a page (and its sub-pages), by path, like "Private/Daily".
export const DEFAULT_FOLDER = 'Daily';
// A folder path typed by the user, tidied up ("" when there is nothing usable).
export function cleanFolder(text) {
return String(text || '')
.split(/[\\/]+/)
.map((p) => p.replace(/[<>:"|?*\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim().replace(/^\.+$/, ''))
.filter(Boolean)
.join('/');
}
// With no folder chosen: the top-level "Daily" if it is there; otherwise the one page called "Daily" somewhere else
// (so a "Daily" kept inside another folder is found, not duplicated at the top); otherwise "Daily".
export function autoFolder(pages) {
const there = pages.filter((p) => p.exists && !/\.canvas$/i.test(p.id));
if (there.some((p) => p.id === DEFAULT_FOLDER)) return DEFAULT_FOLDER;
const named = there.filter((p) => p.title.toLowerCase() === 'daily');
return named.length === 1 ? named[0].id : DEFAULT_FOLDER;
}
// A date as text: YYYY MM DD (numbers), D (day, no zero), dddd (weekday name) and MMMM (month name), in `locale`.
// Characters a file name can't have are never produced, so the result is safe as a page name.
export function formatDate(date, format = 'YYYY-MM-DD', locale) {
const z = (n) => String(n).padStart(2, '0');
const names = (opts) => date.toLocaleDateString(locale || undefined, opts);
const parts = {
YYYY: String(date.getFullYear()),
MMMM: names({ month: 'long' }),
MM: z(date.getMonth() + 1),
DD: z(date.getDate()),
dddd: names({ weekday: 'long' }),
D: String(date.getDate())
};
return format.replace(/YYYY|MMMM|MM|DD|dddd|D/g, (t) => parts[t]).replace(/[\\/<>:"|?*\u0000-\u001f]/g, '-');
}
export function dailyId(folder, date = new Date(), format = 'YYYY-MM-DD', locale) {
return `${folder || DEFAULT_FOLDER}/${formatDate(date, format, locale)}`;
}
// The text a new daily note starts with: the template with {{date}} (as in the note's name) and {{weekday}} filled in.
export function dailyBody(template, date = new Date(), format = 'YYYY-MM-DD', locale) {
return String(template || '')
.replace(/\{\{\s*date\s*\}\}/gi, formatDate(date, format, locale))
.replace(/\{\{\s*weekday\s*\}\}/gi, formatDate(date, 'dddd', locale));
}
+576
View File
@@ -0,0 +1,576 @@
// Databases stored as plain Markdown.
//
// ---
// ink-database: basic <- front matter marks the page as a database
// ---
//
// %% ink-database
// { "properties": [...], "views": [...] } <- the schema: typed properties and the saved views
// %%
//
// Every record is a sub-page of the database. Its properties are ordinary front matter keys on that page
// (`status: In progress`, `due: 2026-03-01`, `done: true`, `people: [Ana, Ben]`), so records stay normal notes
// and Obsidian reads them as properties. The schema only says which keys are columns and how to read them.
import { tagHex } from './tagColors.js';
import { evaluateFormula, FormulaError, show as showValue } from './formula.js';
export const DB_KEY = 'ink-database';
export const isDatabase = (meta) => !!(meta && meta[DB_KEY]);
export const TYPES = [
{ type: 'text', label: 'Text', icon: 'file' },
{ type: 'number', label: 'Number', icon: 'hash' },
{ type: 'select', label: 'Select', icon: 'tag' },
{ type: 'multiselect', label: 'Multi-select', icon: 'list' },
{ type: 'date', label: 'Date', icon: 'calendar' },
{ type: 'checkbox', label: 'Checkbox', icon: 'check2' },
{ type: 'people', label: 'People', icon: 'users' },
{ type: 'files', label: 'Files', icon: 'paperclip' },
{ type: 'url', label: 'URL', icon: 'link' },
{ type: 'relation', label: 'Relation', icon: 'graph' }, // titles of other records in this database (timeline dependencies)
{ type: 'formula', label: 'Formula', icon: 'sparkle' } // computed from the record's other values; never stored (formula.js)
];
export const VIEW_TYPES = [
{ type: 'table', label: 'Table', icon: 'table' },
{ type: 'board', label: 'Board', icon: 'board' },
{ type: 'calendar', label: 'Calendar', icon: 'calendar' },
{ type: 'gallery', label: 'Gallery', icon: 'grid' },
{ type: 'timeline', label: 'Timeline', icon: 'chart' },
{ type: 'list', label: 'List', icon: 'list' }
];
export const typeInfo = (t) => TYPES.find((x) => x.type === t) || TYPES[0];
const LISTY = new Set(['multiselect', 'people', 'files', 'relation']);
export const isListType = (t) => LISTY.has(t);
// Keys that already mean something on a page.
const RESERVED = new Set(['icon', 'cover', 'tags', 'created', DB_KEY, 'kanban-plugin', 'title']);
export const isReservedKey = (k) => RESERVED.has(k);
let seq = 0;
export const uid = (p = 'v') => p + Date.now().toString(36) + (++seq).toString(36) + Math.random().toString(36).slice(2, 5);
// "Due date" -> "due-date", made unique among `taken`.
export function keyFor(name, taken = []) {
let base = String(name || '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '-').replace(/^-+|-+$/g, '') || 'property';
if (RESERVED.has(base)) base += '-2';
let key = base, n = 2;
while (taken.includes(key) || RESERVED.has(key)) key = `${base}-${n++}`;
return key;
}
export const prop = (name, type = 'text', taken = []) => ({
key: keyFor(name, taken), name, type, ...(type === 'select' || type === 'multiselect' ? { options: [] } : {})
});
export const view = (type = 'table', name) => ({
id: uid('v'), name: name || (VIEW_TYPES.find((v) => v.type === type) || VIEW_TYPES[0]).label, type,
sort: null, filters: [], hidden: [], widths: {}, calc: {}
});
export function defaultDatabase() {
const status = { ...prop('Status', 'select'), options: ['Not started', 'In progress', 'Done'] };
const date = prop('Date', 'date');
return {
properties: [status, date],
views: [
{ ...view('table', 'Table') },
{ ...view('calendar', 'Calendar'), dateProp: date.key }
]
};
}
/* the file body */
const BLOCK = /^[ \t]*%%[ \t]*ink-database[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*%%[ \t]*$/m;
function clean(s) {
const out = { properties: [], views: [] };
const keys = [];
for (const p of Array.isArray(s && s.properties) ? s.properties : []) {
if (!p || typeof p.key !== 'string' || !p.key || keys.includes(p.key)) continue;
keys.push(p.key);
const type = TYPES.some((t) => t.type === p.type) ? p.type : 'text';
const q = { key: p.key, name: String(p.name || p.key), type };
if (type === 'select' || type === 'multiselect') {
q.options = [...new Set((Array.isArray(p.options) ? p.options : []).map(String).filter(Boolean))];
const colors = Object.entries(p.colors && typeof p.colors === 'object' ? p.colors : {}).filter(([k, c]) => k && tagHex(c));
if (colors.length) q.colors = Object.fromEntries(colors);
}
if (type === 'number') {
if (NUMBER_FORMATS.some((f) => f.value === p.format && f.value !== 'number')) q.format = p.format;
if (q.format === 'bar' && Number(p.max) > 0) q.max = Number(p.max);
}
if (type === 'formula') {
q.expression = typeof p.expression === 'string' ? p.expression : '';
if (NUMBER_FORMATS.some((f) => f.value === p.format && f.value !== 'number' && f.value !== 'bar')) q.format = p.format;
}
out.properties.push(q);
}
for (const v of Array.isArray(s && s.views) ? s.views : []) {
if (!v || typeof v !== 'object') continue;
const type = VIEW_TYPES.some((t) => t.type === v.type) ? v.type : 'table';
out.views.push({
...v, id: typeof v.id === 'string' && v.id ? v.id : uid('v'), type, name: String(v.name || type),
filters: Array.isArray(v.filters) ? v.filters.filter((f) => f && f.key) : [],
hidden: Array.isArray(v.hidden) ? v.hidden.map(String) : [],
sort: v.sort && v.sort.key ? { key: String(v.sort.key), dir: v.sort.dir === 'desc' ? 'desc' : 'asc' } : null,
calc: Object.fromEntries(Object.entries(v.calc && typeof v.calc === 'object' ? v.calc : {}).filter(([k, c]) => k && typeof c === 'string' && AGG_KINDS.has(c))),
widths: Object.fromEntries(Object.entries(v.widths && typeof v.widths === 'object' ? v.widths : {}).filter(([, w]) => Number.isFinite(w)).map(([k, w]) => [k, Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(w)))]))
});
}
if (!out.views.length) out.views.push(view('table'));
return out;
}
// -> { properties, views, rest } (rest: any text outside the schema block, kept as is)
export function parseDatabase(body) {
const text = String(body || '').replace(/\r\n?/g, '\n');
const m = text.match(BLOCK);
if (!m) return { ...(text.trim() ? clean(null) : defaultDatabase()), rest: text.trim() ? text : '' };
let data = null;
try { data = JSON.parse(m[1]); } catch { /* damaged: fall back to a plain table */ }
const rest = (text.slice(0, m.index) + text.slice(m.index + m[0].length)).replace(/^\n+|\n+$/g, '').replace(/\n{3,}/g, '\n\n');
return { ...clean(data), rest };
}
export function databaseToMd(db) {
const data = { properties: db.properties, views: db.views };
const block = `%% ink-database\n${JSON.stringify(data, null, 2)}\n%%\n`;
return db.rest ? `${block}\n${db.rest}\n` : block;
}
export const MIN_WIDTH = 80, MAX_WIDTH = 720;
export const NUMBER_FORMATS = [
{ value: 'number', label: 'Number' },
{ value: 'commas', label: 'Number with commas' },
{ value: 'percent', label: 'Percent' },
{ value: 'dollar', label: 'Dollar' },
{ value: 'euro', label: 'Euro' },
{ value: 'bar', label: 'Progress bar' }
];
// A number as the property's format shows it (the file always holds the plain number).
export function formatNumber(n, p) {
if (n === null || n === undefined || !Number.isFinite(n)) return '';
const f = (opts) => n.toLocaleString('en-US', { maximumFractionDigits: 10, ...opts });
switch (p && p.format) {
case 'commas': return f();
case 'percent': return `${f()}%`;
case 'dollar': return f({ style: 'currency', currency: 'USD', maximumFractionDigits: 2 });
case 'euro': return f({ style: 'currency', currency: 'EUR', maximumFractionDigits: 2 });
default: return String(n);
}
}
// How full a progress bar is, 0..1 (the property's max, default 100).
export const barFraction = (n, p) => (n === null || !Number.isFinite(n) ? 0 : Math.max(0, Math.min(1, n / (p && p.max > 0 ? p.max : 100))));
/* values
A record's `props` holds what the file says: a string, or a list of strings. `read` turns that into the value
the property's type means, `write` turns an edited value back into what goes in the file. */
const toList = (v) => (Array.isArray(v) ? v.map(String).filter(Boolean) : v === undefined || v === null || v === '' ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean));
export function read(raw, type) {
switch (type) {
case 'number': {
if (raw === undefined || raw === null || raw === '' || Array.isArray(raw)) return null;
const n = Number(String(raw).replace(',', '.'));
return Number.isFinite(n) ? n : null;
}
case 'checkbox': return /^(true|yes|y|x|1|on)$/i.test(String(raw ?? '').trim());
case 'multiselect': case 'people': case 'files': case 'relation': return toList(raw);
default: return Array.isArray(raw) ? raw.join(', ') : raw === undefined || raw === null ? '' : String(raw);
}
}
export function write(value, type) {
switch (type) {
case 'number': return value === null || value === '' || value === undefined || !Number.isFinite(Number(value)) ? '' : String(Number(value));
case 'checkbox': return value ? 'true' : 'false';
case 'multiselect': case 'people': case 'files': case 'relation': return toList(value);
default: return String(value ?? '').trim();
}
}
export const isEmpty = (value, type) => (type === 'checkbox' ? false : Array.isArray(value) ? !value.length : value === null || value === '' || value === undefined);
// A date as written: YYYY-MM-DD (a time after it is ignored).
export const dayOf = (s) => { const m = String(s || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[1]}-${m[2]}-${m[3]}` : ''; };
export const dayNum = (iso) => { const m = dayOf(iso); return m ? Math.round(Date.UTC(+m.slice(0, 4), +m.slice(5, 7) - 1, +m.slice(8, 10)) / 864e5) : null; };
export const isoOfDay = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
export const todayIso = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
// What a record has in a column: the page's title is the `title` column.
export function valueOf(rec, p) {
if (p === 'title') return rec.title;
return read(rec.props ? rec.props[p.key] : undefined, p.type);
}
/* filters */
export const OPS = {
text: ['contains', 'is', 'is not', 'is empty', 'is not empty'],
number: ['=', '≠', '>', '<', '≥', '≤', 'is empty', 'is not empty'],
select: ['is', 'is not', 'is empty', 'is not empty'],
multiselect: ['contains', 'does not contain', 'is empty', 'is not empty'],
date: ['is', 'before', 'after', 'is empty', 'is not empty'],
checkbox: ['is checked', 'is unchecked'],
formula: ['is', 'is not', 'contains', '>', '<', '≥', '≤', 'is empty', 'is not empty']
};
export const opsFor = (type) => OPS[type] || (isListType(type) ? OPS.multiselect : OPS.text);
export const needsValue = (op) => !['is empty', 'is not empty', 'is checked', 'is unchecked'].includes(op);
export function matches(rec, f, props) {
const p = f.key === 'title' ? { key: 'title', type: 'text' } : props.find((x) => x.key === f.key);
if (!p) return true;
const v = f.key === 'title' ? rec.title : valueOf(rec, p);
const want = String(f.value ?? '');
const low = (s) => String(s).toLowerCase();
switch (f.op) {
case 'is empty': return isEmpty(v, p.type);
case 'is not empty': return !isEmpty(v, p.type);
case 'is checked': return v === true;
case 'is unchecked': return v === false;
}
if (!want && needsValue(f.op)) return true; // a half-made filter lets everything through
if (p.type === 'formula' && ['>', '<', '≥', '≤'].includes(f.op)) {
const a = Number(v), b = Number(want);
if (v === '' || !Number.isFinite(a) || !Number.isFinite(b)) return false;
return { '>': a > b, '<': a < b, '≥': a >= b, '≤': a <= b }[f.op];
}
if (p.type === 'number') {
const n = Number(want);
if (v === null || !Number.isFinite(n)) return false;
return { '=': v === n, '≠': v !== n, '>': v > n, '<': v < n, '≥': v >= n, '≤': v <= n }[f.op] ?? true;
}
if (p.type === 'date') {
const a = dayNum(v), b = dayNum(want === 'today' ? todayIso() : want);
if (a === null || b === null) return false;
return { is: a === b, before: a < b, after: a > b }[f.op] ?? true;
}
if (Array.isArray(v)) {
const has = v.some((x) => low(x) === low(want)) || (f.op === 'contains' && v.some((x) => low(x).includes(low(want))));
return f.op === 'does not contain' ? !has : has;
}
const s = String(v);
switch (f.op) {
case 'contains': return low(s).includes(low(want));
case 'is': return low(s) === low(want);
case 'is not': return low(s) !== low(want);
}
return true;
}
const cmp = (a, b) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }));
// Records that pass the view's filters, in its sort order (empty values last either way; title as the tie-breaker).
export function select(records, v, props) {
let out = records.filter((r) => (v.filters || []).every((f) => matches(r, f, props)));
const s = v.sort;
const sp = s && (s.key === 'title' ? 'title' : props.find((p) => p.key === s.key));
const by = (r) => {
const x = sp === 'title' ? r.title : sp ? valueOf(r, sp) : '';
return Array.isArray(x) ? x.join(', ') : x;
};
const type = sp && sp !== 'title' ? sp.type : 'text';
out = [...out].sort((a, b) => {
if (sp) {
const x = by(a), y = by(b);
const ex = isEmpty(x, type) || (type === 'checkbox' && false), ey = isEmpty(y, type);
if (ex !== ey) return ex ? 1 : -1;
if (!ex) {
// options sort in the order they're listed, not alphabetically
let d = 0;
if (type === 'select' && Array.isArray(sp.options)) d = sp.options.indexOf(x) - sp.options.indexOf(y);
else d = type === 'checkbox' ? Number(x) - Number(y) : type === 'formula' && x !== '' && y !== '' && Number.isFinite(Number(x)) && Number.isFinite(Number(y)) ? Number(x) - Number(y) : cmp(x, y);
if (d) return s.dir === 'desc' ? -d : d;
}
}
return a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' });
});
return out;
}
/* board */
// Columns for a board grouped by a select (options in order, then what's left over) or checkbox property.
export function columnsFor(records, p) {
if (!p) return [{ id: '', title: 'All', value: '', records }];
if (p.type === 'checkbox') {
return [
{ id: 'false', title: 'Unchecked', value: false, records: records.filter((r) => !valueOf(r, p)) },
{ id: 'true', title: 'Checked', value: true, records: records.filter((r) => valueOf(r, p)) }
];
}
const known = p.options || [];
const values = (r) => { const v = valueOf(r, p); return Array.isArray(v) ? v : v ? [v] : []; };
const extra = [];
for (const r of records) for (const x of values(r)) if (!known.includes(x) && !extra.includes(x)) extra.push(x);
const names = [...known, ...extra];
const cols = names.map((n) => ({ id: 'o:' + n, title: n, value: n, records: records.filter((r) => values(r).includes(n)) }));
return [{ id: '', title: `No ${p.name.toLowerCase()}`, value: '', records: records.filter((r) => !values(r).length) }, ...cols];
}
/* calendar */
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Day names for a week that starts on Monday (the default) or Sunday.
export const weekdayNames = (sunday = false) => (sunday ? ['Sun', ...WEEKDAYS.slice(0, 6)] : WEEKDAYS);
// The seven days (Monday first, or Sunday first) of the week that holds `iso`.
export function weekOf(iso, sunday = false) {
const n = dayNum(iso);
if (n === null) return [];
const start = n - ((new Date(n * 864e5).getUTCDay() + (sunday ? 0 : 6)) % 7);
return Array.from({ length: 7 }, (_, d) => { const i = isoOfDay(start + d); return { iso: i, day: +i.slice(8), inMonth: true }; });
}
// The weeks (Monday first, or Sunday first) that show month `month` (0-11) of `year`: always whole weeks, 5 or 6 of them.
export function monthGrid(year, month, sunday = false) {
const first = Math.round(Date.UTC(year, month, 1) / 864e5);
const last = Math.round(Date.UTC(year, month + 1, 0) / 864e5);
const lead = (new Date(first * 864e5).getUTCDay() + (sunday ? 0 : 6)) % 7; // the first day of the week = 0
const start = first - lead;
const weeks = Math.ceil((lead + (last - first + 1)) / 7);
const out = [];
for (let w = 0; w < weeks; w++) {
out.push(Array.from({ length: 7 }, (_, d) => {
const iso = isoOfDay(start + w * 7 + d);
return { iso, day: +iso.slice(8), inMonth: +iso.slice(5, 7) - 1 === month };
}));
}
return out;
}
// Records by the days they fall on. With an end date property a record covers every day from start to end.
export function byDay(records, startProp, endProp) {
const map = new Map();
if (!startProp) return map;
const put = (iso, r) => { if (!map.has(iso)) map.set(iso, []); map.get(iso).push(r); };
for (const r of records) {
const a = dayNum(valueOf(r, startProp));
if (a === null) continue;
let b = endProp ? dayNum(valueOf(r, endProp)) : null;
if (b === null || b < a || b - a > 366) b = a;
for (let n = a; n <= b; n++) put(isoOfDay(n), r);
}
return map;
}
/* timeline */
// Bars for records with a start date (end = the end property, else a single day). Returns the day span to draw.
export function timeline(records, startProp, endProp) {
const bars = [];
if (!startProp) return { bars, from: 0, to: 0, undated: records };
const undated = [];
for (const r of records) {
const a = dayNum(valueOf(r, startProp));
if (a === null) { undated.push(r); continue; }
let b = endProp ? dayNum(valueOf(r, endProp)) : null;
if (b === null || b < a) b = a;
bars.push({ rec: r, start: a, end: b });
}
bars.sort((x, y) => x.start - y.start || x.end - y.end || x.rec.title.localeCompare(y.rec.title));
if (!bars.length) { const t = dayNum(todayIso()); return { bars, from: t - 3, to: t + 40, undated }; }
const lo = Math.min(...bars.map((b) => b.start)), hi = Math.max(...bars.map((b) => b.end));
return { bars, from: lo - 2, to: Math.max(hi + 4, lo + 40), undated };
}
/* formulas
A formula property has no stored value: it is computed from the record's other values each time (see
formula.js). withFormulas() returns the records with those results filled into their `props` (as plain text, so
sorting, filtering and totals work like for any other property) and `fx`, the error message of any that failed. */
export function computeFormulas(properties, rec, today = todayIso()) {
const formulas = properties.filter((p) => p.type === 'formula');
const out = {};
if (!formulas.length) return out;
const byName = new Map();
for (const p of properties) { byName.set(p.name.toLowerCase(), p); if (!byName.has(p.key.toLowerCase())) byName.set(p.key.toLowerCase(), p); }
const memo = new Map();
const visiting = new Set();
const valueOfProp = (p) => {
if (p.type !== 'formula') return read(rec.props ? rec.props[p.key] : undefined, p.type);
if (memo.has(p.key)) {
const m = memo.get(p.key);
if (m.error) throw new FormulaError(m.error);
return m.value;
}
if (visiting.has(p.key)) throw new FormulaError(`“${p.name}” refers to itself`);
visiting.add(p.key);
try {
const expr = (p.expression || '').trim();
const value = expr ? evaluateFormula(expr, ctx) : null;
memo.set(p.key, { value });
return value;
} catch (e) {
const error = e instanceof FormulaError ? e.message : 'The formula could not be calculated';
memo.set(p.key, { error });
throw new FormulaError(error);
} finally {
visiting.delete(p.key);
}
};
const ctx = {
today,
prop(name) {
const low = String(name).trim().toLowerCase();
const p = byName.get(low);
if (p) return valueOfProp(p);
if (low === 'name' || low === 'title') return rec.title;
throw new FormulaError(`Unknown property “${name}”`);
}
};
for (const p of formulas) {
try {
const v = valueOfProp(p);
out[p.key] = { value: v, text: showValue(v) };
} catch (e) {
out[p.key] = { value: null, text: '', error: e.message };
}
}
return out;
}
export const hasFormulas = (properties) => properties.some((p) => p.type === 'formula');
export function withFormulas(records, properties, today) {
if (!hasFormulas(properties)) return records;
return records.map((r) => {
const c = computeFormulas(properties, r, today);
const props = { ...(r.props || {}) };
const fx = {};
for (const [k, v] of Object.entries(c)) {
props[k] = v.text;
if (v.error) fx[k] = v.error;
}
return { ...r, props, fx };
});
}
// A formula's result as shown (numbers in the property's format).
export function formulaDisplay(p, text) {
if (text === '' || text === null || text === undefined) return '';
const n = Number(text);
if (p && p.format && Number.isFinite(n) && String(text).trim() !== '' && !/^\d{4}-\d{2}-\d{2}/.test(text)) return formatNumber(n, p);
return String(text);
}
/* totals: what the table's "Calculate" row can show under a column */
export const AGGREGATES = [
{ value: 'count', label: 'Count all', for: 'any' },
{ value: 'filled', label: 'Count values', for: 'any' },
{ value: 'empty', label: 'Count empty', for: 'any' },
{ value: 'unique', label: 'Count unique values', for: 'any' },
{ value: 'percentFilled', label: 'Percent filled', for: 'any' },
{ value: 'sum', label: 'Sum', for: 'number' },
{ value: 'avg', label: 'Average', for: 'number' },
{ value: 'median', label: 'Median', for: 'number' },
{ value: 'min', label: 'Minimum', for: 'number' },
{ value: 'max', label: 'Maximum', for: 'number' },
{ value: 'range', label: 'Range', for: 'number' },
{ value: 'earliest', label: 'Earliest', for: 'date' },
{ value: 'latest', label: 'Latest', for: 'date' },
{ value: 'span', label: 'Date range (days)', for: 'date' },
{ value: 'checked', label: 'Checked', for: 'checkbox' },
{ value: 'unchecked', label: 'Unchecked', for: 'checkbox' },
{ value: 'percentChecked', label: 'Percent checked', for: 'checkbox' }
];
const AGG_KINDS = new Set(AGGREGATES.map((a) => a.value));
// the aggregates that make sense for a property type ('title' is text)
export function aggregatesFor(type) {
const kind = type === 'number' || type === 'formula' ? 'number' : type === 'date' ? 'date' : type === 'checkbox' ? 'checkbox' : null;
return AGGREGATES.filter((a) => a.for === 'any' || a.for === kind || (type === 'formula' && a.for === 'date'));
}
// -> { text, label } for `kind` over the typed values of one column. `p` is the property (for its number format).
export function aggregate(kind, values, type, p) {
const label = (AGGREGATES.find((a) => a.value === kind) || {}).label || '';
const total = values.length;
const filled = values.filter((v) => (type === 'checkbox' ? v === true : !isEmpty(v, type)));
const nums = () => values
.map((v) => (typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) && !/^\d{4}-\d{2}-\d{2}/.test(v) ? Number(v) : null))
.filter((n) => n !== null);
const dates = () => values.map((v) => dayNum(typeof v === 'string' ? v : '')).filter((n) => n !== null);
const fmt = (n) => (p && (p.type === 'number' || p.type === 'formula') && p.format && p.format !== 'bar' ? formatNumber(n, p) : String(Number(n.toFixed(2))));
const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : '0%');
let text = '';
switch (kind) {
case 'count': text = String(total); break;
case 'filled': text = String(type === 'checkbox' ? total : filled.length); break;
case 'empty': text = String(type === 'checkbox' ? 0 : total - filled.length); break;
case 'unique': text = String(new Set(filled.flatMap((v) => (Array.isArray(v) ? v : [v])).map((v) => String(v).toLowerCase())).size); break;
case 'percentFilled': text = pct(type === 'checkbox' ? total : filled.length, total); break;
case 'sum': { const n = nums(); text = n.length ? fmt(n.reduce((a, b) => a + b, 0)) : '0'; break; }
case 'avg': { const n = nums(); text = n.length ? fmt(n.reduce((a, b) => a + b, 0) / n.length) : ''; break; }
case 'median': {
const n = nums().sort((a, b) => a - b);
text = n.length ? fmt(n.length % 2 ? n[(n.length - 1) / 2] : (n[n.length / 2 - 1] + n[n.length / 2]) / 2) : '';
break;
}
case 'min': { const n = nums(); text = n.length ? fmt(Math.min(...n)) : ''; break; }
case 'max': { const n = nums(); text = n.length ? fmt(Math.max(...n)) : ''; break; }
case 'range': { const n = nums(); text = n.length ? fmt(Math.max(...n) - Math.min(...n)) : ''; break; }
case 'earliest': { const d = dates(); text = d.length ? isoOfDay(Math.min(...d)) : ''; break; }
case 'latest': { const d = dates(); text = d.length ? isoOfDay(Math.max(...d)) : ''; break; }
case 'span': { const d = dates(); text = d.length ? String(Math.max(...d) - Math.min(...d)) : ''; break; }
case 'checked': text = String(values.filter((v) => v === true).length); break;
case 'unchecked': text = String(values.filter((v) => v !== true).length); break;
case 'percentChecked': text = pct(values.filter((v) => v === true).length, total); break;
default: text = '';
}
return { text, label };
}
/* embeds
![[Tasks#Board|sort=due:desc; filter=status:is:Done; hide=owner]] shows a database inside a page. `#Board` picks
one of its saved views; the |alias holds this embed's own sort, filters and hidden properties, which sit on top of
the view's and never change the database itself. */
const enc = (s) => encodeURIComponent(String(s)).replace(/%20/g, ' ');
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
export function parseEmbedAlias(alias) {
const out = { view: '', sort: null, filters: [], hidden: [] };
for (const part of String(alias || '').split(';')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim().toLowerCase(), v = part.slice(i + 1).trim();
if (k === 'view') out.view = dec(v);
else if (k === 'sort') {
const [key, dir] = v.split(':');
if (key) out.sort = { key: dec(key), dir: dir === 'desc' ? 'desc' : 'asc' };
} else if (k === 'filter') {
const [key, op, ...rest] = v.split(':');
if (key && op) out.filters.push({ key: dec(key), op: dec(op), value: dec(rest.join(':')) });
} else if (k === 'hide') out.hidden.push(...v.split(',').map(dec).filter(Boolean));
}
return out;
}
export function embedAlias(o) {
const parts = [];
if (o.view) parts.push('view=' + enc(o.view));
if (o.sort && o.sort.key) parts.push(`sort=${enc(o.sort.key)}:${o.sort.dir === 'desc' ? 'desc' : 'asc'}`);
for (const f of o.filters || []) parts.push(`filter=${enc(f.key)}:${enc(f.op)}:${enc(f.value ?? '')}`);
if ((o.hidden || []).length) parts.push('hide=' + o.hidden.map(enc).join(','));
return parts.join('; ');
}
/* files */
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i;
export const firstImage = (rec, props) => {
for (const p of props) {
if (p.type !== 'files') continue;
const f = valueOf(rec, p).find((x) => IMAGE_EXT.test(x));
if (f) return f;
}
return null;
};
// A colour for an option: stable for its name, one of the tag colours.
const HUES = ['#e5534b', '#e8883a', '#d9b130', '#44ad6e', '#3aa6c4', '#5b8def', '#9a6de0', '#e06fa6'];
export function optionColor(name, p) {
const chosen = p && p.colors && tagHex(p.colors[name]);
if (chosen) return chosen;
let h = 5;
for (const ch of String(name)) h = (h * 33 + ch.charCodeAt(0)) | 0;
return HUES[Math.abs(h) % HUES.length];
}
// The properties a record shows beside its title (those the view doesn't hide), in schema order.
export const visibleProps = (db, v) => db.properties.filter((p) => !(v.hidden || []).includes(p.key));
+11
View File
@@ -0,0 +1,11 @@
// Mounts a live database view (Database.svelte in its embedded mode) into an ![[embed]] block of the editor.
import { mount, unmount } from 'svelte';
import Database from './components/Database.svelte';
// -> a function that removes it again
export function mountDatabaseEmbed(el, { id, body, alias, setAlias }) {
const instance = mount(Database, { target: el, props: { id, body, embed: { alias, setAlias } } });
return () => {
try { unmount(instance); } catch { /* already gone */ }
};
}
+1458 -94
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
// What createEditor (editor.js) needs from the app, for the page the store has open: link lookup, copy, images.
// Shared by the page editor and the card editor on a canvas.
import { store } from './state.svelte.js';
import { api, errorText } from './api.js';
import { toast } from './toast.svelte.js';
import { parseMarkdown } from './markdown.js';
import { headingsOf } from './embed.js';
import { isDatabase, parseDatabase } from './database.js';
import { mountDatabaseEmbed } from './databaseEmbed.js';
// pageId: the page the editor belongs to when that is not the one the store has open (the side pane)
export function editorHooks(pageId = null) {
const here = () => pageId || store.currentId;
return {
recentKinds: () => store.slashRecent,
rememberKind: (t) => store.rememberSlash(t),
prefs: () => store.prefs,
onCopy: (text) =>
navigator.clipboard.writeText(text).then(
() => toast('Code copied', 'copy'),
() => toast('Copying is blocked here. Select the text instead.', 'x')
),
links: {
pages: () => store.pages,
resolve: (ref) => store.resolveLink(ref),
refFor: (id) => store.refFor(id),
open: (ref) => store.openLink(ref),
create: (title) => store.createLinked(title),
current: here,
// headings of a page, for [[Page#… suggestions
async headings(id) {
if (id === store.currentId && !pageId) return store.headings;
try {
const r = await api.read(id);
// a database has no headings: its views take their place (![[Tasks#Board]] shows that view)
if (isDatabase(r.meta)) return parseDatabase(r.body).views.map((v) => ({ id: v.id, level: 2, text: v.name }));
return headingsOf(parseMarkdown(r.body));
} catch { return []; }
},
mountDatabase: (el, props) => mountDatabaseEmbed(el, props),
embed: (ref) => store.loadEmbed(ref)
},
images: {
url: (src) => api.mediaUrl(here(), src),
async save(file, name) {
try {
return await api.saveImage(here(), name, new Uint8Array(await file.arrayBuffer()));
} catch (err) {
toast('Could not add the image: ' + errorText(err), 'x');
return null;
}
},
async pick() {
try {
return await api.pickImage(here());
} catch (err) {
toast('Could not add the image: ' + errorText(err), 'x');
return null;
}
}
}
};
}
+95
View File
@@ -0,0 +1,95 @@
// Embeds: ![[Page]] shows another note's content inline, ![[Page#Heading]] just that section.
// Pure helpers (no DOM), so they can be tested on their own:
// headingsOf(blocks) the headings of a page, for pickers and the outline
// sectionOf(blocks, text) a heading plus everything under it, up to the next heading of the same or a higher level
// staticHtml(blocks, opts) read-only HTML for a list of blocks, styled by the same CSS as the editor's blocks
import katex from 'katex';
import { ic } from './icons.js';
import { esc, htmlText } from './text.js';
import { highlight } from './highlight.js';
import { linkChip } from './markdown.js';
import { matchesHeading } from './links.js';
const isHeading = (b) => b.type === 'h1' || b.type === 'h2' || b.type === 'h3';
export const headingText = (b) => htmlText(b.html).replace(/\s+/g, ' ').trim();
export const headingsOf = (blocks) =>
blocks.filter(isHeading).map((b) => ({ id: b.id, level: +b.type[1], text: headingText(b) })).filter((h) => h.text);
export function sectionOf(blocks, heading) {
const i = blocks.findIndex((b) => isHeading(b) && matchesHeading(heading, headingText(b)));
if (i < 0) return null;
const level = +blocks[i].type[1];
let j = i + 1;
while (j < blocks.length && !(isHeading(blocks[j]) && +blocks[j].type[1] <= level)) j++;
return blocks.slice(i, j);
}
const CALLOUT_ICONS = { note: 'leaf', tip: 'bulb', warning: 'bell', caution: 'bolt' };
// Blocks use the editor's class names but are never editable, and carry no `.block` class, so the editor's own
// queries (drag, undo, find) don't mistake them for the page's blocks.
// embed(ref) -> { title, html } shows an ![[embed]] block's note in place (null: just a chip for it)
export function staticHtml(blocks, { imageUrl = (src) => src, embed = null } = {}) {
let html = '';
const render = (b) => {
const e = embed && b.type === 'embed' ? embed(b.ref) : null;
return e
? `<div class="eb b-embed"><div class="emb open"><div class="emb-head"><button class="emb-title" data-ref="${esc(b.ref)}" title="Open this note">${ic('file')}<span>${esc(e.title)}</span></button></div><div class="emb-body">${e.html}</div></div></div>`
: one(b, imageUrl);
};
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
if (!b.col) { html += render(b); continue; }
// blocks of one column group sit side by side
let end = i;
while (end + 1 < blocks.length && blocks[end + 1].col && blocks[end + 1].col.g === b.col.g) end++;
const byCol = new Map();
for (const x of blocks.slice(i, end + 1)) {
if (!byCol.has(x.col.i)) byCol.set(x.col.i, []);
byCol.get(x.col.i).push(x);
}
const keys = [...byCol.keys()].sort((a, b2) => a - b2);
const cols = keys.map((k) => `<div class="eb-col">${byCol.get(k).map(render).join('')}</div>`);
const ws = keys.map((k) => byCol.get(k)[0].col.w);
const tpl = ws.every((w) => w > 0) ? `;grid-template-columns:${ws.map((w) => `minmax(0,${w}fr)`).join(' ')}` : '';
html += `<div class="eb-cols" style="--n:${cols.length}${tpl}">${cols.join('')}</div>`;
i = end;
}
return html;
}
function one(b, imageUrl) {
const c = `<div class="c">${b.html || ''}</div>`;
const row = (inner, cls = '') => `<div class="eb b-${b.type}${cls}"${b.indent ? ` style="margin-left:${b.indent * 24}px"` : ''}>${inner}</div>`;
switch (b.type) {
case 'p': return htmlText(b.html).trim() ? row(c) : '';
case 'bullet': return row(`<span class="bul"></span>${c}`);
case 'number': return row(`<span class="num"></span>${c}`);
case 'todo': return row(`<span class="check${b.checked ? ' on' : ''}">${ic('tick')}</span>${c}`, b.checked ? ' done' : '');
case 'callout': {
const k = b.kind || 'note';
return row(`<div class="callout" data-kind="${esc(k)}"><span class="callout-ico">${ic(CALLOUT_ICONS[k] || 'leaf')}</span>${c}</div>`);
}
case 'code': return row(`<div class="code"><div class="code-bar"><span class="lang">${esc(b.lang || 'txt')}</span></div><pre class="c code-c">${highlight(b.text || '')}</pre></div>`);
case 'mermaid': return row(`<div class="code"><div class="code-bar"><span class="lang">mermaid</span></div><pre class="c code-c">${highlight(b.text || '')}</pre></div>`);
case 'math': {
let out;
try { out = katex.renderToString(b.text || '', { throwOnError: false, displayMode: true }); } catch { out = esc(b.text || ''); }
return row(`<div class="math"><div class="math-preview">${out}</div></div>`);
}
case 'divider': return row('<div class="hr"></div>');
case 'image':
return row(`<figure class="img"><img src="${esc(imageUrl(b.src))}" alt="${esc(b.alt || '')}" draggable="false"><span class="img-miss">${ic('image')}Image not found: ${esc(b.src)}</span></figure>`);
case 'table': {
const align = (i) => (b.align?.[i] ? ` style="text-align:${esc(b.align[i])}"` : '');
const rows = (b.rows || []).map((r, ri) => `<tr>${r.map((h, ci) => `<${ri ? 'td' : 'th'}${align(ci)}><div class="ecell">${h || ''}</div></${ri ? 'td' : 'th'}>`).join('')}</tr>`).join('');
return row(`<div class="tbl"><div class="tbl-scroll"><table><tbody>${rows}</tbody></table></div></div>`);
}
case 'toggle':
return row(`<details class="etg"${b.open ? ' open' : ''}><summary class="c">${b.html || ''}</summary><div class="c etg-body">${b.body || ''}</div></details>`);
case 'footnote': return `<div class="eb b-footnote" data-fid="${esc(b.fid)}"><span class="fn-num">${esc(b.fid)}</span>${c}</div>`;
case 'embed': return row(`<div class="emb-nested">${ic('file')}${linkChip(b.ref)}</div>`);
default: return row(c); // h1 h2 h3 quote
}
}
+24
View File
@@ -0,0 +1,24 @@
// Footnote numbers on screen. References ([^id] and ^[inline] notes, as <sup class="fn">) are numbered by first
// appearance; footnote texts (elements with data-fid) get the number of their id, or show the id if nothing
// references them. `inScope(el)` leaves out parts of `root` that number on their own (embeds).
export function paintFootnotes(root, inScope = () => true) {
const texts = new Set([...root.querySelectorAll('[data-fid]')].filter(inScope).map((el) => el.dataset.fid));
const order = new Map();
let n = 0;
for (const s of root.querySelectorAll('sup.fn')) {
if (!inScope(s)) continue;
if ('inline' in s.dataset) { s.textContent = String(++n); continue; }
const id = s.dataset.fn;
if (!order.has(id)) order.set(id, ++n);
s.textContent = String(order.get(id));
s.classList.toggle('missing', !texts.has(id));
}
for (const el of root.querySelectorAll('[data-fid]')) {
if (!inScope(el)) continue;
const k = order.get(el.dataset.fid);
const num = el.querySelector('.fn-num');
if (num) num.textContent = k ? String(k) : el.dataset.fid;
el.classList.toggle('orphan', !k);
}
return order;
}
+341
View File
@@ -0,0 +1,341 @@
// A small formula language for database properties. Pure functions (no DOM), so they can be tested on their own.
//
// prop("Price") * prop("Quantity") a property by name ({Price} is the same)
// if(prop("Opened"), dateBetween(prop("Expiry"), today(), "days"), "")
// concat(prop("Brand"), " – ", prop("Name"))
//
// Values are numbers, text, true / false, dates (text like 2026-03-01) and lists. An empty value counts as 0 in
// arithmetic, "" in text and false in conditions. Nothing here can touch anything but the values it is given.
export class FormulaError extends Error {}
/* tokens */
const isDigit = (c) => c >= '0' && c <= '9';
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
const isIdent = (c) => /[A-Za-z0-9_]/.test(c);
function tokenize(src) {
const out = [];
let i = 0;
while (i < src.length) {
const c = src[i];
if (/\s/.test(c)) { i++; continue; }
const at = i;
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] || ''))) {
let j = i;
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
const text = src.slice(i, j);
if ((text.match(/\./g) || []).length > 1) throw new FormulaError(`“${text}” is not a number (at ${at + 1})`);
out.push({ t: 'num', v: Number(text), at });
i = j;
} else if (c === '"' || c === "'") {
let j = i + 1, s = '';
while (j < src.length && src[j] !== c) {
if (src[j] === '\\' && j + 1 < src.length) {
const n = src[j + 1];
s += n === 'n' ? '\n' : n === 't' ? '\t' : n;
j += 2;
} else s += src[j++];
}
if (j >= src.length) throw new FormulaError(`The text starting at ${at + 1} is not closed`);
out.push({ t: 'str', v: s, at });
i = j + 1;
} else if (c === '{') {
const j = src.indexOf('}', i);
if (j < 0) throw new FormulaError(`The “{” at ${at + 1} is not closed`);
out.push({ t: 'prop', v: src.slice(i + 1, j).trim(), at });
i = j + 1;
} else if (isIdentStart(c)) {
let j = i;
while (j < src.length && isIdent(src[j])) j++;
out.push({ t: 'id', v: src.slice(i, j), at });
i = j;
} else {
const two = src.slice(i, i + 2);
if (['==', '!=', '<=', '>=', '&&', '||', '<>'].includes(two)) { out.push({ t: 'op', v: two === '<>' ? '!=' : two, at }); i += 2; }
else if ('+-*/%^<>!(),='.includes(c)) { out.push({ t: 'op', v: c === '=' ? '==' : c, at }); i++; }
else throw new FormulaError(`Unexpected “${c}” at ${at + 1}`);
}
}
return out;
}
/* parser: precedence climbing */
export function parseFormula(src) {
const toks = tokenize(String(src));
let p = 0;
const peek = () => toks[p];
const next = () => toks[p++];
const isOp = (v) => peek() && peek().t === 'op' && peek().v === v;
const isWord = (v) => peek() && peek().t === 'id' && peek().v.toLowerCase() === v;
const expect = (v) => {
if (!isOp(v)) throw new FormulaError(peek() ? `Expected “${v}” at ${peek().at + 1}` : `Expected “${v}” at the end`);
p++;
};
function or() {
let a = and();
while (isOp('||') || isWord('or')) { p++; a = { t: 'or', a, b: and() }; }
return a;
}
function and() {
let a = eq();
while (isOp('&&') || isWord('and')) { p++; a = { t: 'and', a, b: eq() }; }
return a;
}
function eq() {
let a = cmp();
while (isOp('==') || isOp('!=')) { const op = next().v; a = { t: 'bin', op, a, b: cmp() }; }
return a;
}
function cmp() {
let a = add();
while (isOp('<') || isOp('>') || isOp('<=') || isOp('>=')) { const op = next().v; a = { t: 'bin', op, a, b: add() }; }
return a;
}
function add() {
let a = mul();
while (isOp('+') || isOp('-')) { const op = next().v; a = { t: 'bin', op, a, b: mul() }; }
return a;
}
function mul() {
let a = unary();
while (isOp('*') || isOp('/') || isOp('%')) { const op = next().v; a = { t: 'bin', op, a, b: unary() }; }
return a;
}
function unary() {
if (isOp('-')) { p++; return { t: 'un', op: '-', a: unary() }; }
if (isOp('!') || isWord('not')) { p++; return { t: 'un', op: '!', a: unary() }; }
return power();
}
function power() {
const a = primary();
if (isOp('^')) { p++; return { t: 'bin', op: '^', a, b: unary() }; } // right-associative
return a;
}
function primary() {
const t = next();
if (!t) throw new FormulaError('The formula ends too soon');
if (t.t === 'num') return { t: 'lit', v: t.v };
if (t.t === 'str') return { t: 'lit', v: t.v };
if (t.t === 'prop') return { t: 'prop', name: t.v };
if (t.t === 'op' && t.v === '(') {
const e = or();
expect(')');
return e;
}
if (t.t === 'id') {
const low = t.v.toLowerCase();
if (isOp('(')) {
p++;
const args = [];
if (!isOp(')')) {
for (;;) {
args.push(or());
if (isOp(',')) { p++; continue; }
break;
}
}
expect(')');
return { t: 'call', name: low, args, at: t.at };
}
if (low === 'true') return { t: 'lit', v: true };
if (low === 'false') return { t: 'lit', v: false };
if (low === 'empty' || low === 'null') return { t: 'lit', v: null };
throw new FormulaError(`“${t.v}” is not a function or a value. Property names go in prop("…") or {…}`);
}
throw new FormulaError(`Unexpected “${t.v}” at ${t.at + 1}`);
}
if (!toks.length) throw new FormulaError('The formula is empty');
const ast = or();
if (p < toks.length) throw new FormulaError(`Unexpected “${toks[p].v}” at ${toks[p].at + 1}`);
return ast;
}
/* values */
export const isDateText = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}(?:$|[T ])/.test(v);
const dayNum = (s) => Math.round(Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10)) / 864e5);
const isoDay = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
const blank = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length);
export const truthy = (v) => (Array.isArray(v) ? v.length > 0 : typeof v === 'string' ? v.trim() !== '' && v.toLowerCase() !== 'false' : !!v);
function num(v, what = 'a number') {
if (blank(v)) return 0;
if (typeof v === 'number') return v;
if (typeof v === 'boolean') return v ? 1 : 0;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
throw new FormulaError(`“${Array.isArray(v) ? v.join(', ') : v}” can't be used as ${what}`);
}
const numeric = (v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) && !isDateText(v));
export function show(v) {
if (blank(v)) return '';
if (typeof v === 'number') return Number.isFinite(v) ? String(Number(v.toFixed(10))) : '';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (Array.isArray(v)) return v.map(show).join(', ');
return String(v);
}
const UNITS = { day: 'days', days: 'days', week: 'weeks', weeks: 'weeks', month: 'months', months: 'months', year: 'years', years: 'years' };
const unitOf = (u) => UNITS[String(u ?? 'days').toLowerCase()] || (() => { throw new FormulaError(`Unknown unit “${u}” (use days, weeks, months or years)`); })();
function dateOf(v, what = 'a date') {
if (isDateText(v)) return v.slice(0, 10);
throw new FormulaError(`“${show(v)}” can't be used as ${what}`);
}
function addMonths(iso, n) {
const y = +iso.slice(0, 4), m = +iso.slice(5, 7) - 1, d = +iso.slice(8, 10);
const t = new Date(Date.UTC(y, m + n, 1));
const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
return new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), Math.min(d, last))).toISOString().slice(0, 10);
}
function dateAdd(iso, n, unit) {
const u = unitOf(unit);
if (u === 'days') return isoDay(dayNum(iso) + n);
if (u === 'weeks') return isoDay(dayNum(iso) + n * 7);
return addMonths(iso, u === 'years' ? n * 12 : n);
}
function dateBetween(a, b, unit) {
const u = unitOf(unit);
const x = dayNum(a), y = dayNum(b);
if (u === 'days') return x - y;
if (u === 'weeks') return Math.trunc((x - y) / 7);
// whole months between two dates (negative if a is before b)
let months = (+a.slice(0, 4) - +b.slice(0, 4)) * 12 + (+a.slice(5, 7) - +b.slice(5, 7));
const da = +a.slice(8, 10), db = +b.slice(8, 10);
if (months > 0 && da < db) months--;
else if (months < 0 && da > db) months++;
return u === 'years' ? Math.trunc(months / 12) : months;
}
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function formatDate(iso, fmt = 'MMM D, YYYY') {
const y = iso.slice(0, 4), m = +iso.slice(5, 7), d = +iso.slice(8, 10);
return String(fmt)
.replace(/YYYY/g, y).replace(/YY/g, y.slice(2))
.replace(/MMMM/g, MONTH_NAMES[m - 1]).replace(/MMM/g, MONTH_NAMES[m - 1].slice(0, 3))
.replace(/MM/g, String(m).padStart(2, '0')).replace(/DD/g, String(d).padStart(2, '0')).replace(/\bD\b/g, String(d));
}
/* evaluation */
const flat = (args) => args.flatMap((a) => (Array.isArray(a) ? a : [a]));
const nums = (args) => flat(args).filter((v) => !blank(v)).map((v) => num(v));
const FUNCS = {
// logic
if: null, and: null, or: null, // lazily, below
not: (c) => !truthy(c),
empty: (v) => blank(v),
// numbers
abs: (x) => Math.abs(num(x)),
round: (x, d = 0) => { const k = 10 ** num(d); return Math.round((num(x) + Number.EPSILON) * k) / k; },
floor: (x) => Math.floor(num(x)),
ceil: (x) => Math.ceil(num(x)),
sqrt: (x) => { const n = num(x); if (n < 0) throw new FormulaError('The square root of a negative number'); return Math.sqrt(n); },
pow: (x, y) => num(x) ** num(y),
min: (...a) => { const n = nums(a); return n.length ? Math.min(...n) : null; },
max: (...a) => { const n = nums(a); return n.length ? Math.max(...n) : null; },
sum: (...a) => nums(a).reduce((s, x) => s + x, 0),
average: (...a) => { const n = nums(a); return n.length ? n.reduce((s, x) => s + x, 0) / n.length : null; },
tonumber: (v) => { if (blank(v)) return 0; const n = Number(String(v).replace(/[^0-9.+\-eE]/g, '')); if (!Number.isFinite(n)) throw new FormulaError(`“${show(v)}” is not a number`); return n; },
// text
concat: (...a) => flat(a).map(show).join(''),
join: (list, sep = ', ') => (Array.isArray(list) ? list : [list]).map(show).join(show(sep)),
length: (v) => (Array.isArray(v) ? v.length : show(v).length),
lower: (v) => show(v).toLowerCase(),
upper: (v) => show(v).toUpperCase(),
trim: (v) => show(v).trim(),
contains: (a, b) => (Array.isArray(a) ? a.map((x) => show(x).toLowerCase()).includes(show(b).toLowerCase()) : show(a).toLowerCase().includes(show(b).toLowerCase())),
replace: (s, from, to) => show(s).split(show(from)).join(show(to)),
format: (v) => show(v),
// dates
today: () => null, // filled in by the evaluator (it knows the day)
now: () => null,
datebetween: (a, b, unit) => dateBetween(dateOf(a), dateOf(b), unit),
dateadd: (d, n, unit) => dateAdd(dateOf(d), num(n), unit),
year: (d) => +dateOf(d).slice(0, 4),
month: (d) => +dateOf(d).slice(5, 7),
day: (d) => +dateOf(d).slice(8, 10),
formatdate: (d, f) => formatDate(dateOf(d), f)
};
export const FUNCTION_NAMES = Object.keys(FUNCS).filter((n) => n !== 'now' && n !== 'tonumber').concat('toNumber');
function compare(op, a, b) {
const bothNum = numeric(a) && numeric(b);
if (bothNum) {
const x = Number(a), y = Number(b);
return { '<': x < y, '>': x > y, '<=': x <= y, '>=': x >= y, '==': x === y, '!=': x !== y }[op];
}
if (blank(a) || blank(b)) {
const eq = blank(a) && blank(b);
if (op === '==') return eq;
if (op === '!=') return !eq;
return false;
}
const x = show(a), y = show(b);
const c = x < y ? -1 : x > y ? 1 : 0;
return { '<': c < 0, '>': c > 0, '<=': c <= 0, '>=': c >= 0, '==': c === 0, '!=': c !== 0 }[op];
}
function evalNode(n, ctx) {
switch (n.t) {
case 'lit': return n.v;
case 'prop': return ctx.prop(n.name);
case 'or': return truthy(evalNode(n.a, ctx)) || truthy(evalNode(n.b, ctx));
case 'and': return truthy(evalNode(n.a, ctx)) && truthy(evalNode(n.b, ctx));
case 'un': {
const v = evalNode(n.a, ctx);
return n.op === '-' ? -num(v) : !truthy(v);
}
case 'bin': {
const a = evalNode(n.a, ctx), b = evalNode(n.b, ctx);
switch (n.op) {
case '+':
if (isDateText(a) && !blank(b) && numeric(b)) return dateAdd(a.slice(0, 10), Number(b), 'days');
if ((typeof a === 'string' && !numeric(a)) || (typeof b === 'string' && !numeric(b)) || Array.isArray(a) || Array.isArray(b)) return show(a) + show(b);
return num(a) + num(b);
case '-':
if (isDateText(a) && isDateText(b)) return dateBetween(a.slice(0, 10), b.slice(0, 10), 'days');
if (isDateText(a) && !blank(b) && numeric(b)) return dateAdd(a.slice(0, 10), -Number(b), 'days');
return num(a) - num(b);
case '*': return num(a) * num(b);
case '/': { const d = num(b); if (d === 0) throw new FormulaError('Division by zero'); return num(a) / d; }
case '%': { const d = num(b); if (d === 0) throw new FormulaError('Division by zero'); return num(a) % d; }
case '^': return num(a) ** num(b);
default: return compare(n.op, a, b);
}
}
case 'call': {
const name = n.name;
if (name === 'if') {
if (n.args.length < 2 || n.args.length > 3) throw new FormulaError('if() takes a condition, a value for true and optionally one for false');
return truthy(evalNode(n.args[0], ctx)) ? evalNode(n.args[1], ctx) : n.args[2] ? evalNode(n.args[2], ctx) : null;
}
if (name === 'and') return n.args.every((a) => truthy(evalNode(a, ctx)));
if (name === 'or') return n.args.some((a) => truthy(evalNode(a, ctx)));
if (name === 'prop') {
if (n.args.length !== 1) throw new FormulaError('prop() takes one property name');
return ctx.prop(show(evalNode(n.args[0], ctx)));
}
if (name === 'today' || name === 'now') return ctx.today;
const f = FUNCS[name];
if (typeof f !== 'function') throw new FormulaError(`There is no function called “${name}”`);
return f(...n.args.map((a) => evalNode(a, ctx)));
}
default: throw new FormulaError('Unsupported formula');
}
}
const cache = new Map();
// Evaluate `src`. ctx: { prop(name) -> value (throws FormulaError for an unknown name), today: 'YYYY-MM-DD' }.
// Returns the value; throws FormulaError.
export function evaluateFormula(src, ctx) {
let ast = cache.get(src);
if (!ast) {
ast = parseFormula(src);
if (cache.size > 500) cache.clear();
cache.set(src, ast);
}
const v = evalNode(ast, ctx);
if (typeof v === 'number' && !Number.isFinite(v)) throw new FormulaError('The result is not a number');
return v;
}
+12 -1
View File
@@ -7,7 +7,7 @@
// Like d3-force, every push is scaled by `alpha`, which cools from 1 towards 0 so the layout settles; // Like d3-force, every push is scaled by `alpha`, which cools from 1 towards 0 so the layout settles;
// dragging a node or changing the graph warms it up again. // dragging a node or changing the graph warms it up again.
const THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher let THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher; a big graph sets it per tick
export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.04, velocityDecay = 0.42 } = {}) { export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.04, velocityDecay = 0.42 } = {}) {
let nodes = []; let nodes = [];
@@ -54,7 +54,18 @@ export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.0
reheat(to = 0.6) { sim.alpha = Math.max(sim.alpha, to); return sim; }, reheat(to = 0.6) { sim.alpha = Math.max(sim.alpha, to); return sim; },
get running() { return sim.alpha > sim.alphaMin || sim.alphaTarget > 0; }, get running() { return sim.alpha > sim.alphaMin || sim.alphaTarget > 0; },
// Ticks until `times` are done or `ms` have passed (at least one): a big graph must not freeze the page.
tickFor(times, ms) {
const end = performance.now() + ms;
for (let t = 0; t < times; t++) {
sim.tick(1);
if (performance.now() >= end) break;
}
return sim;
},
tick(times = 1) { tick(times = 1) {
THETA2 = nodes.length > 3000 ? 1.8 : 0.81; // 10k notes: coarser far-field, about twice as fast
for (let t = 0; t < times; t++) { for (let t = 0; t < times; t++) {
sim.alpha += (sim.alphaTarget - sim.alpha) * sim.alphaDecay; sim.alpha += (sim.alphaTarget - sim.alpha) * sim.alphaDecay;
const alpha = sim.alpha; const alpha = sim.alpha;
+11
View File
@@ -0,0 +1,11 @@
import { tagHex } from './tagColors.js';
// The colour of a dot in the graph: the colour of the first of its tags that has one (set in the sidebar's tag
// menu), otherwise `plain`. (There is no automatic colour per tag: it would look just like a chosen one.)
export function nodeColor(tags, tagColors, plain) {
for (const t of tags || []) {
const hex = tagHex(tagColors && tagColors[t]);
if (hex) return hex;
}
return plain;
}
+20
View File
@@ -0,0 +1,20 @@
import { esc } from './text.js';
// Small single-pass highlighter: comments, strings, keywords, numbers, function calls.
const RE =
/(\/\/[^\n]*|#[^\n]*)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\b(const|let|var|function|return|if|else|for|while|import|from|export|default|async|await|new|class|def|in|of|true|false|null|undefined|None|True|False|npm|npx|cd)\b|\b(\d+(?:\.\d+)?)\b|([A-Za-z_$][\w$]*)(?=\()/g;
export function highlight(src) {
let out = '', last = 0, m;
RE.lastIndex = 0;
while ((m = RE.exec(src))) {
out += esc(src.slice(last, m.index));
const cls = m[1] ? 'tc' : m[2] ? 'ts' : m[3] ? 'tk' : m[4] ? 'tn' : 'tf';
out += `<span class="${cls}">${esc(m[0])}</span>`;
last = RE.lastIndex;
}
out += esc(src.slice(last));
// A trailing newline needs a <br> to get its own visible line in a contenteditable <pre>.
if (src.endsWith('\n')) out += '<br>';
return out;
}
+136
View File
@@ -0,0 +1,136 @@
// HTML -> Markdown for whole documents and clipboard content (web pages, Notion/Word/Docs copies).
// Runs in the page (DOMParser). `base` makes relative links and pictures absolute.
// htmlToMarkdown(html, { base }) -> Markdown text
// articleOf(html) -> { title, html } the main content of a web page
const BLOCKS = new Set(['P', 'DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'ASIDE', 'FIGURE', 'FIGCAPTION', 'DETAILS', 'SUMMARY', 'ADDRESS', 'BODY', 'HTML']);
const DROP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'CANVAS', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA', 'HEAD', 'LINK', 'META']);
const esc = (s) => s.replace(/([\\`*_[\]])/g, '\\$1');
function absolute(href, base) {
if (!href) return '';
try { return new URL(href, base || undefined).href; } catch { return href; }
}
const safeHref = (h) => (/^(https?:|mailto:)/i.test(h) ? h : '');
export function htmlToMarkdown(html, { base = '' } = {}) {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
const out = walkBlocks(doc.body, { base, depth: 0 });
return out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}
// inline content of an element
function inline(node, cx) {
let s = '';
for (const n of node.childNodes) {
if (n.nodeType === 3) { s += esc(n.data.replace(/\s+/g, ' ')); continue; }
if (n.nodeType !== 1 || DROP.has(n.tagName.toUpperCase())) continue;
const tag = n.tagName.toUpperCase();
const inner = () => inline(n, cx);
const wrap = (m) => { const t = inner(); const lead = t.match(/^\s*/)[0], trail = t.match(/\s*$/)[0]; const core = t.trim(); return core ? lead + m + core + m + trail : t; };
switch (tag) {
case 'STRONG': case 'B': s += wrap('**'); break;
case 'EM': case 'I': s += wrap('*'); break;
case 'DEL': case 'S': case 'STRIKE': s += wrap('~~'); break;
case 'MARK': s += wrap('=='); break;
case 'CODE': s += '`' + n.textContent.replace(/`/g, "'") + '`'; break;
case 'BR': s += '\n'; break;
case 'IMG': {
const src = absolute(n.getAttribute('src') || '', cx.base);
if (/^https?:/i.test(src)) s += `![${esc(n.getAttribute('alt') || '')}](${src})`;
break;
}
case 'A': {
const href = safeHref(absolute(n.getAttribute('href') || '', cx.base));
const t = inner().trim();
s += href && t ? `[${t}](${href.replace(/\)/g, '%29')})` : t;
break;
}
case 'INPUT': break;
default: s += inner();
}
}
return s;
}
const hasBlock = (el) => [...el.children].some((c) => BLOCKS.has(c.tagName) || /^(H[1-6]|UL|OL|LI|TABLE|BLOCKQUOTE|PRE|HR)$/.test(c.tagName));
// block content of an element, as Markdown with blank lines between blocks
function walkBlocks(el, cx) {
const parts = [];
let run = '';
const flush = () => { const t = run.replace(/[ \t]*\n[ \t]*/g, '\n').trim(); if (t) parts.push(t.replace(/\n/g, ' \n')); run = ''; };
for (const n of el.childNodes) {
if (n.nodeType === 3) { run += esc(n.data.replace(/\s+/g, ' ')); continue; }
if (n.nodeType !== 1) continue;
const tag = n.tagName.toUpperCase();
if (DROP.has(tag)) continue;
const m = /^H([1-6])$/.exec(tag);
if (m) { flush(); const t = inline(n, cx).replace(/\s+/g, ' ').trim(); if (t) parts.push('#'.repeat(+m[1]) + ' ' + t); continue; }
if (tag === 'P' || ((tag === 'DIV' || BLOCKS.has(tag)) && !hasBlock(n))) { flush(); const t = inline(n, cx).replace(/[ \t]*\n[ \t]*/g, '\n').trim(); if (t) parts.push(t.replace(/\n/g, ' \n')); continue; }
if (BLOCKS.has(tag)) { flush(); const t = walkBlocks(n, cx); if (t) parts.push(t); continue; }
if (tag === 'UL' || tag === 'OL') { flush(); parts.push(list(n, cx, 0)); continue; }
if (tag === 'BLOCKQUOTE') { flush(); const t = walkBlocks(n, cx); if (t) parts.push(t.split('\n').map((l) => (l ? '> ' + l : '>')).join('\n')); continue; }
if (tag === 'PRE') {
flush();
const code = n.querySelector('code');
const lang = ((code || n).className.match(/(?:language|lang)-([\w+#-]+)/) || [])[1] || '';
parts.push('```' + lang + '\n' + n.textContent.replace(/\n+$/, '') + '\n```');
continue;
}
if (tag === 'HR') { flush(); parts.push('---'); continue; }
if (tag === 'TABLE') { flush(); const t = table(n, cx); if (t) parts.push(t); continue; }
if (tag === 'LI') { flush(); parts.push(list({ children: [n], tagName: 'UL', getAttribute: () => null }, cx, 0)); continue; }
run += inline({ childNodes: [n] }, cx);
}
flush();
return parts.join('\n\n');
}
function list(el, cx, depth) {
const ordered = el.tagName === 'OL';
let i = Number(el.getAttribute && el.getAttribute('start')) || 1;
const lines = [];
for (const li of el.children) {
if (li.tagName !== 'LI') continue;
const sub = [];
let own = '';
for (const n of li.childNodes) {
if (n.nodeType === 1 && (n.tagName === 'UL' || n.tagName === 'OL')) sub.push(list(n, cx, depth + 1));
else if (n.nodeType === 1 && (n.tagName === 'P' || n.tagName === 'DIV')) own += ' ' + inline(n, cx);
else if (n.nodeType === 1 && DROP.has(n.tagName)) continue;
else own += inline({ childNodes: [n] }, cx);
}
const box = li.querySelector(':scope > input[type=checkbox], :scope > p > input[type=checkbox]');
const text = own.replace(/\s+/g, ' ').trim();
const mark = ordered ? `${i++}. ` : '- ';
lines.push(' '.repeat(depth) + mark + (box ? (box.hasAttribute('checked') ? '[x] ' : '[ ] ') : '') + text);
for (const s of sub) lines.push(s);
}
return lines.join('\n');
}
function table(el, cx) {
const rows = [...el.querySelectorAll('tr')].map((tr) => [...tr.children].filter((c) => /^T[DH]$/.test(c.tagName)).map((c) => inline(c, cx).replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim()));
if (!rows.length || !rows[0].length) return '';
const w = Math.max(...rows.map((r) => r.length));
const pad = (r) => '| ' + Array.from({ length: w }, (_, i) => r[i] || '').join(' | ') + ' |';
return [pad(rows[0]), '|' + ' --- |'.repeat(w), ...rows.slice(1).map(pad)].join('\n');
}
// A web page's main content: <article>, <main> or the biggest block of text, with page chrome removed.
export function articleOf(html, { base = '' } = {}) {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
for (const bad of doc.querySelectorAll('script,style,noscript,nav,footer,aside,form,header nav,[role=navigation],[role=banner],[aria-hidden=true],.advert,.ads,.cookie,.share,.social')) bad.remove();
const title = (doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || doc.querySelector('h1')?.textContent || doc.title || '').replace(/\s+/g, ' ').trim();
let root = doc.querySelector('article') || doc.querySelector('main,[role=main]');
if (!root) {
let best = null, score = 0;
for (const el of doc.querySelectorAll('div,section')) {
const own = [...el.children].filter((c) => c.tagName === 'P').reduce((n, p) => n + p.textContent.length, 0);
if (own > score) { score = own; best = el; }
}
root = best || doc.body;
}
return { title, markdown: htmlToMarkdown(root.outerHTML, { base }) };
}
+118 -3
View File
@@ -2,6 +2,8 @@
export const PATHS = { export const PATHS = {
plus: 'M12 5v14|M5 12h14', plus: 'M12 5v14|M5 12h14',
chev: 'M9 6l6 6-6 6', chev: 'M9 6l6 6-6 6',
chevLeft: 'M15 18l-6-6 6-6',
chevRight: 'M9 6l6 6-6 6',
search: 'M4 11a7 7 0 1 0 14 0a7 7 0 1 0-14 0|M16.5 16.5L21 21', search: 'M4 11a7 7 0 1 0 14 0a7 7 0 1 0-14 0|M16.5 16.5L21 21',
panel: 'M4 5h16v14H4z|M9.5 5v14', panel: 'M4 5h16v14H4z|M9.5 5v14',
dots: 'M6 12h.01|M12 12h.01|M18 12h.01', dots: 'M6 12h.01|M12 12h.01|M18 12h.01',
@@ -10,6 +12,10 @@ export const PATHS = {
minimize: 'M6 12h12', minimize: 'M6 12h12',
maximize: 'M6 6h12v12H6z', maximize: 'M6 6h12v12H6z',
restore: 'M8 9h10v10H8z|M6 14V5h11', restore: 'M8 9h10v10H8z|M6 14V5h11',
card: 'M4 5h16v14H4z|M8 10h8|M8 14h5',
group: 'M3 7V4h3|M18 4h3v3|M21 17v3h-3|M6 20H3v-3|M10 4h4|M10 20h4|M8 9h8v6H8z',
fit: 'M4 9V4h5|M15 4h5v5|M20 15v5h-5|M9 20H4v-5',
canvas: 'M4 4h6v5H4z|M14 15h6v5h-6z|M7 9v4.5a1.5 1.5 0 0 0 1.5 1.5H14|M14 4h6v5h-6z',
board: 'M4 4h4.5v16H4z|M9.75 4h4.5v11h-4.5z|M15.5 4H20v7h-4.5z', board: 'M4 4h4.5v16H4z|M9.75 4h4.5v11h-4.5z|M15.5 4H20v7h-4.5z',
star: 'M12 3.8l2.5 5.2 5.7.8-4.1 4 1 5.6L12 16.7l-5.1 2.7 1-5.6-4.1-4 5.7-.8z', star: 'M12 3.8l2.5 5.2 5.7.8-4.1 4 1 5.6L12 16.7l-5.1 2.7 1-5.6-4.1-4 5.7-.8z',
undo: 'M9 14L4 9l5-5|M4 9h10.5a5.5 5.5 0 0 1 0 11H11', undo: 'M9 14L4 9l5-5|M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
@@ -44,6 +50,17 @@ export const PATHS = {
link: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1', link: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1',
unlink: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1|M4 4l16 16', unlink: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1|M4 4l16 16',
alignLeft: 'M4 6h16|M4 10h10|M4 14h16|M4 18h10', alignLeft: 'M4 6h16|M4 10h10|M4 14h16|M4 18h10',
layoutV: 'M4 6h2|M9 6h11|M4 12h2|M9 12h11|M4 18h2|M9 18h11',
layoutH: 'M4 8h4v8H4z|M10 8h4v8h-4z|M16 8h4v8h-4z',
// lining up and spreading out boxes (canvas selection bar)
boxLeft: 'M4 3v18|M8 7h12v4H8z|M8 14h7v4H8z',
boxCenterX: 'M12 3v18|M6 7h12v4H6z|M8.5 14h7v4h-7z',
boxRight: 'M20 3v18|M4 7h12v4H4z|M9 14h7v4H9z',
boxTop: 'M3 4h18|M7 8v12h4V8z|M14 8v7h4V8z',
boxCenterY: 'M3 12h18|M7 6v12h4V6z|M14 8.5v7h4v-7z',
boxBottom: 'M3 20h18|M7 4v12h4V4z|M14 9v7h4V9z',
spreadX: 'M4 3v18|M20 3v18|M10 7h4v10h-4z',
spreadY: 'M3 4h18|M3 20h18|M7 10h10v4H7z',
alignCenter: 'M4 6h16|M7 10h10|M4 14h16|M7 18h10', alignCenter: 'M4 6h16|M7 10h10|M4 14h16|M7 18h10',
alignRight: 'M4 6h16|M10 10h10|M4 14h16|M10 18h10', alignRight: 'M4 6h16|M10 10h10|M4 14h16|M10 18h10',
minus: 'M5 12h14', minus: 'M5 12h14',
@@ -89,7 +106,70 @@ export const PATHS = {
tag: 'M3 12V4h8l10 10-8 8z|M7.5 8h.01', tag: 'M3 12V4h8l10 10-8 8z|M7.5 8h.01',
inbox: 'M3 13l3-8h12l3 8v6H3z|M3 13h5l1 2h6l1-2h5', inbox: 'M3 13l3-8h12l3 8v6H3z|M3 13h5l1 2h6l1-2h5',
layers: 'M12 3l9 5-9 5-9-5z|M3 13l9 5 9-5', layers: 'M12 3l9 5-9 5-9-5z|M3 13l9 5 9-5',
check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5' check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5',
// more page icons
shield: 'M12 3l7 3v5c0 5-3.2 8.5-7 10-3.8-1.5-7-5-7-10V6z',
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z|M9.5 12a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0',
wifi: 'M2.5 9a14 14 0 0 1 19 0|M5.5 12.5a9.5 9.5 0 0 1 13 0|M8.5 16a5 5 0 0 1 7 0|M12 19.5h.01',
phone: 'M7 3h10v18H7z|M11 18h2',
headphones: 'M4 14v-2a8 8 0 0 1 16 0v2|M4 14h3v6H4z|M17 14h3v6h-3z',
mic: 'M9 6a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0z|M5 11a7 7 0 0 0 14 0|M12 18v3|M9 21h6',
video: 'M3 6h12v12H3z|M15 10l6-3v10l-6-3',
film: 'M4 4h16v16H4z|M8 4v16|M16 4v16|M4 8h4|M4 12h4|M4 16h4|M16 8h4|M16 12h4|M16 16h4',
printer: 'M6 9V3h12v6|M6 18H4V9h16v9h-2|M6 14h12v7H6z',
download: 'M12 4v11|M7 11l5 5 5-5|M5 20h14',
upload: 'M12 16V5|M7 9l5-5 5 5|M5 20h14',
cloud: 'M7 18a4 4 0 0 1-.5-8A6 6 0 0 1 18 9.5 4 4 0 0 1 17 18z',
umbrella: 'M3 12a9 9 0 0 1 18 0z|M12 12v6a2 2 0 0 0 4 0|M12 3v1',
snowflake: 'M12 3v18|M4.2 7.5l15.6 9|M4.2 16.5l15.6-9',
droplet: 'M12 3s6 6.2 6 11a6 6 0 0 1-12 0c0-4.8 6-11 6-11z',
fire: 'M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 .3 1.2 1 2 2 2 0-3-1-5 1-8z',
anchor: 'M9.5 5.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M12 8v13|M5 12H3a9 9 0 0 0 18 0h-2|M8 12h8',
flask: 'M9 3h6|M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3|M8 15h8',
magnet: 'M5 4h4v9a3 3 0 0 0 6 0V4h4v9a7 7 0 0 1-14 0z|M5 8h4|M15 8h4',
scissors: 'M3 6a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M3 18a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M8.5 7.5L21 19|M8.5 16.5L21 5',
paperclip: 'M20 11l-8.5 8.5a5 5 0 0 1-7-7l9-9a3.5 3.5 0 0 1 5 5l-9 9a2 2 0 0 1-3-3l8-8',
pin: 'M9 4h6l-1 6 3 3H7l3-3z|M12 13v8',
grid: 'M4 4h7v7H4z|M13 4h7v7h-7z|M4 13h7v7H4z|M13 13h7v7h-7z',
columns: 'M4 4h16v16H4z|M10 4v16|M15 4v16',
crown: 'M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z',
trophy: 'M8 4h8v6a4 4 0 0 1-8 0z|M8 6H4v2a3 3 0 0 0 4 3|M16 6h4v2a3 3 0 0 1-4 3|M12 14v4|M8 21h8|M10 18h4',
medal: 'M7.5 9a4.5 4.5 0 1 0 9 0a4.5 4.5 0 1 0-9 0|M8.5 13L7 21l5-3 5 3-1.5-8',
diamond: 'M6 4h12l4 6-10 11L2 10z|M2 10h20|M9 4l-2 6 5 11 5-11-2-6',
skull: 'M12 3a8 8 0 0 0-5 14v3h10v-3a8 8 0 0 0-5-14z|M9 12h.01|M15 12h.01|M10 20v-2|M14 20v-2',
ghost: 'M5 21V11a7 7 0 0 1 14 0v10l-3-2-2 2-2-2-2 2-2-2z|M9.5 11h.01|M14.5 11h.01',
bug: 'M9 8a3 3 0 0 1 6 0v1H9z|M7 9h10v6a5 5 0 0 1-10 0z|M12 9v11|M3 12h4|M17 12h4|M4 6l3 2|M20 6l-3 2|M4 19l3-2|M20 19l-3-2',
infinity: 'M7 8a4 4 0 1 0 0 8c4 0 6-8 10-8a4 4 0 1 1 0 8c-4 0-6-8-10-8z',
building: 'M5 21V4h9v17|M14 9h5v12|M8 8h2|M8 12h2|M8 16h2|M16 13h.01|M16 17h.01',
bank: 'M3 9l9-5 9 5z|M5 10v8|M9.5 10v8|M14.5 10v8|M19 10v8|M3 21h18',
cross: 'M9 3h6v6h6v6h-6v6H9v-6H3V9h6z',
pill: 'M10.5 20.5a5 5 0 0 1-7-7l10-10a5 5 0 0 1 7 7z|M8.5 8.5l7 7',
dumbbell: 'M6.5 6.5v11|M17.5 6.5v11|M3 9v6|M21 9v6|M6.5 12h11',
bike: 'M2 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M15 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M5.5 16.5l4-8h6l3 8|M9.5 8.5L12 16.5H5.5|M15.5 8.5l-1-3h2',
car: 'M5 16v-5l2-5h10l2 5v5|M3 16h18|M7 19v-3|M17 19v-3|M7.5 13h.01|M16.5 13h.01',
plane: 'M2 16l8-3V5.5a2 2 0 0 1 4 0V13l8 3v2l-8-2v3l2 1.5V22l-4-1-4 1v-1.5L10 19v-3l-8 2z',
wine: 'M8 3h8l-1 7a3 3 0 0 1-6 0z|M12 13v8|M8 21h8',
cake: 'M4 12h16v8H4z|M4 15c2 1.5 4 1.5 6 0s4-1.5 6 0 2.5 1 4 0|M12 12V8|M12 5.5c.8 0 1.2-1 0-2.5-1.2 1.5-.8 2.5 0 2.5z',
apple: 'M12 7c-3-2-7-1-7 4 0 5 3 10 5 10 1 0 1.5-.5 2-.5s1 .5 2 .5c2 0 5-5 5-10 0-5-4-6-7-4z|M12 7c0-2 1-3.5 3-4',
feather: 'M20 4c-8 0-14 4-14 12l-2 4|M20 4c0 8-4 12-12 12|M9 15l6-6',
mountain: 'M3 19l6-11 4 7 3-4 5 8z',
waves: 'M3 9c2-2 4-2 6 0s4 2 6 0 4-2 6 0|M3 14c2-2 4-2 6 0s4 2 6 0 4-2 6 0|M3 19c2-2 4-2 6 0s4 2 6 0 4-2 6 0',
text: 'M5 6V4h14v2|M12 4v16|M9 20h6',
quote: 'M5 8h5v5c0 3-1.5 4.5-4 5|M14 8h5v5c0 3-1.5 4.5-4 5',
smile: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 14.5a5 5 0 0 0 8 0|M9 9.5h.01|M15 9.5h.01',
thumbsUp: 'M7 11v9H4v-9z|M7 11l4-8c1.7 0 2.5 1 2.5 2.5V9H19a2 2 0 0 1 2 2.3l-1 6a2.5 2.5 0 0 1-2.5 2.2H7',
dice: 'M4 4h16v16H4z|M8.5 8.5h.01|M15.5 8.5h.01|M12 12h.01|M8.5 15.5h.01|M15.5 15.5h.01',
wrench: 'M14.5 6.5a4 4 0 0 0-5 5L3 18l3 3 6.5-6.5a4 4 0 0 0 5-5l-2.5 2.5-2.5-.5-.5-2.5z',
ruler: 'M3 17L17 3l4 4L7 21z|M7 13l2 2|M10 10l2 2|M13 7l2 2',
brush: 'M18 3l3 3-9 9-3-3z|M9 12c-3 0-4 2-4 4s-1 3-2 4c3 .5 7 0 7-4',
archive: 'M3 4h18v4H3z|M5 8v12h14V8|M10 12h4',
package: 'M12 3l9 4.5v9L12 21l-9-4.5v-9z|M3 7.5L12 12l9-4.5|M12 12v9',
hourglass: 'M6 3h12|M6 21h12|M7 3c0 5 5 6 5 9s-5 4-5 9|M17 3c0 5-5 6-5 9s5 4 5 9',
tv: 'M3 7h18v12H3z|M8 3l4 4 4-4',
bookOpen: 'M12 6c-2-1.5-5-2-8-2v14c3 0 6 .5 8 2 2-1.5 5-2 8-2V4c-3 0-6 .5-8 2z|M12 6v14',
newspaper: 'M4 4h13v16H6a2 2 0 0 1-2-2z|M17 8h3v10a2 2 0 0 1-2 2|M8 8h5|M8 12h5|M8 16h3',
coins: 'M4 8a6 3 0 1 0 12 0a6 3 0 1 0-12 0|M4 8v4c0 1.7 2.7 3 6 3s6-1.3 6-3V8|M16 11c2.5.3 4 1.3 4 2.5v4c0 1.7-2.7 3-6 3-2.5 0-4.6-.8-5.5-1.8',
at: 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z|M16 8v5a2 2 0 0 0 4 0v-1a8 8 0 1 0-3 6.2'
}; };
// The stroke icons offered in the icon picker, with words to find them by. // The stroke icons offered in the icon picker, with words to find them by.
@@ -107,7 +187,26 @@ export const PICKER_ICONS = [
['user', 'person me profile'], ['users', 'people team group'], ['mail', 'email letter'], ['chat', 'message talk'], ['user', 'person me profile'], ['users', 'people team group'], ['mail', 'email letter'], ['chat', 'message talk'],
['globe', 'world web internet'], ['map', 'travel place'], ['send', 'share plane'], ['tree', 'nature forest'], ['globe', 'world web internet'], ['map', 'travel place'], ['send', 'share plane'], ['tree', 'nature forest'],
['sun', 'day light summer'], ['pulse', 'health fitness activity'], ['food', 'recipe cooking eat'], ['cart', 'shopping buy'], ['sun', 'day light summer'], ['pulse', 'health fitness activity'], ['food', 'recipe cooking eat'], ['cart', 'shopping buy'],
['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin'] ['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin'],
['canvas', 'whiteboard map diagram'], ['graph', 'network connections links'], ['table', 'spreadsheet grid data'], ['list', 'bullets outline'],
['link', 'url connect'], ['search', 'find look'], ['gear', 'settings tools config'], ['save', 'disk store'],
['export', 'share out'], ['copy', 'duplicate clone'], ['monitor', 'screen display desktop'],
['shield', 'security protect safe'], ['eye', 'view watch look'], ['wifi', 'network wireless internet'], ['phone', 'mobile call'],
['headphones', 'audio listen music'], ['mic', 'record podcast voice'], ['video', 'film movie record'], ['film', 'movie cinema'],
['printer', 'print paper'], ['download', 'get save'], ['upload', 'send put'], ['cloud', 'weather sky online'],
['umbrella', 'rain weather'], ['snowflake', 'winter cold snow'], ['droplet', 'water rain'], ['fire', 'hot flame trending'],
['anchor', 'ship sea fixed'], ['flask', 'science chemistry lab test'], ['magnet', 'attract'], ['scissors', 'cut craft'],
['paperclip', 'attach file'], ['pin', 'location keep'], ['grid', 'tiles layout overview'], ['columns', 'layout split'],
['crown', 'king queen best'], ['trophy', 'win award prize'], ['medal', 'award achievement'], ['diamond', 'gem premium'],
['skull', 'danger dead hacker'], ['ghost', 'spooky halloween'], ['bug', 'insect error issue'], ['infinity', 'loop forever'],
['building', 'office company city'], ['bank', 'finance institution money'], ['cross', 'medical health plus'], ['pill', 'medicine health'],
['dumbbell', 'gym workout fitness'], ['bike', 'cycling ride'], ['car', 'drive vehicle'], ['plane', 'flight travel trip'],
['wine', 'drink glass'], ['cake', 'birthday party dessert'], ['apple', 'fruit food health'], ['feather', 'light write'],
['mountain', 'hike outdoors peak'], ['waves', 'sea ocean water'], ['text', 'type font writing'], ['quote', 'citation saying'],
['smile', 'happy mood face'], ['thumbsUp', 'like good approve'], ['dice', 'game random chance'], ['wrench', 'fix repair tool'],
['ruler', 'measure design'], ['brush', 'paint art draw'], ['archive', 'old storage box'], ['package', 'box delivery release'],
['hourglass', 'wait time later'], ['tv', 'television watch'], ['bookOpen', 'read library story'], ['newspaper', 'news article press'],
['coins', 'money cash savings'], ['at', 'mention email']
]; ];
// Emoji offered in the picker, as "emoji words" pairs. // Emoji offered in the picker, as "emoji words" pairs.
@@ -127,7 +226,20 @@ export const PICKER_EMOJI = [
'❤️ heart love', '💪 strong fitness', '🏃 run exercise', '🧘 yoga calm', '😴 sleep', '🩺 doctor health', '💊 pill medicine', '🏋️ gym', '❤️ heart love', '💪 strong fitness', '🏃 run exercise', '🧘 yoga calm', '😴 sleep', '🩺 doctor health', '💊 pill medicine', '🏋️ gym',
'🔒 lock private', '🔑 key password', '🛠️ tools build', '🔧 wrench fix', '⚙️ gear settings', '🧰 toolbox', '🐛 bug', '🧹 cleanup', '🔒 lock private', '🔑 key password', '🛠️ tools build', '🔧 wrench fix', '⚙️ gear settings', '🧰 toolbox', '🐛 bug', '🧹 cleanup',
'📦 package box', '🎁 gift', '🛒 shopping cart', '🏷️ tag label', '🔔 bell reminder', '❓ question', '❗ important', '⚠️ warning', '📦 package box', '🎁 gift', '🛒 shopping cart', '🏷️ tag label', '🔔 bell reminder', '❓ question', '❗ important', '⚠️ warning',
'🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus' '🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus',
'🧭 compass direction', '🔭 telescope space', '🪐 planet space', '🌟 glowing star', '💫 dizzy star', '🌠 shooting star', '🌌 galaxy night', '🛰️ satellite',
'🛸 ufo', '🧬 dna biology', '⚗️ alembic chemistry', '🧲 magnet', '🔋 battery', '💾 floppy save', '💿 disc', '📀 dvd',
'🖨️ printer', '📱 phone mobile', '☎️ telephone', '📡 antenna signal', '🔌 plug power', '🪙 coin', '💎 gem diamond',
'👑 crown', '🏆 trophy win', '🥇 gold medal first', '🎖️ medal', '🎓 graduation cap', '🏛️ classical building', '⚖️ scales law', '🧮 abacus math',
'✏️ pencil', '🖊️ pen', '🖋️ fountain pen', '🖌️ paintbrush', '📐 triangle ruler', '📏 ruler', '✂️ scissors', '📋 clipboard',
'📰 newspaper news', '🗞️ rolled newspaper', '📜 scroll', '📃 page', '📄 document', '📑 bookmark tabs', '📓 notebook', '📔 notebook decorated',
'🍇 grapes', '🍊 orange fruit', '🍋 lemon', '🍓 strawberry', '🥑 avocado', '🥕 carrot', '🌶️ chili pepper', '🍞 bread',
'🧀 cheese', '🍜 noodles ramen', '🍣 sushi', '🍪 cookie', '🍫 chocolate', '🍷 wine', '🍺 beer', '🥂 cheers',
'⚽ football soccer', '🏀 basketball', '🎾 tennis', '🏓 ping pong', '⛳ golf', '🎳 bowling', '🥊 boxing', '🛹 skateboard',
'🚂 train', '🚌 bus', '🚢 ship', '⛵ sailboat', '🚁 helicopter', '🏍️ motorcycle', '🛴 scooter', '⛽ fuel',
'🦄 unicorn', '🐧 penguin', '🦁 lion', '🐯 tiger', '🐻 bear', '🐨 koala', '🐝 bee', '🐞 ladybug',
'😀 smile happy', '😎 cool sunglasses', '🤔 thinking', '😂 laugh', '🥳 party', '😇 angel', '🤯 mind blown', '🫡 salute',
'👍 thumbs up like', '👏 clap', '🙏 thanks pray', '✌️ peace victory', '💯 hundred perfect', '🎉 party celebrate', '🎊 confetti', '🔮 crystal ball'
].map((s) => { const i = s.indexOf(' '); return [s.slice(0, i), s.slice(i + 1)]; }); ].map((s) => { const i = s.indexOf(' '); return [s.slice(0, i), s.slice(i + 1)]; });
export const isEmoji = (s) => typeof s === 'string' && /\p{Extended_Pictographic}/u.test(s) && s.length <= 16; export const isEmoji = (s) => typeof s === 'string' && /\p{Extended_Pictographic}/u.test(s) && s.length <= 16;
@@ -140,6 +252,9 @@ export const ic = (name, cls = '') =>
export const PAGE_ICONS = ['sparkle', 'pen', 'leaf', 'book', 'code', 'moon', 'compass', 'cup']; export const PAGE_ICONS = ['sparkle', 'pen', 'leaf', 'book', 'code', 'moon', 'compass', 'cup'];
// A cover can be a gradient (a key of COVERS) or a picture saved next to the page ("header.png" in its front matter).
export const IMAGE_COVER = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
export const isImageCover = (v) => typeof v === 'string' && !!v && !(v in COVERS) && IMAGE_COVER.test(v) && !/^[a-z][a-z0-9+.-]*:/i.test(v);
export const COVERS = { export const COVERS = {
blush: ['#d99a91', '#caa07c'], blush: ['#d99a91', '#caa07c'],
sage: ['#97b096', '#d1b48e'], sage: ['#97b096', '#d1b48e'],
+91
View File
@@ -0,0 +1,91 @@
// Kanban boards stored as plain Markdown, in the same shape as the Obsidian Kanban plugin:
//
// ---
// kanban-plugin: basic <- front matter marks the page as a board
// ---
//
// ## To do <- a column
//
// - [ ] Write the intro <- a card (text keeps inline Markdown: **bold**, [[links]] …)
// with a second line <- indented lines belong to the card above
// - [x] Pick a title <- a checked card
//
// %% kanban:settings ... %% <- anything from a *** or %% line on is kept as is
//
// Lines in a column that aren't cards (e.g. the plugin's **Complete** marker) are kept too.
import { parseMarkdown, htmlToMd } from './markdown.js';
export const BOARD_KEY = 'kanban-plugin';
export const isBoard = (meta) => !!(meta && meta[BOARD_KEY]);
let seq = 0;
const uid = (p) => p + (++seq).toString(36) + Math.random().toString(36).slice(2, 6);
export const card = (text = '', done = false) => ({ id: uid('k'), text, done });
export const column = (title = 'Untitled', cards = []) => ({ id: uid('c'), title, extra: [], cards });
export function parseBoard(src) {
const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
const board = { head: '', columns: [], tail: '' };
const head = [];
let col = null;
let last = null; // card that indented lines continue
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
if (t === '***' || t.startsWith('%% kanban')) {
board.tail = lines.slice(i).join('\n').replace(/\s+$/, '');
break;
}
let m;
if ((m = line.match(/^##\s+(.*?)\s*$/))) {
col = column(m[1]);
board.columns.push(col);
last = null;
continue;
}
if (!col) { head.push(line); continue; }
if ((m = line.match(/^[-*+]\s+(?:\[([ xX])\]\s?)?(.*)$/))) {
last = card(m[2], !!m[1] && m[1] !== ' ');
col.cards.push(last);
continue;
}
if (last && /^(\s{2,}|\t)\S/.test(line)) { last.text += '\n' + line.replace(/^(\s{2}|\t)/, ''); continue; }
if (line.trim()) { col.extra.push(line); last = null; }
}
board.head = head.join('\n').trim();
return board;
}
export function boardToMd(board) {
const parts = [];
if (board.head) parts.push(board.head);
for (const c of board.columns) {
const body = [...c.extra, ...c.cards.map((k) => `- [${k.done ? 'x' : ' '}] ` + k.text.replace(/\n/g, '\n '))];
parts.push(`## ${c.title}` + (body.length ? '\n\n' + body.join('\n') : ''));
}
if (board.tail) parts.push(board.tail);
return parts.length ? parts.join('\n\n\n') + '\n' : '';
}
export const defaultBoard = () => ({ head: '', tail: '', columns: [column('To do'), column('Doing'), column('Done')] });
// Turn an ordinary page into a board: each ## heading becomes a column, list items and paragraphs become cards.
// Anything before the first heading lands in a "To do" column.
export function boardFromMarkdown(body) {
const blocks = parseMarkdown(body);
const columns = [];
let col = null;
for (const b of blocks) {
if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3') {
col = column(htmlToMd(b.html).trim() || 'Untitled');
columns.push(col);
continue;
}
const text = b.type === 'code' ? '`' + (b.text || '').split('\n')[0] + '`' : b.type === 'toggle' ? htmlToMd(b.html) : htmlToMd(b.html);
if (b.type === 'divider' || b.type === 'image' || !text.trim()) continue;
if (!col) { col = column('To do'); columns.unshift(col); }
col.cards.push(card(text.trim().replace(/\n+/g, '\n'), !!b.checked));
}
if (!columns.length) return defaultBoard();
return { head: '', tail: '', columns };
}
+57
View File
@@ -0,0 +1,57 @@
// [[Wiki links]] between pages. Kept in step with electron/links.cjs, which does the same on disk.
//
// [[Roadmap]] -> the page titled "Roadmap" (shortest path wins when titles repeat)
// [[Projects/Roadmap]] -> that exact page
// [[Roadmap|the plan]] -> shown as "the plan"
// [[Roadmap#Goals]] -> that heading of the page; shown as "Roadmap › Goals"
// [[#Goals]] -> a heading of the page you're on
// ![[Roadmap]] -> the page's content shown inline (an "embed"); ![[Roadmap#Goals]] just that section
export const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim();
// The heading a link points at ('' if none). Only the last #part counts, and #^blockid isn't a heading.
export const headingOf = (ref) => {
const s = String(ref || '');
const i = s.lastIndexOf('#');
if (i < 0) return '';
const h = s.slice(i + 1).trim();
return h.startsWith('^') ? '' : h;
};
// Headings are matched ignoring case and extra spaces.
export const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
// Heading text as it is written inside a link: # | ^ [ ] would break the link syntax, so they become spaces.
export const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
// Does a link's heading part point at this heading? (Either as the heading is written or in its link-safe form.)
export const matchesHeading = (linkText, headingText) => {
const a = normHeading(linkText);
return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText)));
};
// Files that ![[...]] shows as a picture, and other files (not notes) that it can't show.
export const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
export const OTHER_FILE = /\.(pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
export const linkLabel = (ref) => {
const p = refPage(ref);
const name = p ? p.slice(p.lastIndexOf('/') + 1) : '';
const h = headingOf(ref);
return name && h ? `${name} › ${h}` : name || h || String(ref || '');
};
export function resolveLink(ref, pages) {
const want = refPage(ref).toLowerCase();
if (!want) return null;
const exact = pages.find((p) => p.id.toLowerCase() === want);
if (exact) return exact.id;
// by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas
const byTitle = pages.filter((p) => p.id.slice(p.id.lastIndexOf('/') + 1).toLowerCase() === want).sort((a, b) => a.id.length - b.id.length);
return byTitle.length ? byTitle[0].id : null;
}
// The shortest text that links to `id`: its title when that's unique, else the full path.
export function refFor(id, pages) {
const title = id.slice(id.lastIndexOf('/') + 1);
const same = pages.filter((p) => p.id.slice(p.id.lastIndexOf('/') + 1).toLowerCase() === title.toLowerCase());
return same.length <= 1 ? title : id;
}
+69 -2
View File
@@ -241,6 +241,34 @@ export function parseMarkdown(src) {
out.push(block('quote', mdToHtml(buf.join('\n')))); out.push(block('quote', mdToHtml(buf.join('\n'))));
continue; continue;
} }
// :::: columns / ::: column ... ::: / :::: : side-by-side blocks (fenced divs; plain text anywhere else)
if (/^:{4,}\s*columns\s*$/.test(line)) {
let j = i + 1;
while (j < lines.length && !/^:{4,}\s*$/.test(lines[j])) j++;
if (j < lines.length) {
const cols = [];
let cur = null;
for (const l of lines.slice(i + 1, j)) {
const open = l.match(/^:{3}\s*(?:column|\{\s*\.column(?:\s+width\s*=\s*"?([\d.]+)%"?)?\s*\})\s*$/);
if (open) { cur = { lines: [], w: open[1] ? +open[1] : null }; cols.push(cur); }
else if (/^:{3}\s*$/.test(l)) cur = null;
else if (cur) cur.lines.push(l);
}
if (cols.length) {
flush();
const g = blockId();
// widths count only if every column has one
const widths = cols.every((c) => c.w > 0) ? cols.map((c) => c.w) : null;
cols.forEach((col, k) => {
let inner = parseMarkdown(col.lines.join('\n'));
if (!inner.length) inner = [block('p')];
for (const b of inner) { if (b.col) delete b.col; b.col = widths ? { g, i: k, w: widths[k] } : { g, i: k }; out.push(b); }
});
i = j + 1;
continue;
}
}
}
if (/^<details(\s[^>]*)?>\s*$/i.test(line.trim())) { if (/^<details(\s[^>]*)?>\s*$/i.test(line.trim())) {
flush(); flush();
i++; i++;
@@ -341,12 +369,38 @@ export function blockToMd(b, n = 1) {
} }
} }
// A run of blocks that share a column group, as fenced columns.
function columnsToMd(run) {
const byCol = new Map();
for (const b of run) {
if (!byCol.has(b.col.i)) byCol.set(b.col.i, []);
byCol.get(b.col.i).push(b);
}
const keys = [...byCol.keys()].sort((a, b) => a - b);
const widths = keys.every((k) => byCol.get(k)[0].col.w > 0) ? keys.map((k) => byCol.get(k)[0].col.w) : null;
const parts = keys.map((k, n) => `${widths ? `::: {.column width="${+widths[n].toFixed(1)}%"}` : '::: column'}\n${toMarkdown(byCol.get(k).map((b) => ({ ...b, col: undefined }))).trimEnd()}\n:::`);
return `:::: columns\n${parts.join('\n')}\n::::`;
}
export function toMarkdown(blocks) { export function toMarkdown(blocks) {
let out = ''; let out = '';
let prevList = false; let prevList = false;
let prevType = null; let prevType = null;
let n = 0; // position in a run of numbered items let n = 0; // position in a run of numbered items
for (const b of blocks) { for (let idx = 0; idx < blocks.length; idx++) {
const b = blocks[idx];
if (b.col) {
let end = idx;
while (end + 1 < blocks.length && blocks[end + 1].col && blocks[end + 1].col.g === b.col.g) end++;
const md = columnsToMd(blocks.slice(idx, end + 1));
out += out ? '\n\n' : '';
out += md;
idx = end;
prevList = false;
prevType = 'columns';
n = 0;
continue;
}
if (b.type === 'p' && !htmlToMd(b.html).trim()) continue; if (b.type === 'p' && !htmlToMd(b.html).trim()) continue;
const isList = LISTS.has(b.type); const isList = LISTS.has(b.type);
n = b.type === 'number' ? n + 1 : 0; n = b.type === 'number' ? n + 1 : 0;
@@ -391,7 +445,18 @@ export function toHtmlDocument(title, raw) {
const blocks = raw.map((b) => ({ ...b, html: fns.html(b.html), body: b.body && fns.html(b.body), rows: b.rows && b.rows.map((r) => r.map(fns.html)) })); const blocks = raw.map((b) => ({ ...b, html: fns.html(b.html), body: b.body && fns.html(b.body), rows: b.rows && b.rows.map((r) => r.map(fns.html)) }));
const body = []; const body = [];
let list = null; // 'ul' or 'ol' while inside a list let list = null; // 'ul' or 'ol' while inside a list
let curKey = null, curG = null; // the column (group:index) and group the output is in
for (const b of blocks) { for (const b of blocks) {
const key = b.col ? `${b.col.g}:${b.col.i}` : null;
const g = b.col ? b.col.g : null;
if (key !== curKey) {
if (list) { body.push(`</${list}>`); list = null; }
if (curG !== null && g !== curG) body.push('</div></div>');
else if (curKey !== null) body.push('</div>');
if (g !== null && g !== curG) body.push('<div class="cols"><div class="col">');
else if (g !== null) body.push('<div class="col">');
curKey = key; curG = g;
}
const want = b.type === 'number' ? 'ol' : LISTS.has(b.type) ? 'ul' : null; const want = b.type === 'number' ? 'ol' : LISTS.has(b.type) ? 'ul' : null;
if (list && list !== want) { body.push(`</${list}>`); list = null; } if (list && list !== want) { body.push(`</${list}>`); list = null; }
if (want && !list) { body.push(`<${want}>`); list = want; } if (want && !list) { body.push(`<${want}>`); list = want; }
@@ -418,7 +483,8 @@ export function toHtmlDocument(title, raw) {
default: if (b.html) body.push(`<p>${b.html}</p>`); default: if (b.html) body.push(`<p>${b.html}</p>`);
} }
} }
if (list) body.push(`</${list}>`); if (list) { body.push(`</${list}>`); list = null; }
if (curG !== null) body.push('</div></div>');
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -438,6 +504,7 @@ export function toHtmlDocument(title, raw) {
hr { border: 0; border-top: 1px solid #e3e3e6; margin: 24px 0; } hr { border: 0; border-top: 1px solid #e3e3e6; margin: 24px 0; }
img { max-width: 100%; border-radius: 12px; } img { max-width: 100%; border-radius: 12px; }
table { border-collapse: collapse; margin: 12px 0; } th, td { border: 1px solid #e3e3e6; padding: 6px 10px; } th { background: #f3f3f4; text-align: left; } table { border-collapse: collapse; margin: 12px 0; } th, td { border: 1px solid #e3e3e6; padding: 6px 10px; } th { background: #f3f3f4; text-align: left; }
.cols { display: flex; gap: 28px; } .col { flex: 1; min-width: 0; }
a { color: #a8564c; } a { color: #a8564c; }
.wl { color: #a8564c; text-decoration: underline; text-decoration-color: #e3c2bb; } .wl { color: #a8564c; text-decoration: underline; text-decoration-color: #e3c2bb; }
</style> </style>
+307
View File
@@ -0,0 +1,307 @@
// Browser-only stand-in for the Electron bridge. Same method names and shapes as preload.cjs.
// Keeps notes in memory (and localStorage when available) so `npm run dev:web` is usable.
import { resolveLink } from './links.js';
const KEY = 'notes-web-mock-v1';
const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
const today = () => new Date().toISOString().slice(0, 10);
const SEED = {
'Getting started': {
meta: { icon: 'sparkle', cover: 'blush', tags: ['guide'], created: today() },
body: `> [!note]
> This is the browser preview. In the desktop app every page is a .md file in your notes folder.
## Write the way you think
- Type \`/\` on any line to add a heading, to-do, quote, toggle or code block.
- Start a line with \`#\`, \`-\`, \`[]\` or \`>\` and it turns into that block as you type.
- Press <kbd>Ctrl</kbd> <kbd>K</kbd> to search every note.
- [x] Pick a folder for your notes
- [ ] Write one page, however short
`
},
Projects: { meta: { icon: 'compass', cover: 'sage', created: today() }, body: 'Things in motion.\n' },
'Projects/Note app roadmap': {
meta: { icon: 'code', cover: 'dusk', tags: ['dev', 'todo'], created: today() },
body: `An offline notes app built on Electron and SvelteKit.
### v0.1
- [x] Block editor with a slash menu
- [ ] Full-text search with SQLite FTS5
\`\`\`js
const id = await window.vault.create(null, 'Untitled')
\`\`\`
`
}
};
export function createMock() {
let files = null;
let settings = { theme: 'dark' };
try {
const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
if (saved) { files = saved.files; settings = saved.settings || settings; }
} catch { /* storage unavailable */ }
let opened = !!files;
files = files || structuredClone(SEED);
const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ files, settings })); } catch { /* ignore */ } };
const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null);
const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1);
const pages = () => {
const ids = new Set(Object.keys(files));
for (const id of [...ids]) { let p = parentOf(id); while (p) { ids.add(p); p = parentOf(p); } }
return [...ids].map((id) => {
const f = files[id];
return {
id, parent: parentOf(id), title: titleOf(id), exists: !!f, mtime: 0,
icon: f?.meta.icon || null, cover: f?.meta.cover || null, tags: f?.meta.tags || [], created: f?.meta.created || null,
...(f?.meta['ink-database'] ? { db: true } : {}),
props: Object.fromEntries(Object.entries(f?.meta || {}).filter(([k]) => !['icon', 'cover', 'tags', 'created'].includes(k)))
};
});
};
const snap = () => ({ path: '~/Notes', name: 'Notes', pages: pages() });
const free = (parent, base) => {
for (let n = 1; ; n++) {
const id = (parent ? parent + '/' : '') + (n === 1 ? base : `${base} ${n}`);
if (!files[id] && !pages().some((p) => p.id === id)) return id;
}
};
const listeners = new Set();
const images = new Map();
const trash = []; // [{ entry, id, title, icon, pages, deletedAt, gone: { id: file } }], kept for this session
return {
platform: 'web',
async listTemplates() {
return ['Daily Note', 'Meeting'];
},
async duplicate(id) {
const f = files[id];
if (!f) throw new Error('That page has no file to copy.');
let next = id + ' copy';
for (let n = 2; files[next]; n++) next = `${id} copy ${n}`;
files[next] = structuredClone(f);
save();
return next;
},
async saveImageFromUrl() { throw new Error('Adding a picture from a link needs the desktop app.'); },
async pickFile() { throw new Error('Attaching files needs the desktop app.'); },
async openFile() { throw new Error('Opening files needs the desktop app.'); },
async createMany(parent, items) {
const ids = [];
for (const it of items || []) ids.push(await this.create(parent, it.title, it.meta, ''));
return ids;
},
async createCanvas() { throw new Error('Canvases need the desktop app.'); },
async openTemplatesFolder() { return 'The templates folder needs the desktop app.'; },
async getTemplate(name) {
if (name === 'Daily Note') return { meta: { tags: ['daily'] }, body: '# Daily Note\n\n' };
if (name === 'Meeting') return { meta: { tags: ['meeting'] }, body: '# Meeting\n\n' };
return null;
},
async get() { return opened ? snap() : null; },
async list() { return snap(); },
async pick() { opened = true; save(); return snap(); },
async read(id) {
const f = files[id];
return { id, title: titleOf(id), meta: f ? structuredClone(f.meta) : {}, body: f ? f.body : '', exists: !!f, mtime: 0 };
},
async write(id, meta, body) { files[id] = { meta: structuredClone(meta || {}), body: body || '' }; save(); return Date.now(); },
async create(parent, title, meta, body) {
const id = free(parent, (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled');
files[id] = { meta: { created: today(), ...(meta || {}) }, body: body || '' };
save();
return id;
},
async rename(id, title) {
const clean = (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled';
if (clean === titleOf(id)) return id;
const next = free(parentOf(id), clean);
for (const k of Object.keys(files)) {
if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
}
save();
return next;
},
async move(id, parent) {
if ((parent || null) === parentOf(id)) return id;
if (parent && (parent === id || parent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.');
const next = free(parent || null, titleOf(id));
for (const k of Object.keys(files)) {
if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
}
save();
return next;
},
// No version history in the browser preview.
async keepVersion() {},
async listHistory() { return []; },
async readHistory() { throw new Error('Version history needs the desktop app.'); },
async restoreHistory() { throw new Error('Version history needs the desktop app.'); },
async graph() {
const all = pages();
const nodes = all.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon, parent: p.parent, tags: p.tags }));
const edges = [], ghosts = new Map(), seen = new Set();
for (const [id, f] of Object.entries(files)) {
for (const m of f.body.matchAll(/(!?)\[\[([^\[\]\n|#]*)/g)) {
// ![[Page]] embeds count as links; ![[photo.png]] is a file, and [[#Heading]] points at its own page
if ((m[1] && FILE.test(m[2].trim())) || !m[2].trim()) continue;
let to = resolveLink(m[2], all);
if (!to) { to = '?' + m[2].trim().toLowerCase(); ghosts.set(to, { id: to, title: m[2].trim() }); }
if (to !== id && !seen.has(id + to)) { seen.add(id + to); edges.push([id, to]); }
}
}
return { nodes, edges, ghosts: [...ghosts.values()] };
},
// A heading of page `id` was renamed: update [[Page#from]] links and ![[Page#from]] embeds elsewhere (browser preview).
async renameHeading(id, from, to) {
const all = pages();
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const safe = String(to).replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
const touched = [];
let count = 0;
if (!safe) return { pages: touched, count };
for (const [k, f] of Object.entries(files)) {
if (k === id) continue;
let n = 0;
const body = f.body.replace(/(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g, (m, bang, ref, alias) => {
const at = ref.lastIndexOf('#');
if (at < 0 || !ref.slice(0, at).replace(/#.*/, '').trim() || resolveLink(ref, all) !== id || norm(ref.slice(at + 1)) !== norm(from)) return m;
n++;
return `${bang}[[${ref.slice(0, at + 1)}${safe}${alias || ''}]]`;
});
if (n) { f.body = body; touched.push(k); count += n; }
}
if (touched.length) save();
return { pages: touched, count };
},
async tasks() {
const out = [];
for (const [id, f] of Object.entries(files)) {
if (f.meta['kanban-plugin'] || id.endsWith('.canvas')) continue;
f.body.split('\n').forEach((l, line) => {
const m = l.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/);
if (!m) return;
const due = m[2].match(/(?:📅|\bdue:)\s*(\d{4}-\d{2}-\d{2})/);
const tags = new Set(f.meta.tags || []);
for (const t of m[2].matchAll(/(?:^|\s)#([\w/-]+)/g)) tags.add(t[1]);
out.push({ id, title: titleOf(id), icon: f.meta.icon || null, line, done: m[1] !== ' ', text: m[2].trim(), due: due ? due[1] : '', tags: [...tags], depth: 0 });
});
}
return out;
},
async setTask(id, line, text, done) {
const rows = files[id].body.split('\n');
const m = rows[line] && rows[line].match(/^(\s*[-*+]\s+)\[[ xX]\](\s+)(.*)$/);
if (!m || m[3].trim() !== text.trim()) throw new Error('That task changed. Reopen the list.');
rows[line] = m[1] + (done ? '[x]' : '[ ]') + m[2] + m[3];
files[id].body = rows.join('\n');
save();
},
async unlinked(id) {
const title = titleOf(id);
if (title.length < 3) return [];
const all = pages();
const re = new RegExp('(?<![\\p{L}\\p{N}_])' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\p{L}\\p{N}_])', 'iu');
return Object.entries(files)
.filter(([k, f]) => k !== id && re.test(f.body.replace(/\[\[[^\]]*\]\]/g, '')) && ![...f.body.matchAll(/\[\[([^\[\]\n|#]+)/g)].some((m) => resolveLink(m[1], all) === id))
.map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => re.test(l)) || '').trim() }));
},
async linkMention(id, target) {
const title = titleOf(target);
const re = new RegExp('(?<![\\p{L}\\p{N}_])' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\p{L}\\p{N}_])', 'iu');
files[id].body = files[id].body.replace(re, (m) => (m === title ? `[[${title}]]` : `[[${title}|${m}]]`));
save();
},
async openCapture() {},
async takeCaptures() { return []; },
onCapture() { return () => {}; },
async backlinks(id) {
const all = pages();
return Object.entries(files)
.filter(([k, f]) => k !== id && [...f.body.matchAll(/(!?)\[\[([^\[\]\n|]+)/g)].some((m) => !(m[1] && FILE.test(m[2].trim())) && resolveLink(m[2], all) === id))
.map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => l.includes('[[')) || '').replace(/[[\]]/g, '') }));
},
// Images live in memory only here; the desktop app writes them next to the note.
async saveImage(_id, name, bytes) {
const key = `${Date.now().toString(36)}-${name}`;
images.set(key, URL.createObjectURL(new Blob([bytes])));
return key;
},
async pickImage(id) {
const input = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/*' });
const file = await new Promise((done) => { input.onchange = () => done(input.files[0] || null); input.click(); });
return file ? this.saveImage(id, file.name, new Uint8Array(await file.arrayBuffer())) : null;
},
mediaUrl: (_id, src) => images.get(src) || src,
async remove(id) {
const gone = {};
for (const k of Object.keys(files)) if (k === id || k.startsWith(id + '/')) { gone[k] = files[k]; delete files[k]; }
const entry = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
trash.unshift({ entry, id, title: titleOf(id), icon: gone[id]?.meta.icon || null, pages: Object.keys(gone).length, deletedAt: Date.now(), gone });
save();
return entry;
},
async trash() { return trash.map(({ gone, ...t }) => t); },
async restore(entry) {
const t = trash.find((x) => x.entry === entry);
if (!t) throw new Error('Unknown trash item.');
const next = free(parentOf(t.id), titleOf(t.id));
for (const [k, f] of Object.entries(t.gone)) files[next + k.slice(t.id.length)] = f;
trash.splice(trash.indexOf(t), 1);
save();
return next;
},
async purge(entry) {
const keep = entry ? trash.filter((x) => x.entry !== entry) : [];
trash.splice(0, trash.length, ...keep);
},
async search(q) {
const terms = String(q).toLowerCase().match(/[\p{L}\p{N}_]+/gu) || [];
if (!terms.length) return [];
return Object.entries(files)
.filter(([id, f]) => terms.every((t) => (id + ' ' + f.body).toLowerCase().includes(t)))
.slice(0, 20)
.map(([id, f]) => {
const lo = f.body.toLowerCase();
const at = lo.indexOf(terms[0]);
const start = Math.max(0, at - 30);
const snip = at < 0 ? f.body.slice(0, 90)
: (start ? '…' : '') + f.body.slice(start, at) + '\u0001' + f.body.slice(at, at + terms[0].length) + '\u0002' + f.body.slice(at + terms[0].length, at + 70);
return { id, title: titleOf(id), snip };
});
},
async reveal() {},
async filePath(id) { return `~/Notes/${id}.md`; },
async saveExport(name, content) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
return name;
},
async linkPreview() { return null; },
async getSettings() { return { ...settings }; },
async setSettings(patch) { settings = { ...settings, ...patch }; save(); return settings; },
async getSpellcheck() { return { enabled: settings.spellcheck !== false, languages: [], available: [], system: true }; },
async setSpellcheck(enabled) { settings = { ...settings, spellcheck: !!enabled }; save(); return true; },
async spellWords() { return Array.isArray(settings.words) ? settings.words : []; },
async addSpellWord(word) { settings = { ...settings, words: [...new Set([...(settings.words || []), word])] }; save(); },
async removeSpellWord(word) { settings = { ...settings, words: (settings.words || []).filter((w) => w !== word) }; save(); },
async backupNow() { throw new Error('Backups need the desktop app.'); },
async backupInfo() { return { folder: '', last: 0 }; },
async pickBackupFolder() { return null; },
async revealBackups() {},
onNav(cb) { return () => {}; },
onChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); },
onFlush() { return () => {}; }
};
}
+115
View File
@@ -0,0 +1,115 @@
// Settings beyond the few that have their own field on the store (theme, text size, ...). One object, saved as `prefs`.
// Every key has a default; cleanPrefs() fills gaps and drops anything of the wrong kind from a file written by hand.
export const DEFAULTS = {
// appearance
uiFont: 'inter', // 'inter' | 'system' | 'mono'
density: 'default', // sidebar rows and spacing: 'compact' | 'default' | 'comfortable'
sidebarWidth: 268, // px
showCover: true,
showIcon: true,
showBacklinks: true,
codeSize: 100, // % of the page's code size
codeWrap: false, // wrap long lines in code blocks instead of scrolling
customPageFont: '', // an installed font's name for page text; '' uses the Page font choice
customCodeFont: '', // same, for code
// editor
autoPair: true, // ( [ { ` close themselves
smartTypography: false, // -- to an em dash, ... to an ellipsis, curly quotes
pasteRich: true, // pasted web / rich text keeps headings, lists, links and bold as Markdown
mdShortcuts: true, // "# " and "- " turn a line into a heading or a list
typewriter: false, // keep the line being typed in the middle of the screen
hiddenKinds: [], // block kinds left out of the slash menu
autosaveDelay: 600, // ms after the last keystroke
// notes
dateFormat: 'YYYY-MM-DD', // daily note names
dailyTemplate: '', // text a new daily note starts with; {{date}} and {{weekday}} are filled in
newPageIcon: 'random', // 'random' | 'none'
newPageCover: 'random', // 'random' | 'none'
weekStart: 'monday', // calendar views: 'monday' | 'sunday'
locale: '', // date language; '' follows the system
exportLinks: 'wiki', // [[Page]] links in Markdown exports: 'wiki' | 'markdown'
confirmTrash: false, // ask before moving a page to the trash
trashDays: 30, // days in the trash before it is emptied; 0 never
historyKeep: 100, // versions kept per page
historyGapMin: 5, // at most one version per this many minutes of editing
// canvas
snapToGrid: true,
cardColor: '', // color of new canvas cards: '' (none) or a preset 1-6
// window
alwaysOnTop: false,
startMinimized: false,
// keyboard
shortcuts: {}, // { action id: 'Mod+Shift+K' } for the shortcuts that were changed (src/lib/shortcuts.js)
// quick capture
captureShortcut: 'CommandOrControl+Alt+N', // global shortcut for the capture window; '' turns it off
captureTo: 'daily', // 'daily' (today's note) | 'inbox'
// backups
backupEvery: 'off', // 'off' | 'daily' | 'weekly'
backupKeep: 5, // zips kept
backupFolder: '' // '' = <app data>/backups
};
export const DATE_FORMATS = ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM-DD-YYYY', 'YYYY.MM.DD', 'YYYY-MM-DD dddd', 'MMMM D, YYYY'];
const CHOICES = {
uiFont: ['inter', 'system', 'mono'],
density: ['compact', 'default', 'comfortable'],
newPageIcon: ['random', 'none'],
newPageCover: ['random', 'none'],
weekStart: ['monday', 'sunday'],
exportLinks: ['wiki', 'markdown'],
backupEvery: ['off', 'daily', 'weekly'],
captureTo: ['daily', 'inbox'],
dateFormat: DATE_FORMATS,
cardColor: ['', '1', '2', '3', '4', '5', '6']
};
const RANGES = {
sidebarWidth: [220, 420], codeSize: [80, 140], autosaveDelay: [200, 5000],
trashDays: [0, 365], historyKeep: [5, 1000], historyGapMin: [1, 120], backupKeep: [1, 50]
};
export function cleanPrefs(raw) {
const src = raw && typeof raw === 'object' ? raw : {};
const out = {};
for (const [k, def] of Object.entries(DEFAULTS)) {
const v = src[k];
if (k === 'shortcuts') {
out[k] = {};
if (v && typeof v === 'object' && !Array.isArray(v)) for (const [id, c] of Object.entries(v)) if (typeof c === 'string' && /^Mod(\+(Alt|Shift))*\+.+$/.test(c)) out[k][id] = c;
continue;
}
if (CHOICES[k]) out[k] = CHOICES[k].includes(v) ? v : def;
else if (RANGES[k]) {
const n = Math.round(Number(v));
out[k] = Number.isFinite(n) && v !== '' && v !== null ? Math.min(RANGES[k][1], Math.max(RANGES[k][0], n)) : def;
} else if (typeof def === 'boolean') out[k] = typeof v === 'boolean' ? v : def;
else if (Array.isArray(def)) out[k] = Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, 64) : [];
else out[k] = typeof v === 'string' ? v.slice(0, 4000) : def;
}
return out;
}
// [[Page]] -> [Page](Page.md), [[Page|label]] -> [label](Page.md), ![[photo.png]] -> ![](photo.png); a #heading goes
// into the address. Links inside code are left alone.
export function wikiToMarkdownLinks(md) {
const enc = (t) => encodeURI(t).replace(/\(/g, '%28').replace(/\)/g, '%29');
const link = (embed, target, alias) => {
const [name, heading] = target.split('#');
const file = /\.[a-z0-9]{2,5}$/i.test(name) ? name : name + '.md';
const label = alias || target;
if (embed && /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(name)) return `![${alias || ''}](${enc(name)})`;
return `${embed ? '!' : ''}[${label}](${enc(file)}${heading ? '#' + enc(heading.toLowerCase().replace(/\s+/g, '-')) : ''})`;
};
let fenced = false;
return md
.split('\n')
.map((line) => {
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; return line; }
if (fenced) return line;
return line
.split(/(`[^`]*`)/)
.map((part, i) => (i % 2 ? part : part.replace(/(!?)\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, bang, target, alias) => link(!!bang, target.trim(), alias && alias.trim()))))
.join('');
})
.join('\n');
}
+12
View File
@@ -0,0 +1,12 @@
// Scroll a page's block into view and flash it, so you can see where you landed.
// Used by the outline and by [[Page#Heading]] links. Returns how long (ms) smooth scrolling will keep running.
export function scrollToBlock(scroller, el) {
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 24;
scroller.scrollTo({ top: Math.max(0, top), behavior: reduce ? 'auto' : 'smooth' });
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
el.addEventListener('animationend', () => el.classList.remove('flash'), { once: true });
return reduce ? 50 : 700;
}
+52
View File
@@ -0,0 +1,52 @@
// The app-level keyboard shortcuts, and the ones a user has changed (prefs.shortcuts: { id: 'Mod+Shift+K' }).
// A combo is written "Mod+Shift+K": Mod is Ctrl (⌘ on a Mac), then Alt / Shift, then one key.
export const ACTIONS = [
{ id: 'palette', label: 'Search and commands', combo: 'Mod+K' },
{ id: 'newPage', label: 'New page', combo: 'Mod+N' },
{ id: 'daily', label: "Today's note", combo: 'Mod+Shift+D' },
{ id: 'tasks', label: 'Tasks', combo: 'Mod+Shift+T' },
{ id: 'graph', label: 'Graph view', combo: 'Mod+G' },
{ id: 'outline', label: 'Outline', combo: 'Mod+Shift+O' },
{ id: 'split', label: 'Split view', combo: 'Mod+Shift+\\' },
{ id: 'sidebar', label: 'Show or hide the sidebar', combo: 'Mod+\\' },
{ id: 'find', label: 'Find in page', combo: 'Mod+F' },
{ id: 'save', label: 'Save now', combo: 'Mod+S' },
{ id: 'settings', label: 'Settings', combo: 'Mod+,' }
];
export const comboOf = (id, custom = {}) => {
const mine = custom && typeof custom[id] === 'string' ? custom[id] : '';
return mine || (ACTIONS.find((a) => a.id === id) || {}).combo || '';
};
const MODS = ['Mod', 'Alt', 'Shift'];
// The combo a keydown event is, or '' when it has no Ctrl/⌘ (the app's shortcuts all have one).
export function eventCombo(e) {
if (!(e.ctrlKey || e.metaKey)) return '';
const k = e.key;
if (!k || ['Control', 'Meta', 'Shift', 'Alt', 'AltGraph', 'Dead'].includes(k)) return '';
// Shift turns "," into "<" on some layouts and "\\" into "|": judge the physical key for punctuation
const key = k.length === 1 ? (/[a-z0-9]/i.test(k) ? k.toUpperCase() : e.code === 'Backslash' ? '\\' : e.code === 'Comma' ? ',' : k) : k;
return ['Mod', e.altKey && 'Alt', e.shiftKey && 'Shift', key].filter(Boolean).join('+');
}
export function actionFor(e, custom = {}) {
const c = eventCombo(e);
if (!c) return null;
const hit = ACTIONS.find((a) => comboOf(a.id, custom) === c);
return hit ? hit.id : null;
}
// "Mod+Shift+K" -> "Ctrl ⇧ K" / "⌘ ⇧ K"
export function format(combo, mac = false) {
return String(combo || '')
.split('+')
.map((p) => (p === 'Mod' ? (mac ? '⌘' : 'Ctrl') : p === 'Shift' ? '⇧' : p === 'Alt' ? (mac ? '⌥' : 'Alt') : p))
.join(' ');
}
// Whether a combo may be assigned: needs Mod and one non-modifier key.
export const valid = (combo) => {
const parts = String(combo || '').split('+');
return parts[0] === 'Mod' && parts.length >= 2 && !MODS.includes(parts[parts.length - 1]);
};
+880 -54
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
// Colours a tag can have (kept by name, so the file stays valid if the set ever changes).
export const TAG_COLORS = [
['red', '#e5534b'], ['orange', '#e8883a'], ['yellow', '#d9b130'], ['green', '#44ad6e'],
['teal', '#3aa6c4'], ['blue', '#5b8def'], ['purple', '#9a6de0'], ['pink', '#e06fa6']
];
const HEX = Object.fromEntries(TAG_COLORS);
export const tagHex = (key) => HEX[key] || null;
+28
View File
@@ -0,0 +1,28 @@
export const esc = (s) =>
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
export const unesc = (s) =>
s.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
// Plain text of an HTML fragment without a DOM (works in tests too).
export const htmlText = (h) => unesc(String(h || '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''));
// Highlight every case-insensitive occurrence of q in s, HTML-escaped.
export function mark(s, q) {
s = String(s || '');
if (!q) return esc(s);
const lo = s.toLowerCase();
let out = '', i = 0, j;
while ((j = lo.indexOf(q, i)) >= 0) {
out += esc(s.slice(i, j)) + '<mark>' + esc(s.slice(j, j + q.length)) + '</mark>';
i = j + q.length;
}
return out + esc(s.slice(i));
}
export const countWords = (t) => (String(t).trim().match(/\S+/g) || []).length;
+20
View File
@@ -0,0 +1,20 @@
export const toastState = $state({ msg: '', icon: 'check', show: false, action: null });
let timer;
// action: optional { label, run } shown as a button; the toast then stays up longer.
export function toast(msg, icon = 'check', action = null) {
toastState.msg = msg;
toastState.icon = icon;
toastState.action = action;
toastState.show = true;
clearTimeout(timer);
timer = setTimeout(() => (toastState.show = false), action ? 6000 : 2200);
}
export function runToastAction() {
const a = toastState.action;
toastState.action = null;
toastState.show = false;
clearTimeout(timer);
if (a) a.run();
}