diff --git a/src/lib/a11y.js b/src/lib/a11y.js new file mode 100644 index 0000000..d02ce9a --- /dev/null +++ b/src/lib/a11y.js @@ -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); }; +} diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..fe8aee5 --- /dev/null +++ b/src/lib/api.js @@ -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: /, ''); diff --git a/src/lib/canvas.js b/src/lib/canvas.js new file mode 100644 index 0000000..8fa85cb --- /dev/null +++ b/src/lib/canvas.js @@ -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 }; +} diff --git a/src/lib/canvasExport.js b/src/lib/canvasExport.js new file mode 100644 index 0000000..b2bedc5 --- /dev/null +++ b/src/lib/canvasExport.js @@ -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 = `` + + `` + + `${wrap.outerHTML}`; + + // 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 }; +} diff --git a/src/lib/csv.js b/src/lib/csv.js new file mode 100644 index 0000000..871fdfa --- /dev/null +++ b/src/lib/csv.js @@ -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 }; +} diff --git a/src/lib/daily.js b/src/lib/daily.js new file mode 100644 index 0000000..52ec25c --- /dev/null +++ b/src/lib/daily.js @@ -0,0 +1,47 @@ +// Where daily notes go: /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)); +} diff --git a/src/lib/database.js b/src/lib/database.js new file mode 100644 index 0000000..d3e2a56 --- /dev/null +++ b/src/lib/database.js @@ -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)); diff --git a/src/lib/databaseEmbed.js b/src/lib/databaseEmbed.js new file mode 100644 index 0000000..66a30f5 --- /dev/null +++ b/src/lib/databaseEmbed.js @@ -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 */ } + }; +} diff --git a/src/lib/editor.js b/src/lib/editor.js index 492ec34..bdeedf6 100644 --- a/src/lib/editor.js +++ b/src/lib/editor.js @@ -5,18 +5,20 @@ import { ic } from './icons.js'; import { esc, htmlText } from './text.js'; import { highlight } from './highlight.js'; -import { block, linkChip, webLink, isWebUrl, toMarkdown, footnoteRef, inlineNote } from './markdown.js'; +import { block, blockId, linkChip, webLink, isWebUrl, toMarkdown, footnoteRef, inlineNote, mdToHtml, htmlToMd, parseMarkdown } from './markdown.js'; +import { htmlToMarkdown } from './htmlDoc.js'; import { paintFootnotes } from './footnotes.js'; import { scrollToBlock } from './scroll.js'; import { staticHtml } from './embed.js'; -import { linkLabel, IMAGE_FILE, OTHER_FILE } from './links.js'; +import { linkLabel, IMAGE_FILE, OTHER_FILE, headingOf, refPage, linkHeading, matchesHeading } from './links.js'; import katex from 'katex'; import mermaid from 'mermaid'; mermaid.initialize({ startOnLoad: false, theme: 'dark' }); -let mermaidId = 0; +// clipboard HTML with structure worth keeping (a code editor's copy has none of these) +const RICH = /<(h[1-6]|ul|ol|li|table|blockquote|pre|a\s|strong|b>|b\s|em>|em\s|i>|img)/i; const reduceMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; const PH = { @@ -24,7 +26,7 @@ const PH = { bullet: 'List', number: 'List', footnote: 'Footnote text', quote: 'Quote', callout: 'Write something worth noticing', toggle: 'Toggle' }; -const KINDS = [ +export const KINDS = [ { t: 'p', label: 'Text', desc: 'Plain paragraph', g: 'Aa', k: 'text paragraph plain' }, { t: 'h1', label: 'Heading 1', desc: 'Big section heading', g: 'H1', hint: '#', k: 'title h1' }, { t: 'h2', label: 'Heading 2', desc: 'Medium section heading', g: 'H2', hint: '##', k: 'h2' }, @@ -38,6 +40,8 @@ const KINDS = [ { t: 'code', label: 'Code', desc: 'With syntax highlighting', g: '</>', hint: '```', k: 'code snippet' }, { t: 'divider', label: 'Divider', desc: 'Separate sections', g: '—', hint: '---', k: 'divider line hr rule' }, { t: 'table', label: 'Table', desc: 'Rows and columns', g: ic('table'), k: 'table grid rows columns spreadsheet' }, + { t: 'cols2', label: '2 columns', desc: 'Two blocks side by side', g: ic('columns'), k: 'columns column split side layout two 2' }, + { t: 'cols3', label: '3 columns', desc: 'Three blocks side by side', g: ic('columns'), k: 'columns column split side layout three 3' }, { t: 'math', label: 'Math block', desc: 'KaTeX equation block', g: '∑', hint: '$', k: 'math latex formula equation katex' }, { t: 'mermaid', label: 'Mermaid block', desc: 'Diagrams and charts', g: '⑂', hint: '```mermaid', k: 'mermaid diagram chart graph' }, @@ -59,7 +63,7 @@ const SHORTCUTS = [ ['### ', 'h3'], ['## ', 'h2'], ['# ', 'h1'], ['- ', 'bullet'], ['* ', 'bullet'], ['[] ', 'todo'], ['[ ] ', 'todo'], ['> ', 'quote'], ['```', 'code'] ]; -const LANGS = ['js', 'ts', 'py', 'sh', 'md', 'txt']; +const LANGS = ['txt', 'js', 'ts', 'py', 'sh', 'json', 'html', 'css', 'md', 'sql', 'yaml', 'rust', 'go', 'c', 'cpp', 'java']; /* caret helpers */ const sel = () => window.getSelection(); @@ -118,8 +122,10 @@ const imageExt = (type) => ({ 'image/jpeg': 'jpg', 'image/svg+xml': 'svg' })[typ // links: { pages(), resolve(ref), refFor(id), open(ref), create(title) -> id, current() -> id of the open page, // headings(id) -> [{ text, level }], embed(ref) -> what an ![[embed]] shows (see store.loadEmbed) } // images: { url(src), save(file, name) -> src, pick() -> src | null } -export function createEditor(host, { blocks: initial, animate = true, onChange = () => {}, onExitTop = () => {}, onCopy = () => {}, links = NO_LINKS, images = NO_IMAGES }) { +export function createEditor(host, { blocks: initial, animate = true, onChange = () => {}, onExitTop = () => {}, onCopy = () => {}, links = NO_LINKS, images = NO_IMAGES, recentKinds = () => [], rememberKind = () => {}, prefs = () => ({}) }) { const reduce = reduceMotion(); + // The kinds picked last come first when only / has been typed. + const recentRank = (k) => { const r = recentKinds().indexOf(k.t); return r < 0 ? 99 : r; }; let blocks = initial && initial.length ? initial : [block('p')]; let slash = null; // popup under the caret: mode 'blocks' (after /) or 'link' (after [[) let dragId = null; @@ -142,14 +148,14 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = const b = byId(c.closest('.block')?.dataset.id); if (!b) return null; if (c.innerHTML === '
') c.innerHTML = ''; - if (c.classList.contains('tcell')) { b.rows[+c.dataset.r][+c.dataset.c] = c.innerHTML; delete b.src; } - else if (c.classList.contains('c2')) b.body = c.innerHTML; - else b.html = c.innerHTML; + if (c.classList.contains('tcell')) { b.rows[+c.dataset.r][+c.dataset.c] = fieldHtml(c); delete b.src; } + else if (c.classList.contains('c2')) b.body = fieldHtml(c); + else b.html = fieldHtml(c); return b; } // kind 'type' = text typed into block `id`; anything else is a structural change. - const changed = (kind = 'op', id = null) => { record(kind, id); onChange(blocks); repaintFootnotes(); }; + const changed = (kind = 'op', id = null) => { layout(); record(kind, id); onChange(blocks); repaintFootnotes(); }; const byId = (id) => blocks.find((b) => b.id === id); const elOf = (id) => host.querySelector(`.block[data-id="${id}"]`); @@ -167,28 +173,10 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } case 'code': return `
${highlight(b.text || '')}
`; - case 'math': { - let preview = ''; - try { preview = katex.renderToString(b.text || '', { throwOnError: false, displayMode: true }); } catch (e) { preview = '
' + esc(e.message) + '
'; } - return `
${preview}
math
${highlight(b.text || '')}
`; - } - case 'mermaid': { - const id = 'mermaid-' + (++mermaidId); - // We will render mermaid in a delayed manner - setTimeout(async () => { - try { - if (!b.text?.trim()) return; - const el = document.getElementById(id); - if (!el) return; - const { svg } = await mermaid.render(id + '-svg', b.text); - el.innerHTML = svg; - } catch (e) { - const el = document.getElementById(id); - if (el) el.innerHTML = '
' + esc(e.message) + '
'; - } - }, 10); - return `
mermaid
${highlight(b.text || '')}
`; - } + case 'math': + return `
Math · LaTeX
${esc(b.text || '')}
`; + case 'mermaid': + return `
Mermaid diagram
${esc(b.text || '')}
`; case 'divider': return '
'; case 'image': return `
${esc(b.alt || '')}${ic('image')}Image not found: ${esc(b.src)}
`; @@ -219,8 +207,59 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = w.addEventListener('animationend', () => w.classList.remove('stagger', 'enter-b', 'morph')); paintLinks(w); if (b.type === 'embed') fillEmbed(w, b); + if (b.type === 'math' || b.type === 'mermaid') updatePreview(w, b); return w; } + /* math and mermaid blocks: the rendered result on top, its source underneath (plain text, not code colouring) */ + const previewSeq = new WeakMap(); // preview box -> number of the newest request, so a slow render can't overwrite a newer one + const previewTimers = new WeakMap(); + let renderId = 0; + const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + + // Redraw the preview of block `b` in element `w`. `delay`: wait for a pause in typing first. + function updatePreview(w, b, delay = 0) { + const box = w && w.querySelector('.math-preview, .mermaid-preview'); + if (!box) return; + clearTimeout(previewTimers.get(box)); + const run = () => (b.type === 'math' ? renderMath(box, b.text) : renderMermaid(box, b.text)); + delay ? previewTimers.set(box, setTimeout(run, delay)) : run(); + } + function renderMath(box, text) { + if (!String(text || '').trim()) { box.innerHTML = 'The formula appears here as you type it below.'; return; } + try { + box.innerHTML = katex.renderToString(text, { throwOnError: false, displayMode: true, errorColor: cssVar('--danger') || '#ea9188' }); + } catch (e) { + box.innerHTML = `
${esc(e.message)}
`; + } + } + async function renderMermaid(box, text) { + const mine = ++renderId; + previewSeq.set(box, mine); + if (!String(text || '').trim()) { box.innerHTML = 'The diagram appears here as you type it below.'; return; } + const id = 'mermaid-' + mine; + try { + // Follow the app's theme each time (it can change between renders). + mermaid.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' }); + await mermaid.parse(text); + const { svg } = await mermaid.render(id, text); + if (previewSeq.get(box) === mine) box.innerHTML = svg; + } catch (e) { + if (previewSeq.get(box) === mine) box.innerHTML = `
${esc(String(e.message || e).split('\n').slice(0, 4).join('\n'))}
`; + } finally { + document.getElementById('d' + id)?.remove(); // mermaid leaves its measuring element behind when a diagram is invalid + } + } + + // Live database embeds mounted in this page: [{ el, stop }]. Their views are removed with the block (or the page). + const liveEmbeds = []; + const pruneEmbeds = () => { + for (let i = liveEmbeds.length - 1; i >= 0; i--) { + if (liveEmbeds[i].el.isConnected) continue; + liveEmbeds[i].stop(); + liveEmbeds.splice(i, 1); + } + }; + // Fetch what an embed points at and show it (read-only) under its title. async function fillEmbed(w, b) { const box = w.querySelector('.emb'), body = w.querySelector('.emb-body'), more = w.querySelector('.emb-more'); @@ -228,7 +267,32 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = let r; try { r = await links.embed(b.ref); } catch { r = { state: 'error' }; } const note = (html) => { body.innerHTML = `

${html}

`; more.hidden = true; }; - if (r.state === 'ok') { + if (r.state === 'database') { + // A database is shown live: its views, filtered and sorted as this embed says (kept in the |alias). + box.classList.add('live'); + more.hidden = true; + title.textContent = r.title; + body.innerHTML = ''; + pruneEmbeds(); + const stop = links.mountDatabase && links.mountDatabase(body, { + id: r.id, + body: r.body, + // ![[Tasks#Board|…]]: the #part names the view, unless the alias says which itself + alias: (() => { + const own = b.alias && /=/.test(b.alias) ? b.alias : ''; + if (!r.view || /(^|;)\s*view=/.test(own)) return own; + return `view=${encodeURIComponent(r.view).replace(/%20/g, ' ')}` + (own ? '; ' + own : ''); + })(), + setAlias: (a) => { + const cur = byId(b.id); + if (!cur || cur.alias === a) return; + cur.alias = a; + changed(); + } + }); + if (stop) liveEmbeds.push({ el: body, stop }); + else note('Open the desktop app to see this database.'); + } else if (r.state === 'ok') { title.textContent = r.heading ? `${r.title} › ${r.heading}` : r.title; body.innerHTML = staticHtml(r.blocks, { imageUrl: r.imageUrl }); paintLinks(body); @@ -246,6 +310,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } else if (r.state === 'missing') { box.classList.add('missing'); note(`There's no note called “${esc(r.name)}”.`); } else if (r.state === 'self') note('A note can’t be embedded in itself.'); else if (r.state === 'board') note('This is a board, so there’s no text to show. Open it with the title above.'); + else if (r.state === 'canvas') note('This is a canvas. Open it with the title above.'); else if (r.state === 'noheading') note(`“${esc(r.title)}” has no heading called “${esc(r.heading)}”.`); else note('Couldn’t load this note.'); } @@ -259,6 +324,148 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = host.append(el); }); + /* columns + Blocks stay one flat list; a block in a column carries col: { g: group, i: column }. Consecutive blocks of a + group are drawn inside
…
. layout() puts the elements where + the list says (and only moves what is out of place, so a caret in a block is not disturbed). */ + const MAX_COLS = 3; + const colsBar = () => `
`; + // Keep the list tidy: a group's blocks together and in column order, columns numbered 0..n-1, a group left with + // a single column turned back into ordinary blocks, widths kept only while they still fit the columns. + function normalizeCols() { + if (!blocks.some((b) => b.col)) return; + const out = []; + const done = new Set(); + const placed = new Set(); // (a dissolved group's blocks lose `col` mid-loop: never place one twice) + for (const b of blocks) { + if (placed.has(b)) continue; + if (!b.col) { out.push(b); placed.add(b); continue; } + const g = b.col.g; + if (done.has(g)) continue; + done.add(g); + const group = blocks.filter((x) => x.col && x.col.g === g); + const idxs = [...new Set(group.map((x) => x.col.i))].sort((x, y) => x - y); + group.sort((x, y) => idxs.indexOf(x.col.i) - idxs.indexOf(y.col.i)); // stable: order within a column kept + for (const x of group) placed.add(x); + if (idxs.length < 2) { for (const x of group) delete x.col; out.push(...group); continue; } + const widths = idxs.map((i) => group.find((x) => x.col.i === i).col.w); + const keepW = widths.every((w) => w > 0) && Math.abs(widths.reduce((t, w) => t + w, 0) - 100) < 1; + for (const x of group) { + const k = idxs.indexOf(x.col.i); + x.col = keepW ? { g, i: k, w: widths[k] } : { g, i: k }; + } + out.push(...group); + } + blocks.splice(0, blocks.length, ...out); + } + function layout() { + const anyCols = blocks.some((b) => b.col) || host.querySelector('.cols'); + if (!anyCols) return; + normalizeCols(); + // a group never ends the page: there is always a line after it to click or type in + const last = blocks[blocks.length - 1]; + if (last && last.col) { const p = block('p'); blocks.push(p); } + // what should be at the top level, in order + const wanted = []; + const groups = new Map(); // g -> Map(i -> [elements]) + for (const b of blocks) { + const el = elOf(b.id) || (() => { const e = make(b); host.append(e); return e; })(); + if (b.col) { + let gm = groups.get(b.col.g); + if (!gm) { gm = new Map(); groups.set(b.col.g, gm); wanted.push({ g: b.col.g }); } + if (!gm.has(b.col.i)) gm.set(b.col.i, []); + gm.get(b.col.i).push(el); + } else wanted.push({ el }); + } + const place = (parent, node, at) => { if (parent.children[at] !== node) parent.insertBefore(node, parent.children[at] || null); }; + const top = []; + for (const w of wanted) { + if (w.el) { top.push(w.el); continue; } + let wrap = host.querySelector(`.cols[data-g="${w.g}"]`); + if (!wrap) { wrap = document.createElement('div'); wrap.className = 'cols'; wrap.dataset.g = w.g; wrap.innerHTML = colsBar(); } + const gm = groups.get(w.g); + const idxs = [...gm.keys()].sort((a, b) => a - b); + wrap.style.setProperty('--n', idxs.length); + wrap.classList.toggle('full', idxs.length >= MAX_COLS); + const widths = idxs.map((i) => byId(gm.get(i)[0].dataset.id)?.col?.w); + wrap.style.gridTemplateColumns = widths.every((x) => x > 0) ? widths.map((x) => `minmax(0,${x}fr)`).join(' ') : ''; + let at = 1; // (the first child is the little toolbar) + for (const i of idxs) { + let col = wrap.querySelector(`:scope > .col[data-i="${i}"]`); + if (!col) { + col = document.createElement('div'); + col.className = 'col'; + col.dataset.i = i; + // between this column and the one before it: a handle to change their widths + col.innerHTML = '
'; + } + place(wrap, col, at++); + gm.get(i).forEach((el, k) => place(col, el, k + 1)); + } + for (const col of [...wrap.querySelectorAll(':scope > .col')]) if (!idxs.includes(+col.dataset.i)) col.remove(); + top.push(wrap); + } + // top level: after the drop marker, in order + top.forEach((node, k) => place(host, node, k + 1)); + for (const w of host.querySelectorAll(':scope > .cols')) if (!w.querySelector('.block')) w.remove(); + // nothing but blocks (and the resize handle) in a column + for (const col of host.querySelectorAll('.cols > .col')) for (const x of [...col.children]) if (!x.classList.contains('col-rs') && !x.classList.contains('block')) x.remove(); + } + // Dragging a handle between two columns changes how wide they are (double-click: equal widths again). + let resizing = null; + function onResizeDown(e) { + const h = e.button === 0 && e.target.closest('.col-rs'); + if (!h) return; + e.preventDefault(); + e.stopPropagation(); + const col = h.parentNode, wrap = col.parentNode, g = wrap.dataset.g; + const cols = [...wrap.querySelectorAll(':scope > .col')]; + const k = cols.indexOf(col); + if (k < 1) return; + const px = cols.map((c) => c.getBoundingClientRect().width); + resizing = { g, k, px, x: e.clientX, wrap, total: px.reduce((t, w) => t + w, 0) }; + document.body.classList.add('col-resizing'); + document.addEventListener('pointermove', onResizeMove); + document.addEventListener('pointerup', onResizeUp); + } + function resizeWidths(dx) { + const { px, k, total } = resizing; + const min = total * 0.12; + const pair = px[k - 1] + px[k]; + const left = Math.max(min, Math.min(pair - min, px[k - 1] + dx)); + const w = px.slice(); + w[k - 1] = left; + w[k] = pair - left; + return w.map((x) => (x / total) * 100); + } + function onResizeMove(e) { + const pct = resizeWidths(e.clientX - resizing.x); + resizing.pct = pct; + resizing.wrap.style.gridTemplateColumns = pct.map((x) => `minmax(0,${x}fr)`).join(' '); + } + function onResizeUp() { + document.removeEventListener('pointermove', onResizeMove); + document.removeEventListener('pointerup', onResizeUp); + document.body.classList.remove('col-resizing'); + const r = resizing; + resizing = null; + if (!r || !r.pct) return; + const pct = r.pct.map((x) => Math.round(x * 10) / 10); + pct[pct.length - 1] = Math.round((100 - pct.slice(0, -1).reduce((t, x) => t + x, 0)) * 10) / 10; + for (const b of blocks) if (b.col && b.col.g === r.g) b.col = { ...b.col, w: pct[b.col.i] }; + changed(); + } + function onResizeDouble(e) { + const h = e.target.closest('.col-rs'); + if (!h) return; + const g = h.closest('.cols').dataset.g; + for (const b of blocks) if (b.col && b.col.g === g) b.col = { g, i: b.col.i }; + changed(); + } + layout(); + // A new block next to a column block joins that column. + const joinColumn = (b, ref) => { if (ref && ref.col && !b.col) b.col = { g: ref.col.g, i: ref.col.i }; }; + /* undo / redo Whole-page snapshots. Typing in one block within a short pause is one step; every structural change (new, removed, moved or converted block) is its own step, however many changed() calls it makes. */ @@ -296,8 +503,9 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = blocks = JSON.parse(s.json); saved = s; last = { kind: null, id: null, at: 0 }; - host.querySelectorAll('.block').forEach((x) => x.remove()); + host.querySelectorAll('.block, .cols').forEach((x) => x.remove()); for (const b of blocks) host.append(make(b)); + layout(); const el = s.caret && elOf(s.caret.id); const c = el && el.querySelector(s.caret.cell ? `.tcell[data-r="${s.caret.cell[0]}"][data-c="${s.caret.cell[1]}"]` : s.caret.c2 ? '.c2' : '.c'); if (c) { c.focus(); setCaret(c, s.caret.off); } @@ -307,7 +515,15 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = function undo() { if (!past.length) return; future.push(snap()); - restore(past.pop()); + let s = past.pop(); + const isEmptyDoc = (state) => { + try { + const bs = JSON.parse(state.json); + return bs.length === 1 && bs[0].type === "p" && !bs[0].html; + } catch { return false; } + }; + if (isEmptyDoc(s) && past.length) s = past.pop(); + restore(s); } function redo() { if (!future.length) return; @@ -319,12 +535,14 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = const k = e.key.toLowerCase(); if (k !== 'z' && k !== 'y') return; const t = e.target; + if (t.closest?.('.emb.live')) return; // a database shown in the page: its fields keep their own undo // Our editor, or nothing focused (e.g. right after dragging a block). Inputs elsewhere keep their own undo. if (!host.contains(t) && t !== document.body && t !== document.documentElement) return; e.preventDefault(); k === 'y' || e.shiftKey ? redo() : undo(); } function onBeforeInput(e) { + if (e.target.closest?.('.emb.live')) return; // fields of a live database keep their own undo if (e.inputType === 'historyUndo') { e.preventDefault(); undo(); } if (e.inputType === 'historyRedo') { e.preventDefault(); redo(); } } @@ -335,11 +553,13 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = const b = byId(id); if (b && SOLID.has(b.type)) return; const cells = el ? el.querySelectorAll('.tcell') : []; - const c = el && (el.querySelector('.c') || (where === 'start' ? cells[0] : cells[cells.length - 1])); + const inBody = where === 'end' && b?.type === 'toggle' && b.open; + const c = el && ((inBody && el.querySelector('.c2')) || el.querySelector('.c') || (where === 'start' ? cells[0] : cells[cells.length - 1])); if (c) (where === 'start' ? placeStart : placeEnd)(c); } function insertAfter(refId, b, focus = 'start') { const i = blocks.findIndex((x) => x.id === refId); + joinColumn(b, blocks[i]); blocks.splice(i + 1, 0, b); const el = make(b); el.classList.add('enter-b'); @@ -372,12 +592,53 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (type !== 'divider') focusBlock(b.id, where); changed(); } + // Make `n` columns here: this (empty) block becomes the first column's, or, if it has text, they start below it. + function addColumns(b, empty, n) { + const g = blockId(); + const first = empty && b.type === 'p' && !b.col ? b : block('p'); + first.col = { g, i: 0 }; + if (first !== b) insertAfter(b.id, first, false); + let after = first; + for (let i = 1; i < n; i++) { + const nb = block('p'); + nb.col = { g, i }; + const at = blocks.indexOf(after); + blocks.splice(at + 1, 0, nb); + after = nb; + } + changed(); + focusBlock(first.id, 'start'); + } + // The toolbar on a group of columns: one more column, or back to ordinary blocks. + function onColsClick(e) { + const btn = e.target.closest('.cols-add, .cols-undo'); + if (!btn) return; + const wrap = btn.closest('.cols'); + const g = wrap && wrap.dataset.g; + if (!g) return; + e.preventDefault(); + const group = blocks.filter((b) => b.col && b.col.g === g); + if (!group.length) return; + if (btn.classList.contains('cols-undo')) { + for (const b of group) delete b.col; + changed(); + return; + } + const i = Math.max(...group.map((b) => b.col.i)) + 1; + if (i >= MAX_COLS) return; + const nb = block('p'); + nb.col = { g, i }; + blocks.splice(blocks.indexOf(group[group.length - 1]) + 1, 0, nb); + changed(); + focusBlock(nb.id, 'start'); + } function addDivider(b, empty) { if (empty) { convert(b, 'divider'); insertAfter(b.id, block('p'), 'start'); } else { const d = block('divider'); insertAfter(b.id, d, false); insertAfter(d.id, block('p'), 'start'); } } // Put block `b` at position `index`. function insertAt(index, b) { + joinColumn(b, blocks[index - 1]); blocks.splice(index, 0, b); const el = make(b); el.classList.add('enter-b'); @@ -388,7 +649,9 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } function move(b, dir, where) { let i = blocks.indexOf(b) + dir; - while (blocks[i] && SOLID.has(blocks[i].type)) i += dir; + // up from the top of a column / down from its bottom: out of the group, not into the column beside it + const beside = (x) => b.col && x && x.col && x.col.g === b.col.g && x.col.i !== b.col.i; + while (blocks[i] && (SOLID.has(blocks[i].type) || beside(blocks[i]))) i += dir; if (blocks[i]) focusBlock(blocks[i].id, where); else if (dir < 0) onExitTop(); } @@ -407,7 +670,8 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = return -1; }; - const items = KINDS.map((k, i) => ({ k, i, s: score(k) })).filter((x) => x.s >= 0).sort((a, z) => a.s - z.s || a.i - z.i).map((x) => x.k); + const hidden = prefs().hiddenKinds || []; + const items = KINDS.map((k, i) => ({ k, i, s: hidden.includes(k.t) ? -1 : score(k) })).filter((x) => x.s >= 0).sort((a, z) => a.s - z.s || (ql ? 0 : recentRank(a.k) - recentRank(z.k)) || a.i - z.i).map((x) => x.k); if (!items.length) return closeSlash(); // Keep the highlighted row only while the query is unchanged; a new query starts at its best match. const idx = slash && slash.mode === 'blocks' && slash.id === b.id && slash.q === q ? Math.min(slash.idx, items.length - 1) : 0; @@ -507,13 +771,14 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = function applySlash(i) { if (slash.mode === 'link') return applyLink(i); const k = slash.items[i], c = slash.c, q = slash.q, end = slash.end, b = byId(slash.id); + if (k && k.t) rememberKind(k.t); closeSlash(); if (!b) return; const from = Math.max(0, end - q.length - 1); const r = rangeAt(c, from, end); if (r) r.deleteContents(); if (c.innerHTML === '
') c.innerHTML = ''; - b.html = c.innerHTML; + b.html = fieldHtml(c); const empty = !c.textContent.trim(); if (k.t === 'link') { setCaret(c, from); @@ -531,6 +796,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = images.pick().then((src) => src && addImage(b, src), () => {}); return; } + if (k.t === 'cols2' || k.t === 'cols3') return addColumns(b, empty, k.t === 'cols3' ? 3 : 2); if (k.t === 'divider') return addDivider(b, empty); if (k.t === 'table') return addTable(b, empty); if (empty) { if (k.t === 'code') b.text = ''; convert(b, k.t, 'start'); } @@ -571,14 +837,16 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = closeSlash(); const from = Math.max(0, end - q.length - (embed ? 3 : 2)); // back over "![[" or "[[" let ref; - if (it.kind === 'heading') ref = it.pageRef + '#' + it.title; + if (it.kind === 'heading') ref = it.pageRef + '#' + linkHeading(it.title); else { const id = it.kind === 'new' ? await links.create(it.title) : it.id; if (!id) return; ref = links.refFor(id); } if (!c.isConnected) return; - embed ? placeEmbed(c, from, end, ref) : putChip(c, from, end, ref); + // a "]]" typed-over pair that is still waiting after the caret goes with the link + const tail = c.textContent.slice(end, end + 2) === ']]' ? 2 : 0; + embed ? placeEmbed(c, from, end + tail, ref) : putChip(c, from, end + tail, ref); } // After a link chip or footnote reference, putInline leaves a no-break space so the caret has somewhere to go. @@ -698,7 +966,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = const r = rangeAt(c, from, end); if (r) r.deleteContents(); if (c.innerHTML === '
') c.innerHTML = ''; - b.html = c.innerHTML; + b.html = fieldHtml(c); addEmbed(b, c, ref); } function addEmbed(b, c, ref, alias = '') { @@ -788,7 +1056,10 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = bar.style.left = Math.max(8, Math.min(rect.left + rect.width / 2 - w / 2, innerWidth - w - 8)) + 'px'; bar.style.top = y + 'px'; } - const onSelection = () => { if (!barFrame) barFrame = requestAnimationFrame(showBar); }; + const onSelection = () => { + if (document.body.style.userSelect && sel().isCollapsed) document.body.style.userSelect = ''; + if (!barFrame) barFrame = requestAnimationFrame(showBar); + }; // Run a formatting change as its own undo step, apart from the typing around it. function asStep(c, fn) { @@ -915,8 +1186,30 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = menuEl.style.top = y + 'px'; (menuEl.querySelector('.mi.cur') || menuEl.querySelector('.mi')).focus(); } + function openLangMenu(b, anchor) { + closeSlash(); + bar.hidden = true; + const cur = b.lang || 'txt'; + const langs = LANGS.includes(cur) ? LANGS : [cur, ...LANGS]; + menuEl.innerHTML = + `
Language
` + + langs.map((l) => ``).join(''); + menuEl.style.maxHeight = Math.min(320, innerHeight - 16) + 'px'; + menuEl.style.overflowY = 'auto'; + menu = { id: b.id }; + elOf(b.id)?.classList.add('menu-open'); + menuEl.hidden = false; + const w = menuEl.offsetWidth, h = menuEl.offsetHeight; + let y = anchor.bottom + 6; + if (y + h > innerHeight - 8) y = Math.max(8, anchor.top - h - 6); + menuEl.style.left = Math.max(8, Math.min(anchor.left, innerWidth - w - 8)) + 'px'; + menuEl.style.top = y + 'px'; + (menuEl.querySelector('.mi.cur') || menuEl.querySelector('.mi')).focus(); + menuEl.querySelector('.mi.cur')?.scrollIntoView({ block: 'nearest' }); + } function closeMenu() { if (!menu) return; + menuEl.style.maxHeight = menuEl.style.overflowY = ''; elOf(menu.id)?.classList.remove('menu-open'); menu = null; menuEl.hidden = true; @@ -938,6 +1231,16 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (!b) return; const act = btn.dataset.act, t = btn.dataset.t, cell = menu.cell; closeMenu(); + if (act === 'lang') { + if (t !== b.lang) { + b.lang = t; + const el = elOf(b.id); + const lb = el?.querySelector('.lang'); + if (lb) lb.textContent = t; + changed(); + } + return focusBlock(b.id, 'end'); + } if (cell) return tableAct(b, act, ...cell); if (act === 'turn' && t !== b.type) { if (t === 'code') b.text = b.text ?? htmlText(b.html); @@ -1277,12 +1580,379 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } const imageFiles = (dt) => [...((dt && dt.files) || [])].filter((f) => f.type.startsWith('image/')); + /* typing pairs + ( [ { and ` close themselves; ** and ~~ (typed as a second star or tilde) become **** and ~~~~ with the caret + in the middle; typing a closer where one already stands moves over it; Backspace in an empty pair takes both; + typing an opener over a selection wraps it. In code blocks, brackets and quotes pair (no Markdown marks). */ + const PAIR_OPEN = { '(': ')', '[': ']', '{': '}', '`': '`' }; + const CODE_OPEN = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" }; + const PAIR_CLOSE = new Set([')', ']', '}', '`', '*', '~']); + const CODE_CLOSE = new Set([')', ']', '}', '"', "'"]); + const isWord = (ch) => !!ch && /[\p{L}\p{N}_]/u.test(ch); + function autoPair(e, c, code) { + if (prefs().autoPair === false) return false; + if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing || e.key.length !== 1) return false; + const s = sel(); + if (!s.rangeCount || !c.contains(s.focusNode)) return false; + const k = e.key; + const opens = code ? CODE_OPEN : PAIR_OPEN; + const r = s.getRangeAt(0); + // a selection (inside this one field) is wrapped + if (!r.collapsed) { + if (!(k in opens) || !c.contains(r.startContainer) || !c.contains(r.endContainer)) return false; + e.preventDefault(); + const txt = r.toString(); + r.deleteContents(); + const node = document.createTextNode(k + txt + opens[k]); + r.insertNode(node); + const inner = document.createRange(); + inner.setStart(node, 1); + inner.setEnd(node, 1 + txt.length); + s.removeAllRanges(); + s.addRange(inner); + onInput(c); + return true; + } + const before = textBefore(c); + const after = c.textContent.slice(before.length); + const prev = before.slice(-1), next = after.charAt(0); + const put = (text, caretAt) => { + e.preventDefault(); + const node = document.createTextNode(text); + r.insertNode(node); + const at = document.createRange(); + at.setStart(node, caretAt); + at.collapse(true); + s.removeAllRanges(); + s.addRange(at); + onInput(c); + return true; + }; + // typing a closer where the same one already is: step over it + if ((code ? CODE_CLOSE : PAIR_CLOSE).has(k) && next === k) { + e.preventDefault(); + setCaret(c, before.length + 1); + if (!code && /^[*~`]$/.test(k)) markTyped(c); + return true; + } + const calm = next === '' || /\s/.test(next) || (code ? CODE_CLOSE : PAIR_CLOSE).has(next); // nothing word-like right after + if (k in opens) { + if (k === '`' && prev === '`') return false; // ``` is a code block shortcut: no pairing for the third + if ((k === '"' || k === "'" || k === '`') && isWord(prev)) return false; // it's an apostrophe / the end of a word + if (!calm) return false; + return put(k + opens[k], 1); + } + // ** and ~~: the second one brings the other two + if (!code && (k === '*' || k === '~') && prev === k && before.slice(-2, -1) !== k && calm && !isWord(before.slice(-2, -1))) { + return put(k + k + k, 1); + } + return false; + } + /* smart typography (a setting): "quotes" curl, -- becomes an em dash, ... an ellipsis. Never in code, and + never when the line starts with -- (--- is the divider shortcut). */ + function smartType(e, c) { + if (!prefs().smartTypography || e.ctrlKey || e.metaKey || e.altKey || e.isComposing || e.key.length !== 1) return false; + const s = sel(); + if (!s.rangeCount || !s.isCollapsed || !c.contains(s.focusNode)) return false; + const at = s.focusNode.nodeType === 1 ? s.focusNode : s.focusNode.parentElement; + if (at && at.closest('code, .md-src')) return false; + const before = textBefore(c), prev = before.slice(-1), k = e.key; + let out = null, drop = 0; + if (k === '"' || k === "'") { + const opening = !prev || /[\s(\[{\u2014\u2013]/.test(prev); + out = k === '"' ? (opening ? '\u201c' : '\u201d') : (opening ? '\u2018' : '\u2019'); + } else if (k === '-' && prev === '-' && before.length >= 2 && before.slice(-2, -1) !== '-') { + out = '\u2014'; drop = 1; + } else if (k === '.' && before.endsWith('..') && before.slice(-3, -2) !== '.') { + out = '\u2026'; drop = 2; + } + if (out === null) return false; + e.preventDefault(); + for (let i = 0; i < drop; i++) document.execCommand('delete'); + document.execCommand('insertText', false, out); + return true; + } + + // Backspace inside an empty pair: ( ) [ ] { } ` ` ** ** ~~ ~~ go together + function unpair(e, c, code) { + if (e.key !== 'Backspace' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return false; + const s = sel(); + if (!s.rangeCount || !s.isCollapsed || !c.contains(s.focusNode)) return false; + const before = textBefore(c); + if (!before) return false; + const after = c.textContent.slice(before.length); + const opens = code ? CODE_OPEN : PAIR_OPEN; + let n = 0; + if (opens[before.slice(-1)] === after.charAt(0)) n = 1; + if (!code && /^(\*\*|~~)$/.test(before.slice(-2)) && after.slice(0, 2) === before.slice(-2)) n = 2; + if (!n) return false; + const r = rangeAt(c, before.length - n, before.length + n); + if (!r) return false; + e.preventDefault(); + r.deleteContents(); + if (c.innerHTML === '
') c.innerHTML = ''; + onInput(c); + return true; + } + + /* live preview of inline formatting (as in Obsidian) + With the caret in (or right next to) bold, italic, strike, highlight or code, that piece shows as its Markdown + source: real characters, the marks dimmed, the text still styled. Marks can be deleted or stepped past with the + arrow keys; once the caret leaves, the source turns back into formatting (whatever it now says). Typing a + complete **text** (or the inside of an auto-closed pair) makes such a piece too. While shown, it is a + ; what is saved is always the formatting (see fieldHtml). */ + const FMT_TAGS = 'b,strong,i,em,s,strike,del,mark,code'; + // whole-source patterns, first match wins: [regex, tag, length of each mark] + const SRC_RULES = [ + [/^\*\*\*(?=\S)[\s\S]*?\S\*\*\*$/, 'bi', 3], + [/^\*\*(?=\S)[\s\S]*?\S\*\*$/, 'b', 2], + [/^__(?=\S)[\s\S]*?\S__$/, 'b', 2], + [/^~~(?=\S)[\s\S]*?\S~~$/, 's', 2], + [/^==(?=\S)[\s\S]*?\S==$/, 'mark', 2], + [/^`[^`]+`$/, 'code', 1], + [/^\*(?=[^\s*])[\s\S]*?[^\s*]\*$/, 'i', 1], + [/^\*[^\s*]\*$/, 'i', 1], + [/^_(?=\S)[\s\S]*?\S_$/, 'i', 1] + ]; + const srcFormat = (src) => { + for (const [re, tag, n] of SRC_RULES) if (src.length > n * 2 && re.test(src)) return { tag, n }; + return null; + }; + let reveal = null; // { c, span, orig, src0 }: the piece shown as source (orig: the element it was; src0: its source then) + + // The source's parts: dimmed marks around the styled text. The caret goes back to `off` characters into it. + function drawSrc(span, src, off) { + const f = srcFormat(src); + span.dataset.fmt = f ? f.tag : ''; + span.textContent = ''; + if (f) { + const mk = (t) => { const m = document.createElement('span'); m.className = 'md-mk'; m.textContent = t; return m; }; + span.append(mk(src.slice(0, f.n)), document.createTextNode(src.slice(f.n, src.length - f.n)), mk(src.slice(src.length - f.n))); + } else span.append(document.createTextNode(src)); + if (off != null) setCaret(span, Math.max(0, Math.min(off, src.length))); + } + // How many characters of the rendered text come before source position `off` (the marks vanish). + function srcToText(src, text, off) { + let j = 0; + for (let i = 0; i < off && i < src.length; i++) if (src[i] === text[j]) j++; + return j; + } + // The reverse: `off` characters into the rendered text, as a position in the source (past the opening marks). + function textToSrc(src, text, off) { + let i = 0, j = 0; + while (i < src.length && text.length && src[i] !== text[0]) i++; + while (j < off && i < src.length) { if (src[i] === text[j]) j++; i++; } + return i; + } + const htmlOf = (src) => { const h = document.createElement('span'); h.innerHTML = mdToHtml(src); return h; }; + // What a field holds, with a piece shown as source turned back into formatting: this is what gets saved. + function fieldHtml(c) { + if (!c.querySelector('.md-src')) return c.innerHTML; + const copy = c.cloneNode(true); + for (const sp of copy.querySelectorAll('.md-src')) { + const src = sp.textContent; + if (reveal && reveal.orig && reveal.src0 === src) sp.replaceWith(reveal.orig.cloneNode(true)); + else sp.replaceWith(...htmlOf(src).childNodes); + } + return copy.innerHTML; + } + // Where the caret is in the shown piece: { off } characters into the source (its edges count), or null. + function caretInReveal(rv = reveal) { + if (!rv || !rv.span.isConnected) return null; + const s = sel(); + if (!s.rangeCount || !s.isCollapsed) return null; + const span = rv.span, n = s.focusNode; + if (span.contains(n)) { + const r = document.createRange(); + r.selectNodeContents(span); + r.setEnd(n, s.focusOffset); + return { off: r.toString().length }; + } + if (n === span.parentNode) { + const i = [...n.childNodes].indexOf(span); + if (s.focusOffset === i) return { off: 0 }; + if (s.focusOffset === i + 1) return { off: span.textContent.length }; + } + return null; + } + // Turn the shown piece back into formatting; the caret keeps its place in the text. + function collapseReveal() { + const rv = reveal; + reveal = null; + if (!rv || !rv.span.isConnected) return; + const { c, span } = rv; + const src = span.textContent; + const at = caretInReveal(rv); + const s = sel(); + let before = 0; + if (at) { const r = document.createRange(); r.selectNodeContents(c); r.setEndBefore(span); before = r.toString().length; } + const parent = span.parentNode, idx = [...parent.childNodes].indexOf(span); + const parentCaret = !at && s.rangeCount && s.focusNode === parent ? s.focusOffset : null; + const nodes = rv.orig && rv.src0 === src ? [rv.orig] : [...htmlOf(src).childNodes]; + span.replaceWith(...nodes); + if (c.innerHTML === '
') c.innerHTML = ''; + if (at) setCaret(c, before + srcToText(src, nodes.map((n) => n.textContent).join(''), at.off)); + else if (parentCaret !== null && parentCaret > idx) { + const r = document.createRange(); + r.setStart(parent, parentCaret + nodes.length - 1); + r.collapse(true); + s.removeAllRanges(); + s.addRange(r); + } + } + // Show formatting element `el` as source, with the caret `textOff` characters into its text, or before / after it. + function revealEl(c, el, where, textOff = 0) { + if (el.querySelector('a, .wl, sup, img, [contenteditable="false"]') || el.closest('a')) return; + const src = htmlToMd(el.outerHTML); + if (!src || src === el.textContent || !srcFormat(src)) return; + const off = where === 'before' ? 0 : where === 'after' ? src.length : textToSrc(src, el.textContent, textOff); + const span = document.createElement('span'); + span.className = 'md-src'; + el.replaceWith(span); + drawSrc(span, src, off); + reveal = { c, span, orig: el, src0: src }; + } + // The formatting at (or right next to) the caret, if any: { el, where: 'in' | 'before' | 'after', off }. + function fmtAtCaret(c) { + const s = sel(); + if (!s.rangeCount || !s.isCollapsed || !c.contains(s.focusNode)) return null; + const n = s.focusNode, o = s.focusOffset; + let el = null; // the outermost formatting around the caret + for (let x = n.nodeType === 3 ? n.parentNode : n; x && x !== c; x = x.parentNode) if (x.matches && x.matches(FMT_TAGS)) el = x; + if (el) { + const r = document.createRange(); + r.selectNodeContents(el); + r.setEnd(n, o); + return { el, where: 'in', off: r.toString().length }; + } + const isFmt = (x) => !!x && x.nodeType === 1 && x.matches(FMT_TAGS); + if (n.nodeType === 3) { + if (o === 0 && isFmt(n.previousSibling)) return { el: n.previousSibling, where: 'after' }; + if (o === n.length && isFmt(n.nextSibling)) return { el: n.nextSibling, where: 'before' }; + return null; + } + if (isFmt(n.childNodes[o - 1])) return { el: n.childNodes[o - 1], where: 'after' }; + if (isFmt(n.childNodes[o])) return { el: n.childNodes[o], where: 'before' }; + return null; + } + // On every caret move: the piece stays shown while the caret is in it; otherwise it goes back and the formatting + // the caret has reached (if any) is shown instead. + function checkReveal() { + if (reveal && !reveal.span.isConnected) reveal = null; + const s = sel(); + if (!s.rangeCount || !s.isCollapsed || !host.contains(s.focusNode)) { + if (reveal && !host.contains(document.activeElement)) { const c = reveal.c; collapseReveal(); sync(c); } + return; + } + if (reveal && caretInReveal()) return; + const was = reveal && reveal.c; + if (reveal) collapseReveal(); + const node = s.focusNode.nodeType === 1 ? s.focusNode : s.focusNode.parentNode; + const c = node && node.closest('.c,.c2'); + if (c && !c.classList.contains('code-c') && !c.closest('.emb')) { + const f = fmtAtCaret(c); + if (f) revealEl(c, f.el, f.where, f.off); + } + if (was) sync(was); + } + // A typed **text** (its closing marks typed, or text typed inside an auto-closed pair) becomes a shown piece. + const MARK_EDGE = '(^|[\\s(\\[{>"\'])'; + const TYPED = [['**', '\\*\\*', '[^\\s*]'], ['~~', '~~', '[^\\s~]'], ['==', '==', '[^\\s=]'], ['`', '`', '[^`\\s]'], ['*', '\\*(?!\\*)', '[^\\s*]']] + .map(([mk, re, first]) => [new RegExp(MARK_EDGE + '(' + re + first + '[^\\n]*)$'), mk]); + function markTyped(c) { + const s = sel(); + if (!s.rangeCount || !s.isCollapsed) return false; + // text typed in pieces (an auto-closed pair is its own text node) is read as one run + if (s.focusNode.nodeType === 3) s.focusNode.parentNode.normalize(); + const n = s.focusNode, o = s.focusOffset; + if (n.nodeType !== 3 || !c.contains(n) || n.parentNode.closest(FMT_TAGS + ',a,.md-src,.wl')) return false; + const head = n.data.slice(0, o), tail = n.data.slice(o); + for (const [re, mk] of TYPED) { + const m = head.match(re); + if (!m) continue; + const start = m.index + m[1].length; + let src = m[2], end = o; + let f = srcFormat(src); + // not closed before the caret: maybe the caret is inside an auto-closed pair ("**te|**") + if (!f || f.n !== mk.length) { + if (!tail.startsWith(mk)) continue; + src += mk; + end = o + mk.length; + f = srcFormat(src); + } + if (!f || f.n !== mk.length) continue; + const r = document.createRange(); + r.setStart(n, start); + r.setEnd(n, end); + r.deleteContents(); + const span = document.createElement('span'); + span.className = 'md-src'; + r.insertNode(span); + drawSrc(span, src, o - start); + reveal = { c, span, orig: null, src0: null }; + return true; + } + return false; + } + // A key pressed while a piece is shown. At its very start or end, a typed character goes outside it (so typing on + // after **bold** is plain) and the piece turns back into formatting. Enter, Tab and shortcuts put it back first. + const INERT_KEYS = new Set(['Control', 'Alt', 'AltGraph', 'Meta', 'OS', 'Shift', 'CapsLock', 'NumLock', 'ScrollLock', 'Fn', 'FnLock', 'Hyper', 'Super', 'Symbol', 'SymbolLock', 'Dead', 'Process', 'Unidentified', 'ContextMenu', 'Escape']); + function revealKey(e, c) { + if (!reveal || reveal.c !== c || !reveal.span.isConnected) return false; + // a modifier or lock key on its own types nothing and moves nothing: the piece stays as it is + if (INERT_KEYS.has(e.key)) return false; + const at = caretInReveal(); + if (!at) return false; + const len = reveal.span.textContent.length; + const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing; + if (printable && (at.off === len || at.off === 0) && srcFormat(reveal.span.textContent)) { + e.preventDefault(); + const span = reveal.span; + const out = document.createTextNode(e.key); + if (at.off === len) span.after(out); else span.before(out); + const s = sel(); + const r = document.createRange(); + r.setStart(out, 1); + r.collapse(true); + s.removeAllRanges(); + s.addRange(r); + collapseReveal(); + onInput(c); + return true; + } + const keepShown = printable || ['Backspace', 'Delete', 'Shift', 'Home', 'End'].includes(e.key) || e.key.startsWith('Arrow'); + if (!keepShown || (e.key === 'Backspace' && atStart(c))) collapseReveal(); + return false; + } + // While a piece is shown, typing in it redraws it (its marks may have changed). + function redrawReveal(c) { + if (!reveal || reveal.c !== c || !reveal.span.isConnected) return false; + const at = caretInReveal(); + if (!at) return false; + const src = reveal.span.textContent; + if (!src) { reveal.span.remove(); reveal = null; return false; } + drawSrc(reveal.span, src, at.off); + return true; + } + /* input */ function onInput(c, e) { const el = c.closest('.block'); const b = el && byId(el.dataset.id); if (!b) return; skipDoubleSpace(e); + // A field with no text left carries no formatting either: an empty (or
) would make the next + // thing typed bold again. + if (!c.classList.contains('code-c') && c.innerHTML !== '' && !c.textContent.trim() && !c.querySelector('[contenteditable="false"], img')) { + c.innerHTML = ''; + b.html = ''; + const at = document.createRange(); + at.setStart(c, 0); + at.collapse(true); + const s = sel(); + s.removeAllRanges(); + s.addRange(at); + } if (c.classList.contains('tcell')) { autoLink(c, e); sync(c); @@ -1292,15 +1962,19 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (e && e.isComposing) return; b.text = c.textContent; const o = caretOffset(c); - c.innerHTML = highlight(b.text); + c.innerHTML = b.type === 'code' ? highlight(b.text) : esc(b.text); setCaret(c, o); + if (b.type === 'math') updatePreview(el, b, 80); + else if (b.type === 'mermaid') updatePreview(el, b, 350); return changed('type', b.id); } if (c.innerHTML === '
') c.innerHTML = ''; + // a shown piece is redrawn as it's edited; elsewhere, a just-completed **text** becomes one + if (!redrawReveal(c) && e && e.inputType === 'insertText') markTyped(c); if (!c.classList.contains('code-c')) autoLink(c, e); - if (c.classList.contains('c2')) { b.body = c.innerHTML; return changed('type', b.id); } - b.html = c.innerHTML; - if (b.type === 'p') { + if (c.classList.contains('c2')) { b.body = fieldHtml(c); return changed('type', b.id); } + b.html = fieldHtml(c); + if (b.type === 'p' && prefs().mdShortcuts !== false) { // Shortcuts only look at what was typed at the very start of the line, up to the first chip: a footnote // reference's number or a link's label must never read as "1. " or "# ". const t = c.textContent.replace(/\u00a0/g, ' '); @@ -1324,6 +1998,21 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } } } + // "- " already made a bullet, so "[ ] " typed at the start of it turns that bullet into a to-do ("- [ ] ") + if (b.type === 'bullet' && prefs().mdShortcuts !== false && !c.classList.contains('c2')) { + let lead = ''; + for (const n of c.childNodes) { if (n.nodeType !== 3) break; lead += n.data; } + const k = lead.replace(/\u00a0/g, ' ').match(/^\[[ xX]?\] /); + if (k) { + const done = /x/i.test(k[0]); + const r = rangeAt(c, 0, k[0].length); + if (r) r.deleteContents(); + if (c.innerHTML === '
') c.innerHTML = ''; + b.html = fieldHtml(c); + b.checked = done; + return convert(b, 'todo', 'start'); + } + } if (linkTyping(c, b)) return; const m = textBefore(c).match(/(?:^|\s)\/([\w-]{0,14})$/); m ? openSlash(c, b, m[1]) : closeSlash(); @@ -1373,8 +2062,407 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = return true; } + /* dragging a selection from one block into another + The browser keeps a mouse selection inside the editable field it started in, so once the pointer leaves that + block this takes over: it sets the selection itself (from where the drag began to the character under the + pointer), keeps the browser from fighting it while the button is down, and paints the selection with a + highlight of its own meanwhile. The result is an ordinary selection over several blocks, which + spannedBlocks() and its delete / replace / copy handling already deal with. */ + let xsel = null; // { anchor: { n, o }, active, x, y, timer } + const fieldOfNode = (n) => (n && (n.nodeType === 1 ? n : n.parentElement))?.closest?.('.c, .c2') || null; + const blockOfNode = (n) => (n && (n.nodeType === 1 ? n : n.parentElement))?.closest?.('.block') || null; + const caretAt = (x, y) => { + const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(x, y) : null; + return r && host.contains(r.startContainer) ? { n: r.startContainer, o: r.startOffset } : null; + }; + function extendSelection() { + if (!xsel) return; + let f = caretAt(xsel.x, xsel.y); + if (!f && scroller) { + // above or below the text: the start of the first block / end of the last one + const rect = scroller.getBoundingClientRect(); + const field = (b) => elOf(b.id)?.querySelector('.c, .c2'); + const edge = xsel.y < rect.top ? field(blocks[0]) : field(blocks[blocks.length - 1]); + if (edge) f = { n: edge, o: xsel.y < rect.top ? 0 : edge.childNodes.length }; + } + if (!f) return; + if (!xsel.active) { + const a = blockOfNode(xsel.anchor.n), b = blockOfNode(f.n); + // still inside the field it began in: the browser's own selection is right + if (!b || (a === b && fieldOfNode(xsel.anchor.n) === fieldOfNode(f.n))) return; + xsel.active = true; + document.body.classList.add('xsel'); + } + const s = sel(); + s.setBaseAndExtent(xsel.anchor.n, xsel.anchor.o, f.n, f.o); + if (s.rangeCount && window.CSS && CSS.highlights && window.Highlight) CSS.highlights.set('xsel', new Highlight(s.getRangeAt(0))); + } + function endCrossSelect() { + if (!xsel) return; + clearInterval(xsel.timer); + document.removeEventListener('mousemove', onCrossMove, true); + document.removeEventListener('mouseup', endCrossSelect, true); + window.removeEventListener('blur', endCrossSelect); + if (xsel.active) { + document.body.classList.remove('xsel'); + if (window.CSS && CSS.highlights) CSS.highlights.delete('xsel'); + } + xsel = null; + } + function onCrossMove(e) { + if (!xsel) return; + xsel.x = e.clientX; + xsel.y = e.clientY; + extendSelection(); + } + function onCrossDown(e) { + xKeep = null; + if (e.button === 0 && e.shiftKey && !e.altKey && e.target.closest?.('.c, .c2') && !e.target.closest('.emb, .tcell')) { + if (extendClick(e)) { e.preventDefault(); return; } + } + if (e.button !== 0 || e.detail > 1 || e.shiftKey || e.altKey) return; + const t = e.target; + if (!t.closest || !t.closest('.c, .c2') || t.closest('.emb, .tcell, .cols-bar, .code-bar')) return; + const a = caretAt(e.clientX, e.clientY); + if (!a) return; + endCrossSelect(); + xsel = { anchor: a, active: false, x: e.clientX, y: e.clientY, timer: 0 }; + document.addEventListener('mousemove', onCrossMove, true); + document.addEventListener('mouseup', endCrossSelect, true); + window.addEventListener('blur', endCrossSelect); + // dragging past the top or bottom edge scrolls the page along, and the selection follows + xsel.timer = setInterval(() => { + if (!xsel || !xsel.active || !scroller) return; + const r = scroller.getBoundingClientRect(); + const v = xsel.y < r.top + 36 ? -(r.top + 36 - xsel.y) : xsel.y > r.bottom - 36 ? xsel.y - (r.bottom - 36) : 0; + if (!v) return; + scroller.scrollTop += Math.max(-24, Math.min(24, v / 2)); + extendSelection(); + }, 16); + } + + /* Shift+arrows, Shift+Home/End and Shift+click carry a selection into the next or previous block + The browser stops extending at the edge of the field the selection began in. The selection's own end is moved + with Selection.modify (which does what the key would: a character, a word, a line, a paragraph), and where + that goes nowhere because the block ended, into the neighbouring block instead. */ + const fieldOf = (b) => (b ? elOf(b.id)?.querySelector('.c, .c2') : null); + const KEY_MOVES = { + ArrowLeft: ['backward', 'character', 'word'], + ArrowRight: ['forward', 'character', 'word'], + ArrowUp: ['backward', 'line', 'paragraph'], + ArrowDown: ['forward', 'line', 'paragraph'], + Home: ['backward', 'lineboundary', 'documentboundary'], + End: ['forward', 'lineboundary', 'documentboundary'] + }; + // Positions inside a text field as plain character counts, and back. + const textNodesIn = (f) => { + const w = document.createTreeWalker(f, NodeFilter.SHOW_TEXT); + const out = []; + for (let n = w.nextNode(); n; n = w.nextNode()) out.push(n); + return out; + }; + const charsBefore = (f, n, o) => { + const r = document.createRange(); + r.setStart(f, 0); + r.setEnd(n, o); + return r.toString().length; + }; + const locate = (f, k) => { + let left = k; + for (const t of textNodesIn(f)) { + if (left <= t.length) return { n: t, o: left }; + left -= t.length; + } + return { n: f, o: f.childNodes.length }; + }; + const isWordChar = (c) => /[\p{L}\p{N}_]/u.test(c); + + // Where the selection's end goes for a key, when it is already in another block than where the selection began + // (the browser's own Selection.modify can't help there: it keeps the end inside the anchor's field). + // Returns a position { n, o } or null (nothing further). + function stepFocus(fwd, gran, x) { + const s = sel(); + const here = (s.focusNode.nodeType === 1 ? s.focusNode : s.focusNode.parentElement)?.closest?.('.c, .c2'); + const bi = blocks.findIndex((b) => b.id === blockOfNode(s.focusNode)?.dataset.id); + const neighbour = () => { + // a toggle is two fields: its title, then (when open) its body + const own = blocks[bi], tg = own?.type === 'toggle' && own.open ? elOf(own.id) : null; + if (tg && fwd && here?.classList.contains('c')) return tg.querySelector('.c2'); + if (tg && !fwd && here?.classList.contains('c2')) return tg.querySelector('.c'); + let j = bi + (fwd ? 1 : -1); + while (blocks[j] && !fieldOf(blocks[j])) j += fwd ? 1 : -1; + const nb = blocks[j]; + // coming up from below into an open toggle: its body is the last field + if (!fwd && nb?.type === 'toggle' && nb.open) return elOf(nb.id)?.querySelector('.c2') || fieldOf(nb); + return fieldOf(nb); + }; + const end = (f) => ({ n: f, o: f.childNodes.length }); + const start = (f) => ({ n: f, o: 0 }); + if (!here) { + const f = neighbour(); + return f ? (fwd ? start(f) : end(f)) : null; + } + const text = here.textContent; + const k = charsBefore(here, s.focusNode, s.focusOffset); + if (gran === 'character') { + if (fwd ? k < text.length : k > 0) { + const step = fwd ? (/[\uD800-\uDBFF]/.test(text[k]) ? 2 : 1) : (/[\uDC00-\uDFFF]/.test(text[k - 1]) ? 2 : 1); + return locate(here, k + (fwd ? step : -step)); + } + } else if (gran === 'word') { + let q = k; + if (fwd) { while (q < text.length && !isWordChar(text[q])) q++; while (q < text.length && isWordChar(text[q])) q++; } + else { while (q > 0 && !isWordChar(text[q - 1])) q--; while (q > 0 && isWordChar(text[q - 1])) q--; } + if (q !== k) return locate(here, q); + } else if (gran === 'paragraph') { + if (fwd ? k < text.length : k > 0) return fwd ? end(here) : start(here); + } else if (gran === 'lineboundary') { + const r = document.createRange(); + r.setStart(s.focusNode, s.focusOffset); + r.collapse(true); + const rect = r.getClientRects()[0] || here.getBoundingClientRect(); + const box = here.getBoundingClientRect(); + const at = caretAt(fwd ? box.right - 1 : box.left + 1, rect.top + rect.height / 2); + return at && here.contains(at.n) ? at : fwd ? end(here) : start(here); + } else if (gran === 'line') { + const r = document.createRange(); + r.setStart(s.focusNode, s.focusOffset); + r.collapse(true); + const rect = r.getClientRects()[0] || here.getBoundingClientRect(); + const lh = parseFloat(getComputedStyle(here).lineHeight) || rect.height || 24; + const cx = x ?? rect.left; + const at = caretAt(cx, rect.top + rect.height / 2 + (fwd ? lh : -lh)); + if (at && here.contains(at.n) && (at.n !== s.focusNode || at.o !== s.focusOffset)) { + // a position on another line of this block + const r2 = document.createRange(); + r2.setStart(at.n, at.o); + r2.collapse(true); + const rr = r2.getClientRects()[0]; + if (!rr || Math.abs(rr.top - rect.top) > 2) return at; + } + // on the first or last line: to the edge of the text, and only then into the neighbour + if (fwd ? k < text.length : k > 0) return fwd ? end(here) : start(here); + } + // at the edge of this block: into the neighbouring one + const f = neighbour(); + if (!f) return null; + if (gran === 'line') { + const rect = f.getBoundingClientRect(); + const lh = parseFloat(getComputedStyle(f).lineHeight) || 24; + const rr = document.createRange(); + rr.setStart(s.focusNode, s.focusOffset); + rr.collapse(true); + const cx = x ?? (rr.getClientRects()[0] || rect).left; + const at = caretAt(cx, fwd ? rect.top + lh / 2 : rect.bottom - lh / 2); + if (at && f.contains(at.n)) return at; + } + return fwd ? start(f) : end(f); + } + + function extendKey(e) { + const move = KEY_MOVES[e.key]; + const s = sel(); + if (!move || !s.rangeCount) return false; + const mod = e.ctrlKey || e.metaKey; + const dir = move[0], gran = mod ? move[2] : move[1]; + const fwd = dir === 'forward'; + const focusBlock = blockOfNode(s.focusNode); + if (!focusBlock || !host.contains(s.focusNode) || !host.contains(s.anchorNode)) return false; + const i = blocks.findIndex((b) => b.id === focusBlock.dataset.id); + const setFocus = (t) => { + s.setBaseAndExtent(s.anchorNode, s.anchorOffset, t.n, t.o); + const bl = blockOfNode(t.n); + bl?.scrollIntoView({ block: 'nearest' }); + }; + // Ctrl+Shift+Home / End: to the very start or end of the page + if (gran === 'documentboundary') { + const f = fieldOf(fwd ? blocks[blocks.length - 1] : blocks[0]); + if (!f) return false; + setFocus(fwd ? { n: f, o: f.childNodes.length } : { n: f, o: 0 }); + return true; + } + // the caret's x, kept while moving over lines (a vertical move remembers its column) + if (gran !== 'line') xKeep = null; + else if (xKeep === null) { + const r = document.createRange(); + r.setStart(s.focusNode, s.focusOffset); + r.collapse(true); + const rect = r.getClientRects()[0]; + if (rect) xKeep = rect.left; + } + const inAnchor = blockOfNode(s.anchorNode) === focusBlock; + if (inAnchor) { + // inside the block the selection began in, the browser does what the key says + const was = { n: s.focusNode, o: s.focusOffset }; + s.modify('extend', dir, gran); + if (s.focusNode !== was.n || s.focusOffset !== was.o || gran === 'lineboundary') return true; + // stuck at the edge of the block + const t = stepFocusFrom(was, fwd, gran, i); + if (t) setFocus(t); + return true; + } + const t = stepFocus(fwd, gran, xKeep); + if (t) setFocus(t); + return true; + } + let xKeep = null; + // At the edge of a block, with the selection ending there: the neighbour block's near edge (same column for lines). + function stepFocusFrom(was, fwd, gran, i) { + let j = i + (fwd ? 1 : -1); + while (blocks[j] && !fieldOf(blocks[j])) j += fwd ? 1 : -1; + const f = fieldOf(blocks[j]); + if (!f) return null; + if (gran === 'line' && xKeep !== null) { + const rect = f.getBoundingClientRect(); + const lh = parseFloat(getComputedStyle(f).lineHeight) || 24; + const at = caretAt(xKeep, fwd ? rect.top + lh / 2 : rect.bottom - lh / 2); + if (at && f.contains(at.n)) return at; + } + return fwd ? { n: f, o: 0 } : { n: f, o: f.childNodes.length }; + } + // Shift+click: extend the selection from where it began to the clicked place, across blocks if need be + function extendClick(e) { + const s = sel(); + if (!s.rangeCount || !s.anchorNode || !host.contains(s.anchorNode)) return false; + const f = caretAt(e.clientX, e.clientY); + if (!f) return false; + s.setBaseAndExtent(s.anchorNode, s.anchorOffset, f.n, f.o); + return true; + } + + /* a selection over several blocks (Ctrl+A twice, or dragging across lines) + Each block is its own editable field, so the browser can't delete across them: Backspace / Delete remove what + the selection covers here, and typing (or Enter) replaces it. The first block keeps its text before the + selection, the last its text after it; those two join, everything between goes. */ + const TEXTISH = new Set(['p', 'h1', 'h2', 'h3', 'bullet', 'number', 'todo', 'quote', 'callout', 'toggle', 'footnote']); + function spannedBlocks() { + const s = sel(); + if (!s.rangeCount || s.isCollapsed) return null; + const r = s.getRangeAt(0); + if (r.commonAncestorContainer !== host && !host.contains(r.commonAncestorContainer)) return null; + const hit = blocks.filter((b) => { const el = elOf(b.id); return el && r.intersectsNode(el); }); + if (hit.length === 1 && hit[0].type === 'toggle' && fieldOfNode(r.startContainer) !== fieldOfNode(r.endContainer)) return { r, hit, inner: true }; + return hit.length > 1 ? { r, hit } : null; + } + function deleteSpanned() { + const span = spannedBlocks(); + if (!span) return false; + const { r, hit } = span; + const first = hit[0], lastB = hit[hit.length - 1]; + const startIdx = blocks.indexOf(first); + const field = (b) => (TEXTISH.has(b.type) ? elOf(b.id)?.querySelector('.c') : null); + const bodyOf = (b) => (b.type === 'toggle' ? elOf(b.id)?.querySelector('.c2') : null); + // inside one toggle, title into body: the title keeps what is before, the body what is after + if (span.inner) { + const head = field(first), body = bodyOf(first); + const a = document.createRange(); + a.setStart(r.startContainer, r.startOffset); + a.setEnd(head, head.childNodes.length); + const pre = document.createRange(); + pre.selectNodeContents(head); + pre.setEnd(r.startContainer, r.startOffset); + const at = pre.toString().length; + const z = document.createRange(); + z.setStart(body, 0); + z.setEnd(r.endContainer, r.endOffset); + z.deleteContents(); + a.deleteContents(); + sync(head); + sync(body); + last = { kind: null, id: null, at: 0 }; + changed(); + head.focus(); + setCaret(head, at); + return true; + } + // where the selection starts / ends inside a toggle's body, that body is the field to trim + const startBody = bodyOf(first)?.contains(r.startContainer) ? bodyOf(first) : null; + const endBody = bodyOf(lastB)?.contains(r.endContainer) ? bodyOf(lastB) : null; + const fc = startBody || field(first), lc = endBody || field(lastB); + // a toggle at the end is kept (its body would otherwise be lost): only what was selected goes + if (lastB.type === 'toggle' && lastB !== first) { + const head = field(lastB); + const to = document.createRange(); + to.setStart(lc, 0); + if (lc.contains(r.endContainer)) to.setEnd(r.endContainer, r.endOffset); + else to.setEnd(lc, lc.childNodes.length); + to.deleteContents(); + if (endBody) head.innerHTML = ''; + sync(head); + sync(lc); + } + // selected from a toggle's title onwards: its whole body is in the selection + if (first.type === 'toggle' && !startBody && bodyOf(first)) { bodyOf(first).innerHTML = ''; first.body = ''; } + // the first block: keep what is before the selection + let caretAt = 0; + if (fc) { + const from = document.createRange(); + if (fc.contains(r.startContainer)) from.setStart(r.startContainer, r.startOffset); + else from.setStart(fc, 0); + const pre = document.createRange(); + pre.selectNodeContents(fc); + pre.setEnd(from.startContainer, from.startOffset); + caretAt = pre.toString().length; + from.setEnd(fc, fc.childNodes.length); + from.deleteContents(); + } + // the last block: keep what is after it + if (lc && !(lastB.type === 'toggle' && lastB !== first)) { + const to = document.createRange(); + to.setStart(lc, 0); + if (lc.contains(r.endContainer)) to.setEnd(r.endContainer, r.endOffset); + else to.setEnd(lc, lc.childNodes.length); + to.deleteContents(); + } + const gone = new Set(hit.slice(1, -1).map((b) => b.id)); + if (!fc) gone.add(first.id); + let keep = fc ? first : null; + if (lastB.type === 'toggle' && lastB !== first) { + if (!keep) keep = lastB; + } else if (lc) { + if (keep) { + // the rest of the last block joins the first + if (lc.textContent || lc.querySelector('img, .wl, sup')) fc.insertAdjacentHTML('beforeend', lc.innerHTML); + gone.add(lastB.id); + } else keep = lastB; + } else gone.add(lastB.id); + for (const id of gone) elOf(id)?.remove(); + blocks = blocks.filter((b) => !gone.has(b.id)); + if (keep) { + const kc = keep === first && startBody ? startBody : field(keep); + sync(kc); + // everything selected from the very start of a heading, a list item ...: an ordinary line is left + if (!kc.textContent && keep.type !== 'p' && caretAt === 0) { keep.type = 'p'; delete keep.checked; delete keep.indent; elOf(keep.id).replaceWith(make(keep)); } + } else { + // nothing left that takes text: an empty line where the selection began + keep = block('p'); + if (first.col) keep.col = { ...first.col }; + const at = Math.min(startIdx, blocks.length); + blocks.splice(at, 0, keep); + const next = blocks[at + 1], kel = make(keep); + next && elOf(next.id) ? elOf(next.id).before(kel) : host.append(kel); + } + if (!blocks.length) { keep = block('p'); blocks.push(keep); host.append(make(keep)); } + last = { kind: null, id: null, at: 0 }; + changed(); + const kc = keep === first && startBody ? startBody : elOf(keep.id)?.querySelector('.c'); + if (kc) { kc.focus(); setCaret(kc, keep === first ? caretAt : 0); } + return true; + } + function onKeydown(e) { if (e.target.closest('.emb')) return; // an embed's text is read-only + if (!(e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown'))) xKeep = null; // a vertical move remembers its column only while it goes on + // Shift+arrows and Shift+Home / End: extend the selection, over block edges too + if (e.shiftKey && !e.altKey && !e.isComposing && KEY_MOVES[e.key] && e.target.closest('.c, .c2') && !e.target.closest('.tcell') && !slash) { + if (extendKey(e)) { e.preventDefault(); return; } + } + // a selection across blocks: delete it (and for a typed character or Enter, carry on in its place) + if (!e.isComposing && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'Enter' || e.key.length === 1) && spannedBlocks()) { + if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); deleteSpanned(); return; } + deleteSpanned(); + if (e.key === 'Enter') return; // the caret is now where the selection was: a plain Enter afterwards splits it + } const c = e.target.closest('.c,.c2,.tcell'); if (!c) return; const el = c.closest('.block'); @@ -1392,7 +2480,9 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); return closeSlash(); } } + if (revealKey(e, c)) return; if (c.classList.contains('code-c')) { + if (unpair(e, c, true) || autoPair(e, c, true)) return; if (e.key === 'Enter') { e.preventDefault(); insertText('\n'); return onInput(c); } if (e.key === 'Tab') { e.preventDefault(); insertText(' '); return onInput(c); } if (e.key === 'Backspace' && !c.textContent) { e.preventDefault(); b.html = ''; b.text = ''; return convert(b, 'p'); } @@ -1423,22 +2513,30 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (k === 'd' && !e.shiftKey) { e.preventDefault(); duplicate(b); return; } if (k === 'a' && !e.shiftKey) { + e.preventDefault(); const s = sel(); - if (s.rangeCount) { - const text = s.toString(); - if (text === c.textContent || text === c.innerText || text.trim() === c.textContent.trim()) { - e.preventDefault(); - const allRange = document.createRange(); - allRange.selectNodeContents(host); - s.removeAllRanges(); - s.addRange(allRange); - return; - } + const text = s.rangeCount && !s.isCollapsed ? s.toString() : ""; + const isBlockSelected = text && (text === c.textContent || text === c.innerText || text.trim() === c.textContent.trim()); + const r = document.createRange(); + if (isBlockSelected || !c.textContent.trim()) { + document.body.style.userSelect = "auto"; + r.selectNodeContents(host); + } else { + r.selectNodeContents(c); } + s.removeAllRanges(); + s.addRange(r); + return; } } + if (!c.classList.contains('tcell') && (smartType(e, c) || unpair(e, c, false) || autoPair(e, c, false))) return; if (c.classList.contains('tcell')) return onCellKey(e, c, b); - if (c.classList.contains('c2')) return; + if (c.classList.contains('c2')) return onBodyKey(e, c, b, el); + + if (e.key === ' ' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && !c.textContent.trim() && !c.querySelector('[contenteditable="false"], img')) { + e.preventDefault(); + return; + } if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && b.type === 'footnote' && atEnd(c) && backToReference(b)) { e.preventDefault(); @@ -1454,7 +2552,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = rest.setEnd(c, c.childNodes.length); const holder = document.createElement('div'); holder.append(rest.extractContents()); - b.html = c.innerHTML; + b.html = fieldHtml(c); const nt = LIST.has(b.type) ? b.type : 'p'; insertAfter(b.id, block(nt, holder.textContent ? holder.innerHTML : '', nt === 'todo' ? { checked: false, indent: b.indent } : { indent: b.indent }), 'start'); return; @@ -1482,6 +2580,12 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = const prev = blocks[blocks.indexOf(b) - 1]; if (!prev) return; e.preventDefault(); + // the start of a column (or of the first block after the columns) does not join the block before it + if ((prev.col ? prev.col.g + ':' + prev.col.i : '') !== (b.col ? b.col.g + ':' + b.col.i : '')) { + // an empty line at the top of a column goes (and an empty column with it; one column left is no group) + if (!c.textContent && b.col) { removeBlock(b.id, false); focusBlock(prev.id, 'end'); } + return; + } if (SOLID.has(prev.type)) return removeBlock(prev.id); if (prev.type === 'table') { // Never delete a table by backspacing into it: go to its last cell (and drop this line if it's empty). @@ -1502,10 +2606,47 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = s.addRange(saved); return; } + if (b.type === 'toggle' && b.open && e.key === 'ArrowDown' && !e.shiftKey && !e.altKey && !mod && atEnd(c)) { + e.preventDefault(); + return placeStart(el.querySelector('.c2')); + } if (e.key === 'ArrowUp' && atStart(c)) { e.preventDefault(); return move(b, -1, 'end'); } if (e.key === 'ArrowDown' && atEnd(c)) { e.preventDefault(); return move(b, 1, 'start'); } } + // A toggle's body: Shift+Enter is a new line in it, Enter a new block after the toggle; arrows and Backspace + // at its edges go back to the title or on to the next block. + function onBodyKey(e, c, b, el) { + if (e.isComposing || e.altKey || e.ctrlKey || e.metaKey) return; + const head = el.querySelector('.c'); + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const r = sel().getRangeAt(0); + r.deleteContents(); + const rest = document.createRange(); + rest.setStart(r.startContainer, r.startOffset); + rest.setEnd(c, c.childNodes.length); + const holder = document.createElement('div'); + holder.append(rest.extractContents()); + // a trailing empty line left behind (Shift+Enter then Enter) goes too + while (c.lastChild && (c.lastChild.nodeName === 'BR' || (c.lastChild.nodeType === 3 && !c.lastChild.textContent.replace(/\n+$/, '') && c.lastChild.textContent))) c.lastChild.remove(); + if (c.lastChild?.nodeType === 3) c.lastChild.textContent = c.lastChild.textContent.replace(/\n+$/, ''); + sync(c); + insertAfter(b.id, block('p', holder.textContent.trim() ? holder.innerHTML : ''), 'start'); + return; + } + if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + document.execCommand('insertLineBreak'); + return onInput(c); + } + if (e.shiftKey) return; + if (e.key === 'ArrowUp' && atStart(c)) { e.preventDefault(); return placeEnd(head); } + if (e.key === 'ArrowDown' && atEnd(c)) { e.preventDefault(); return move(b, 1, 'start'); } + if (e.key === 'Backspace' && atStart(c) && !c.textContent) { e.preventDefault(); c.innerHTML = ''; sync(c); return placeEnd(head); } + if (e.key === 'Escape') c.blur(); + } + function onPaste(e) { const c = e.target.closest('.c,.c2,.tcell'); if (!c) return; @@ -1542,9 +2683,30 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = last = { kind: null, id: null, at: 0 }; return; } + // Copied from a web page or a rich editor: headings, lists, links, bold and so on come in as Markdown blocks. + const html = e.clipboardData.getData('text/html'); + if (html && RICH.test(html) && !c.classList.contains('tcell') && !c.classList.contains('c2') && prefs().pasteRich !== false) { + const md = htmlToMarkdown(html).trim(); + if (md && md !== t.trim()) return pasteMarkdown(c, md); + } if (c.classList.contains('tcell')) t = t.replace(/\s*\n\s*/g, ' '); document.execCommand('insertText', false, t); } + // `md` pasted at the caret: one line of inline formatting goes into the field, anything more becomes blocks after it. + function pasteMarkdown(c, md) { + const b = byId(c.closest('.block').dataset.id); + const parsed = parseMarkdown(md); + if (!b) return; + if (parsed.length === 1 && parsed[0].type === 'p') { + document.execCommand('insertHTML', false, parsed[0].html); + return sync(c); + } + const empty = b.type === 'p' && !htmlText(b.html).trim(); + let ref = b.id; + for (const nb of parsed) { insertAfter(ref, nb, false); ref = nb.id; } + focusBlock(ref, 'end'); + if (empty) removeBlock(b.id, false); + } function onClick(e) { const calloutIco = e.target.closest('.callout-ico'); @@ -1602,17 +2764,24 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } else if (t.classList.contains('tg-chev')) { b.open = !b.open; el.querySelector('.tg').classList.toggle('open', b.open); - } else if (t.classList.contains('lang')) { - b.lang = LANGS[(LANGS.indexOf(b.lang) + 1) % LANGS.length]; - t.textContent = b.lang; - changed(); + } else if (t.classList.contains('lang') && b.type === 'code') { + openLangMenu(b, t.getBoundingClientRect()); } else if (t.classList.contains('copy')) { onCopy(b.text || ''); } } - function onFocusOut() { - setTimeout(() => { if (slash && !slash.c.contains(document.activeElement)) closeSlash(); }, 0); + function onFocusOut(e) { + const c = e.target?.closest?.('.c, .c2, .tcell'); + if (c && !c.classList.contains('code-c') && !c.textContent.trim() && !c.querySelector('[contenteditable="false"], img')) { + c.innerHTML = ''; + const b = byId(c.closest('.block')?.dataset.id); + if (b) b.html = ''; + } + setTimeout(() => { + if (slash && !slash.c.contains(document.activeElement)) closeSlash(); + if (reveal && !host.contains(document.activeElement)) { const c = reveal.c; collapseReveal(); sync(c); } + }, 0); } function onTail() { @@ -1657,22 +2826,101 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = Pointer events rather than native HTML drag and drop: native drops only land when released over the editor itself, which the grip column and heading margins are not, and Linux/Wayland drags are flaky. */ let drag = null; // { id, el, ghost, startY, offsetY, y, raf } - function target(y) { - const els = [...host.querySelectorAll('.block:not(.leaving)')].filter((x) => x.dataset.id !== dragId); - const base = host.getBoundingClientRect().top; - for (const x of els) { - const r = x.getBoundingClientRect(); - if (y < r.top + r.height / 2) return { before: x.dataset.id, top: r.top - base - 2 }; + // Where a dragged block would land, for a pointer at (x, y): + // { kind: 'top', before } between ordinary blocks (before: a block id, or null for the end) + // { kind: 'col', g, i, before } into column i of group g + // { kind: 'newcol', g, at } a new column in group g, at position `at` (fractional: between two) + // { kind: 'side', ref, side } beside block `ref`: the two of them become columns + // with `line`: where to draw the drop marker (relative to the editor). `simple`: only 'top' and 'col'. + function target(x, y, simple = false) { + const base = host.getBoundingClientRect(); + const hline = (r, top) => ({ top: top - base.top - 2, left: r.left - base.left, width: r.width }); + const vline = (left, r) => ({ v: true, left: left - base.left - 2, top: r.top - base.top, height: r.height }); + const live = (el) => el.dataset.id !== dragId && !el.classList.contains('leaving'); + for (const wrap of host.querySelectorAll(':scope > .cols')) { + const wr = wrap.getBoundingClientRect(); + const cols = [...wrap.querySelectorAll(':scope > .col')]; + if (!cols.length) continue; + const rs = cols.map((c) => c.getBoundingClientRect()); + const n = cols.length; + + const inColsX = x >= rs[0].left - 16 && x <= rs[n - 1].right + 16; + const prevEl = wrap.previousElementSibling; + const prevBottom = prevEl ? prevEl.getBoundingClientRect().bottom : base.top; + const topThreshold = inColsX ? Math.min(wr.top - 6, (prevBottom + wr.top) / 2) : wr.top - 6; + + const nextEl = wrap.nextElementSibling; + const nextTop = nextEl ? nextEl.getBoundingClientRect().top : base.bottom; + const bottomThreshold = inColsX ? Math.max(wr.bottom + 6, (wr.bottom + nextTop) / 2) : wr.bottom + 6; + + if (y < topThreshold || y > bottomThreshold) continue; + const g = wrap.dataset.g; + // a new column: at the group's outer edges, or in the gap between two columns + if (!simple) { + const isNewCol = (x < rs[0].left + 10) || (x > rs[n - 1].right - 10) || rs.some((r, k) => k < n - 1 && x > r.right && x < rs[k + 1].left); + if (isNewCol) { + if (n >= MAX_COLS) return { kind: 'none' }; + if (x < rs[0].left + 10) return { kind: 'newcol', g, at: -0.5, line: vline(rs[0].left - 10, wr) }; + if (x > rs[n - 1].right - 10) return { kind: 'newcol', g, at: n - 0.5, line: vline(rs[n - 1].right + 10, wr) }; + for (let k = 0; k < n - 1; k++) { + if (x > rs[k].right && x < rs[k + 1].left) return { kind: 'newcol', g, at: k + 0.5, line: vline((rs[k].right + rs[k + 1].left) / 2, wr) }; + } + } + } + // into the column under the pointer (or the nearest one) + let k = rs.findIndex((r) => x >= r.left && x <= r.right); + if (k < 0) k = x < rs[0].left ? 0 : n - 1; + const col = cols[k], cr = rs[k]; + const colIdx = +col.dataset.i; + const els = [...col.querySelectorAll(':scope > .block')].filter(live); + const isBlockEmpty = (el) => { + const b = byId(el.dataset.id); + return b && b.type === 'p' && (!b.html || !htmlText(b.html).trim()); + }; + const realEls = els.filter((el) => !isBlockEmpty(el)); + const emptyIds = els.filter(isBlockEmpty).map((el) => el.dataset.id); + if (!realEls.length) { + return { kind: 'col', g, i: colIdx, before: null, replaceEmpty: emptyIds, line: hline(cr, cr.top) }; + } + for (const el of realEls) { + const r = el.getBoundingClientRect(); + if (y < r.top + r.height / 2) return { kind: 'col', g, i: colIdx, before: el.dataset.id, cleanEmpty: emptyIds, line: hline(cr, r.top) }; + } + const lastEl = realEls[realEls.length - 1]; + return { kind: 'col', g, i: colIdx, before: null, cleanEmpty: emptyIds, line: hline(cr, lastEl.getBoundingClientRect().bottom + 2) }; } - const l = els[els.length - 1]; - return { before: null, top: l ? l.getBoundingClientRect().bottom - base : 0 }; + // the top level: ordinary blocks, and groups of columns as one piece each + const units = [...host.children].filter((u) => (u.classList.contains('block') && live(u)) || u.classList.contains('cols')); + for (const u of units) { + const r = u.getBoundingClientRect(); + if (!simple && u.classList.contains('block') && y >= r.top && y <= r.bottom && !SOLID.has(byId(u.dataset.id)?.type)) { + // beside a block (its right part, or just inside its left edge): the two become columns + if (x > r.right - 64) return { kind: 'side', ref: u.dataset.id, side: 'right', line: vline(r.right, r) }; + if (x >= r.left && x < r.left + 28) return { kind: 'side', ref: u.dataset.id, side: 'left', line: vline(r.left, r) }; + } + if (y < r.top + r.height / 2) { + const first = u.classList.contains('block') ? u : u.querySelector('.block'); + return { kind: 'top', before: first.dataset.id, line: hline(base, r.top) }; + } + } + const lu = units[units.length - 1]; + return { kind: 'top', before: null, line: hline(base, lu ? lu.getBoundingClientRect().bottom + 2 : base.top) }; + } + function showDrop(line) { + if (!line) { drop.classList.remove('show', 'v'); return; } + drop.classList.toggle('v', !!line.v); + drop.style.top = line.top + 'px'; + drop.style.left = line.left + 'px'; + drop.style.width = line.v ? '' : line.width + 'px'; + drop.style.height = line.v ? line.height + 'px' : ''; + drop.classList.add('show'); } const onPointerDown = (e) => { const g = e.button === 0 && e.target.closest('.g-drag'); if (!g || drag) return; e.preventDefault(); // no focus change or text selection const el = g.closest('.block'); - drag = { id: el.dataset.id, el, ghost: null, startY: e.clientY, offsetY: e.clientY - el.getBoundingClientRect().top, y: e.clientY, raf: 0 }; + drag = { id: el.dataset.id, el, ghost: null, startY: e.clientY, offsetY: e.clientY - el.getBoundingClientRect().top, y: e.clientY, x: e.clientX, raf: 0 }; document.addEventListener('pointermove', onDragMove); document.addEventListener('pointerup', onDragUp); document.addEventListener('pointercancel', onDragCancel); @@ -1699,8 +2947,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = drag.raf = 0; const { y } = drag; drag.ghost.style.top = y - drag.offsetY + 'px'; - drop.style.top = target(y).top + 'px'; - drop.classList.add('show'); + showDrop(target(drag.x, y).line); // Scroll the page when the pointer nears its top or bottom edge. if (scroller) { const r = scroller.getBoundingClientRect(); @@ -1714,6 +2961,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = } function onDragMove(e) { drag.y = e.clientY; + drag.x = e.clientX; if (!dragId) { if (Math.abs(e.clientY - drag.startY) < 4) return; startDrag(); @@ -1723,7 +2971,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = function onDragUp(e) { const moved = !!dragId; const id = drag.id, grip = drag.el.querySelector('.g-drag'); - if (moved) moveTo(target(e.clientY)); + if (moved) moveTo(target(e.clientX, e.clientY)); endDrag(); // A click without dragging opens the block menu. if (!moved && byId(id) && grip) openMenu(byId(id), grip.getBoundingClientRect()); @@ -1740,28 +2988,63 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = document.removeEventListener('pointerup', onDragUp); document.removeEventListener('pointercancel', onDragCancel); document.removeEventListener('keydown', onDragKey, true); - drop.classList.remove('show'); + drop.classList.remove('show', 'v'); dragId = null; drag = null; } function moveTo(t) { - if (t.before === dragId) return; + const b = byId(dragId); + if (!b || !t || t.kind === 'none' || t.before === dragId) return; const els = [...host.querySelectorAll('.block')]; - const first = new Map(els.map((x) => [x, x.getBoundingClientRect().top])); - const i = blocks.findIndex((b) => b.id === dragId); - const [b] = blocks.splice(i, 1); - const j = t.before ? blocks.findIndex((x) => x.id === t.before) : blocks.length; - blocks.splice(j, 0, b); - if (j === i) return; // dropped where it already was - const el = elOf(dragId); - t.before ? elOf(t.before).before(el) : host.append(el); + const first = new Map(els.map((x) => [x, x.getBoundingClientRect()])); + const was = JSON.stringify(blocks.map((x) => [x.id, x.col])); + blocks.splice(blocks.indexOf(b), 1); + if (t.kind === 'side') { + const ref = byId(t.ref); + const g = blockId(); + ref.col = { g, i: t.side === 'left' ? 1 : 0 }; + b.col = { g, i: t.side === 'left' ? 0 : 1 }; + blocks.splice(blocks.indexOf(ref) + 1, 0, b); + } else if (t.kind === 'newcol') { + b.col = { g: t.g, i: t.at }; + const group = blocks.filter((x) => x.col && x.col.g === t.g); + blocks.splice(group.length ? blocks.indexOf(group[group.length - 1]) + 1 : blocks.length, 0, b); + } else { + if (t.kind === 'col') { + b.col = { g: t.g, i: t.i }; + const toClean = (t.replaceEmpty || []).concat(t.cleanEmpty || []); + for (const id of toClean) { + const at = blocks.findIndex((x) => x.id === id); + if (at >= 0) blocks.splice(at, 1); + elOf(id)?.remove(); + } + } else { + delete b.col; + } + let j = blocks.length; + if (t.before) { + j = blocks.findIndex((x) => x.id === t.before); + } else if (t.kind === 'col') { + const inCol = blocks.filter((x) => x.col && x.col.g === t.g && x.col.i === t.i); + if (inCol.length) { + j = blocks.indexOf(inCol[inCol.length - 1]) + 1; + } else { + const group = blocks.filter((x) => x.col && x.col.g === t.g); + const nextInGroup = group.find((x) => x.col.i > t.i); + j = nextInGroup ? blocks.indexOf(nextInGroup) : (group.length ? blocks.indexOf(group[group.length - 1]) + 1 : blocks.length); + } + } + blocks.splice(j < 0 ? blocks.length : j, 0, b); + } + if (JSON.stringify(blocks.map((x) => [x.id, x.col])) === was) return; // dropped where it already was + changed(); // (layout() puts every element where the list now says) if (!reduce) { for (const x of els) { - const dy = first.get(x) - x.getBoundingClientRect().top; - if (dy) x.animate([{ transform: `translateY(${dy}px)` }, { transform: 'none' }], { duration: 320, easing: 'cubic-bezier(.2,.8,.2,1)' }); + const a = first.get(x), z = x.getBoundingClientRect(); + const dx = a.left - z.left, dy = a.top - z.top; + if (dx || dy) x.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: 'none' }], { duration: 320, easing: 'cubic-bezier(.2,.8,.2,1)' }); } } - changed(); } /* image files dropped in from the file manager (native drag and drop is fine for this) */ @@ -1770,8 +3053,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (!hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; - drop.style.top = target(e.clientY).top + 'px'; - drop.classList.add('show'); + showDrop(target(e.clientX, e.clientY, true).line); }; const onFileLeave = (e) => { if (!host.contains(e.relatedTarget)) drop.classList.remove('show'); }; const onFileDrop = (e) => { @@ -1780,8 +3062,27 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = drop.classList.remove('show'); const files = imageFiles(e.dataTransfer); if (!files.length) return; - const t = target(e.clientY); - addFiles(files, { at: t.before ? blocks.findIndex((x) => x.id === t.before) : blocks.length }); + const t = target(e.clientX, e.clientY, true); + if (t.kind === 'col') { + const toClean = (t.replaceEmpty || []).concat(t.cleanEmpty || []); + for (const id of toClean) { + const at = blocks.findIndex((x) => x.id === id); + if (at >= 0) blocks.splice(at, 1); + elOf(id)?.remove(); + } + } + let at = t.before ? blocks.findIndex((x) => x.id === t.before) : blocks.length; + if (!t.before && t.kind === 'col') { + const inCol = blocks.filter((x) => x.col && x.col.g === t.g && x.col.i === t.i); + if (inCol.length) { + at = blocks.indexOf(inCol[inCol.length - 1]) + 1; + } else { + const group = blocks.filter((x) => x.col && x.col.g === t.g); + const nextInGroup = group.find((x) => x.col.i > t.i); + at = nextInGroup ? blocks.indexOf(nextInGroup) : (group.length ? blocks.indexOf(group[group.length - 1]) + 1 : blocks.length); + } + } + addFiles(files, { at }); }; host.addEventListener('input', (e) => { const c = e.target.closest('.c,.c2,.tcell'); if (c) onInput(c, e); }); @@ -1792,7 +3093,14 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = host.addEventListener('error', (e) => { if (e.target.tagName === 'IMG') e.target.closest('.img')?.classList.add('broken'); }, true); document.addEventListener('keydown', onHistoryKey); host.addEventListener('keydown', onKeydown); -host.addEventListener('copy', (e) => { +host.addEventListener('cut', (e) => { + const span = spannedBlocks(); + if (!span) return; + e.preventDefault(); + e.clipboardData.setData('text/plain', span.inner ? span.r.toString() : toMarkdown(span.hit)); + deleteSpanned(); + }); + host.addEventListener('copy', (e) => { const s = window.getSelection(); if (s.isCollapsed) return; @@ -1823,8 +3131,12 @@ host.addEventListener('copy', (e) => { }); host.addEventListener('paste', onPaste); host.addEventListener('click', onClick); + host.addEventListener('click', onColsClick); + host.addEventListener('pointerdown', onResizeDown); + host.addEventListener('dblclick', onResizeDouble); host.addEventListener('focusout', onFocusOut); host.addEventListener('pointerdown', onPointerDown); + host.addEventListener('mousedown', onCrossDown); tail.addEventListener('click', onTail); slashEl.addEventListener('mousedown', slashDown); slashEl.addEventListener('click', slashClick); @@ -1833,7 +3145,27 @@ host.addEventListener('copy', (e) => { scroller?.addEventListener('scroll', closeSlash, { passive: true }); scroller?.addEventListener('scroll', closeMenu, { passive: true }); scroller?.addEventListener('scroll', onSelection, { passive: true }); + // Typewriter mode (a setting): the line being written stays about half-way down the screen. + function typewriterScroll() { + if (!prefs().typewriter) return; + requestAnimationFrame(() => { + const s = sel(); + if (!s.rangeCount || !host.contains(s.focusNode)) return; + let rect = s.getRangeAt(0).getClientRects()[0]; + if (!rect) { const n = s.focusNode.nodeType === 1 ? s.focusNode : s.focusNode.parentElement; rect = n && n.getBoundingClientRect(); } + if (!rect) return; + let sc = host.parentElement; + while (sc && !(sc.scrollHeight > sc.clientHeight && /(auto|scroll)/.test(getComputedStyle(sc).overflowY))) sc = sc.parentElement; + if (!sc) return; + const box = sc.getBoundingClientRect(); + const delta = rect.top + rect.height / 2 - (box.top + box.height * 0.45); + if (Math.abs(delta) > 4) sc.scrollTop += delta; + }); + } + host.addEventListener('input', typewriterScroll); + host.addEventListener('keyup', (e) => /^(Arrow|Page|Home|End|Enter)/.test(e.key) && typewriterScroll()); document.addEventListener('selectionchange', onSelection); + document.addEventListener('selectionchange', checkReveal); document.addEventListener('pointerdown', onDocDown, true); bar.addEventListener('mousedown', barDownSafe); bubble.addEventListener('mousedown', barDown); @@ -1857,8 +3189,37 @@ host.addEventListener('copy', (e) => { if (!b) { b = block('p'); blocks.unshift(b); host.insertBefore(make(b), drop.nextSibling); } focusBlock(b.id, 'start'); }, + // The caret at the end of the last block you can type in. + focusLast() { + const b = [...blocks].reverse().find((x) => !SOLID.has(x.type) && x.type !== 'footnote'); + if (b) focusBlock(b.id, 'end'); else this.focusFirst(); + }, // Page list changed: re-check which links point nowhere. refreshLinks() { paintLinks(host); }, + // A heading of this page was renamed: re-point the links in this page that name it ([[#Old]] or [[ThisPage#Old]]). + // It follows the heading edit, so it is not an undo step of its own. Returns how many links changed. + renameHeadingRefs(from, to) { + const safe = linkHeading(to); + if (!safe) return 0; + const here = links.current(); + const touched = new Set(); + let n = 0; + for (const chip of host.querySelectorAll('.wl[data-ref]')) { + if (chip.closest('.emb')) continue; + const ref = chip.dataset.ref; + if (!matchesHeading(headingOf(ref), from)) continue; + if ((refPage(ref) ? links.resolve(refPage(ref)) : here) !== here) continue; + const next = ref.slice(0, ref.lastIndexOf('#') + 1) + safe; + if (chip.textContent === linkLabel(ref)) chip.textContent = linkLabel(next); // a custom |label stays as written + chip.dataset.ref = next; + const c = chip.closest('.c,.c2,.tcell'); + if (c) touched.add(c); + n++; + } + for (const c of touched) sync(c); + if (n) { saved = snap(); onChange(blocks); } + return n; + }, // For the right-click menu: formats of the current selection (null when formatting doesn't apply), and actions. formatState() { const c = selBlock(); return c ? formatsAt(c) : null; }, format(f) { return format(f); }, @@ -1881,6 +3242,8 @@ host.addEventListener('copy', (e) => { undo, redo, destroy() { + endCrossSelect(); + for (const e of liveEmbeds.splice(0)) e.stop(); closeSlash(); slashEl.remove(); document.removeEventListener('mousemove', trackPointer, true); @@ -1894,6 +3257,7 @@ host.addEventListener('copy', (e) => { scroller?.removeEventListener('scroll', hideBubble); cancelAnimationFrame(barFrame); document.removeEventListener('selectionchange', onSelection); + document.removeEventListener('selectionchange', checkReveal); document.removeEventListener('pointerdown', onDocDown, true); scroller?.removeEventListener('scroll', closeMenu); scroller?.removeEventListener('scroll', onSelection); diff --git a/src/lib/editorHooks.js b/src/lib/editorHooks.js new file mode 100644 index 0000000..e43b43e --- /dev/null +++ b/src/lib/editorHooks.js @@ -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; + } + } + } + }; +} diff --git a/src/lib/embed.js b/src/lib/embed.js new file mode 100644 index 0000000..3f76d42 --- /dev/null +++ b/src/lib/embed.js @@ -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 + ? `
${e.html}
` + : 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) => `
${byCol.get(k).map(render).join('')}
`); + 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 += `
${cols.join('')}
`; + i = end; + } + return html; +} + +function one(b, imageUrl) { + const c = `
${b.html || ''}
`; + const row = (inner, cls = '') => `
${inner}
`; + switch (b.type) { + case 'p': return htmlText(b.html).trim() ? row(c) : ''; + case 'bullet': return row(`${c}`); + case 'number': return row(`${c}`); + case 'todo': return row(`${ic('tick')}${c}`, b.checked ? ' done' : ''); + case 'callout': { + const k = b.kind || 'note'; + return row(`
${ic(CALLOUT_ICONS[k] || 'leaf')}${c}
`); + } + case 'code': return row(`
${esc(b.lang || 'txt')}
${highlight(b.text || '')}
`); + case 'mermaid': return row(`
mermaid
${highlight(b.text || '')}
`); + case 'math': { + let out; + try { out = katex.renderToString(b.text || '', { throwOnError: false, displayMode: true }); } catch { out = esc(b.text || ''); } + return row(`
${out}
`); + } + case 'divider': return row('
'); + case 'image': + return row(`
${esc(b.alt || '')}${ic('image')}Image not found: ${esc(b.src)}
`); + case 'table': { + const align = (i) => (b.align?.[i] ? ` style="text-align:${esc(b.align[i])}"` : ''); + const rows = (b.rows || []).map((r, ri) => `${r.map((h, ci) => `<${ri ? 'td' : 'th'}${align(ci)}>
${h || ''}
`).join('')}`).join(''); + return row(`
${rows}
`); + } + case 'toggle': + return row(`
${b.html || ''}
${b.body || ''}
`); + case 'footnote': return `
${esc(b.fid)}${c}
`; + case 'embed': return row(`
${ic('file')}${linkChip(b.ref)}
`); + default: return row(c); // h1 h2 h3 quote + } +} diff --git a/src/lib/footnotes.js b/src/lib/footnotes.js new file mode 100644 index 0000000..4948519 --- /dev/null +++ b/src/lib/footnotes.js @@ -0,0 +1,24 @@ +// Footnote numbers on screen. References ([^id] and ^[inline] notes, as ) 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; +} diff --git a/src/lib/formula.js b/src/lib/formula.js new file mode 100644 index 0000000..713162f --- /dev/null +++ b/src/lib/formula.js @@ -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; +} diff --git a/src/lib/graph.js b/src/lib/graph.js index a01c8dd..f38f420 100644 --- a/src/lib/graph.js +++ b/src/lib/graph.js @@ -7,7 +7,7 @@ // 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. -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 } = {}) { 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; }, 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) { + THETA2 = nodes.length > 3000 ? 1.8 : 0.81; // 10k notes: coarser far-field, about twice as fast for (let t = 0; t < times; t++) { sim.alpha += (sim.alphaTarget - sim.alpha) * sim.alphaDecay; const alpha = sim.alpha; diff --git a/src/lib/graphColors.js b/src/lib/graphColors.js new file mode 100644 index 0000000..4459111 --- /dev/null +++ b/src/lib/graphColors.js @@ -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; +} diff --git a/src/lib/highlight.js b/src/lib/highlight.js new file mode 100644 index 0000000..214d0a2 --- /dev/null +++ b/src/lib/highlight.js @@ -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 += `${esc(m[0])}`; + last = RE.lastIndex; + } + out += esc(src.slice(last)); + // A trailing newline needs a
to get its own visible line in a contenteditable
.
+  if (src.endsWith('\n')) out += '
'; + return out; +} diff --git a/src/lib/htmlDoc.js b/src/lib/htmlDoc.js new file mode 100644 index 0000000..4b30725 --- /dev/null +++ b/src/lib/htmlDoc.js @@ -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:
,
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 }) }; +} diff --git a/src/lib/icons.js b/src/lib/icons.js index fc85fd0..9fdc1d0 100644 --- a/src/lib/icons.js +++ b/src/lib/icons.js @@ -2,6 +2,8 @@ export const PATHS = { plus: 'M12 5v14|M5 12h14', 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', panel: 'M4 5h16v14H4z|M9.5 5v14', dots: 'M6 12h.01|M12 12h.01|M18 12h.01', @@ -10,6 +12,10 @@ export const PATHS = { minimize: 'M6 12h12', maximize: 'M6 6h12v12H6z', 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', 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', @@ -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', 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', + 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', alignRight: 'M4 6h16|M10 10h10|M4 14h16|M10 18h10', minus: 'M5 12h14', @@ -89,7 +106,70 @@ export const PATHS = { tag: 'M3 12V4h8l10 10-8 8z|M7.5 8h.01', inbox: 'M3 13l3-8h12l3 8v6H3z|M3 13h5l1 2h6l1-2h5', 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. @@ -107,7 +187,26 @@ export const PICKER_ICONS = [ ['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'], ['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. @@ -127,7 +226,20 @@ export const PICKER_EMOJI = [ '❤️ 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', '📦 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)]; }); 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']; +// 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 = { blush: ['#d99a91', '#caa07c'], sage: ['#97b096', '#d1b48e'], diff --git a/src/lib/kanban.js b/src/lib/kanban.js new file mode 100644 index 0000000..d3908f7 --- /dev/null +++ b/src/lib/kanban.js @@ -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 }; +} diff --git a/src/lib/links.js b/src/lib/links.js new file mode 100644 index 0000000..bf67b1e --- /dev/null +++ b/src/lib/links.js @@ -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; +} diff --git a/src/lib/markdown.js b/src/lib/markdown.js index a81c05c..e22f52a 100644 --- a/src/lib/markdown.js +++ b/src/lib/markdown.js @@ -241,6 +241,34 @@ export function parseMarkdown(src) { out.push(block('quote', mdToHtml(buf.join('\n')))); 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 (/^]*)?>\s*$/i.test(line.trim())) { flush(); 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) { let out = ''; let prevList = false; let prevType = null; 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; const isList = LISTS.has(b.type); 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 body = []; 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) { + 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 = null; } + if (curG !== null && g !== curG) body.push(''); + else if (curKey !== null) body.push(''); + if (g !== null && g !== curG) body.push('
'); + else if (g !== null) body.push('
'); + curKey = key; curG = g; + } const want = b.type === 'number' ? 'ol' : LISTS.has(b.type) ? 'ul' : null; if (list && list !== want) { body.push(``); list = null; } if (want && !list) { body.push(`<${want}>`); list = want; } @@ -418,7 +483,8 @@ export function toHtmlDocument(title, raw) { default: if (b.html) body.push(`

${b.html}

`); } } - if (list) body.push(``); + if (list) { body.push(``); list = null; } + if (curG !== null) body.push('
'); return ` @@ -438,6 +504,7 @@ export function toHtmlDocument(title, raw) { hr { border: 0; border-top: 1px solid #e3e3e6; margin: 24px 0; } 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; } + .cols { display: flex; gap: 28px; } .col { flex: 1; min-width: 0; } a { color: #a8564c; } .wl { color: #a8564c; text-decoration: underline; text-decoration-color: #e3c2bb; } diff --git a/src/lib/mock.js b/src/lib/mock.js new file mode 100644 index 0000000..b725c28 --- /dev/null +++ b/src/lib/mock.js @@ -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 Ctrl K 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('(? 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('(? (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 () => {}; } + }; +} diff --git a/src/lib/prefs.js b/src/lib/prefs.js new file mode 100644 index 0000000..5548c85 --- /dev/null +++ b/src/lib/prefs.js @@ -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: '' // '' = /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'); +} diff --git a/src/lib/scroll.js b/src/lib/scroll.js new file mode 100644 index 0000000..18a3d7b --- /dev/null +++ b/src/lib/scroll.js @@ -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; +} diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js new file mode 100644 index 0000000..8e04ae9 --- /dev/null +++ b/src/lib/shortcuts.js @@ -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]); +}; diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index f5e48c7..0796abc 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -7,17 +7,37 @@ import { parseMarkdown, toMarkdown, blockText, toHtmlDocument } from './markdown import { PAGE_ICONS, COVERS, PATHS, isEmoji } from './icons.js'; import { countWords, htmlText } from './text.js'; import { toast } from './toast.svelte.js'; +import { cleanFolder, autoFolder, dailyId, dailyBody, formatDate } from './daily.js'; +import { articleOf } from './htmlDoc.js'; +import { DEFAULTS, cleanPrefs, wikiToMarkdownLinks } from './prefs.js'; import { resolveLink, refFor, refPage, headingOf, normHeading, matchesHeading } from './links.js'; import { sectionOf } from './embed.js'; import { scrollToBlock } from './scroll.js'; import { BOARD_KEY, isBoard, parseBoard, boardToMd, defaultBoard, boardFromMarkdown } from './kanban.js'; +import { isCanvasId } from './canvas.js'; +import { DB_KEY, isDatabase, parseDatabase, databaseToMd, defaultDatabase, view as newView, withFormulas } from './database.js'; +import { databaseCsv, parseCsv } from './csv.js'; +const CORE_KEYS = ['icon', 'cover', 'tags', 'created']; +// A canvas, board or database keeps its file text as is (#raw); other pages are blocks. +const rawOf = (id, r) => (isCanvasId(id) || isBoard(r.meta) || isDatabase(r.meta) ? r.body : null); + +// A page's content as one comparable string: front matter without empty values and in a fixed order, then the text. +// (Used to tell whether the file on disk is still what this window last read or wrote.) +const canon = (meta, body) => + JSON.stringify([ + Object.keys(meta || {}) + .filter((k) => { const v = meta[k]; return !(v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length)); }) + .sort() + .map((k) => [k, Array.isArray(meta[k]) ? meta[k].map(String) : String(meta[k])]), + String(body ?? '').replace(/\r\n/g, '\n') + ]); const hash = (s) => { let h = 7; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) | 0; return Math.abs(h); }; const byTitle = (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }); const lastSegment = (id) => id.slice(id.lastIndexOf('/') + 1); // A page's icon: a stroke icon name or an emoji from its front matter, else one picked from its title. -export const iconOf = (p) => (p && p.icon && (PATHS[p.icon] || isEmoji(p.icon)) ? p.icon : PAGE_ICONS[hash(p ? p.title : '') % PAGE_ICONS.length]); +export const iconOf = (p) => (p && p.icon && (PATHS[p.icon] || isEmoji(p.icon)) ? p.icon : p && isCanvasId(p.id) ? 'canvas' : PAGE_ICONS[hash(p ? p.title : '') % PAGE_ICONS.length]); export const coverOf = (p) => (p && COVERS[p.cover] ? p.cover : Object.keys(COVERS)[hash(p ? p.title : '') % 4]); export const isMobile = () => typeof window !== 'undefined' && window.innerWidth <= 760; @@ -33,11 +53,22 @@ class Store { themePref = $state('dark'); // what was chosen: 'dark' | 'light' | 'system' fontSize = $state(16); // page text, px pageWidth = $state('default'); // 'narrow' | 'default' | 'wide' | 'full' + quickLayout = $state('vertical'); // the sidebar's Search / New page / Today / Graph view: 'vertical' list or 'horizontal' icons + pageFont = $state('sans'); // 'sans' | 'serif' | 'mono' + lineSpacing = $state('default'); // 'compact' | 'default' | 'relaxed' + palette = $state('default'); // color theme: 'default' or a name from Store.PALETTES + accent = $state('blush'); // 'blush' (default) or a name from Store.ACCENTS + reduceMotion = $state(false); + startPage = $state('last'); // which page opens at launch: 'last' | 'today' + prefs = $state({ ...DEFAULTS }); // see prefs.js settingsOpen = $state(false); view = $state('page'); // 'page' | 'graph' - graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, spacing: 1 }); + graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, tags: false, spacing: 1 }); collapsed = $state(false); + peek = $state(false); // the hidden sidebar is floated in while the mouse is at the window's left edge + tagsOpen = $state(true); // the sidebar's tag list is folded away when false focusMode = $state(false); + slashRecent = []; // block kinds last picked from the slash menu, newest first drawer = $state(false); saving = $state(false); justSaved = $state(false); @@ -52,13 +83,33 @@ class Store { paletteQuery = ''; // text to start the search palette with renaming = $state(null); // page id being renamed inline in the sidebar pins = $state([]); // favorite page ids, in the order they were added + tagColors = $state({}); // tag -> colour name (see tagColors.js), per notes folder + dailyFolder = $state(null); // where daily notes go (a page path); null = find it (see daily.js), per notes folder recent = $state([]); // recently opened page ids, newest first trashOpen = $state(false); + tasksOpen = $state(false); + tagManagerOpen = $state(false); // the tag manager dialog + calendarOpen = $state(false); + clipOpen = $state(false); + searches = $state([]); // saved searches: [{ q }], per notes folder historyOpen = $state(null); templatesOpen = $state(null); // null, or { parent } + csvImport = $state(null); // the CSV import dialog: { target: database id or null (a new database), name, text } findOpen = $state(0); // find-in-page bar: 0 closed, otherwise bumped on every Ctrl+F to refocus it + navHistory = $state([]); // [{ id, view }] + navIndex = $state(-1); + #isNavigating = false; + + get canGoBack() { + return this.navIndex > 0; + } + get canGoForward() { + return this.navIndex >= 0 && this.navIndex < this.navHistory.length - 1; + } + editor = null; // set by , not reactive + canvasView = null; // set by (what the menus need from the open canvas), not reactive #vaults = {}; // per notes folder: { [path]: { pins, recent } }, kept in settings #blocks = []; headings = $state.raw([]); // outline of the open page: [{ id (block id), level 1-3, text }] @@ -66,20 +117,36 @@ class Store { #headingTimer = null; #headingChain = Promise.resolve(); outlineOpen = $state(false); + split = $state(null); // id of the page shown in the side pane (an ordinary page, never the one on the left) + splitHint = $state(null); // 'left' | 'right' while a sidebar drag is over that half of the page + splitSave = null; // set by : writes its pending edit, not reactive #raw = null; // a board's Markdown body; null for ordinary pages, which save from #blocks + #base = null; // canon() of the open page as this window last read or wrote it, to notice edits made elsewhere #dirty = false; #timer = null; #chain = Promise.resolve(); #version = 0; get current() { return this.pages.find((p) => p.id === this.currentId) || null; } - children(parent) { return this.pages.filter((p) => p.parent === parent).sort(byTitle); } + // parent id -> its sub-pages by title, built once per page list (the sidebar asks for every folder's children) + #childMap = $derived.by(() => { + const map = new Map(); + for (const p of this.pages) { + const k = p.parent || null; + const list = map.get(k); + if (list) list.push(p); else map.set(k, [p]); + } + for (const list of map.values()) list.sort(byTitle); + return map; + }); + children(parent) { return this.#childMap.get(parent || null) || []; } + #byId = $derived.by(() => new Map(this.pages.map((p) => [p.id, p]))); ancestors(id) { const out = []; - let p = this.pages.find((x) => x.id === id); + let p = this.#byId.get(id); let parent = p && p.parent; while (parent) { - const a = this.pages.find((x) => x.id === parent); + const a = this.#byId.get(parent); if (!a) break; out.unshift(a); parent = a.parent; @@ -102,19 +169,35 @@ class Store { this.#systemDark.addEventListener('change', () => { if (this.themePref === 'system') this.#transition(this.#resolve('system')); }); this.setFontSize(s.fontSize, false); this.setPageWidth(s.pageWidth, false); + this.quickLayout = s.quickLayout === 'horizontal' ? 'horizontal' : 'vertical'; + this.setPageFont(s.pageFont, false); + this.setLineSpacing(s.lineSpacing, false); + this.setPalette(s.palette, false); + this.setAccent(s.accent, false); + this.setReduceMotion(!!s.reduceMotion, false); + this.prefs = cleanPrefs(s.prefs); + this.#applyPrefs(); + this.startPage = s.startPage === 'today' ? 'today' : 'last'; if (s.graph && typeof s.graph === 'object') this.graph = { ...this.graph, ...s.graph }; this.collapsed = !!s.sidebarCollapsed; + this.tagsOpen = s.tagsOpen !== false; this.focusMode = !!s.focusMode; + this.slashRecent = Array.isArray(s.slashRecent) ? s.slashRecent.filter((t) => typeof t === 'string').slice(0, 5) : []; this.outlineOpen = !!s.outline; this.open = s.open && typeof s.open === 'object' ? s.open : {}; this.#vaults = s.vaults && typeof s.vaults === 'object' ? s.vaults : {}; const v = await api.get(); - if (v) await this.#load(v, s.lastPage); + if (v) { + await this.#load(v, s.lastPage); + if (this.startPage === 'today') await this.openDaily(); + } } catch (err) { toast(errorText(err), 'x'); } this.ready = true; api.onChanged((ids) => this.#external(ids)); + api.onCapture?.(() => this.takeCaptures()); + this.takeCaptures(); api.onFlush(() => this.settle()); window.addEventListener('blur', () => this.settle()); } @@ -124,6 +207,9 @@ class Store { this.pages = v.pages; const saved = this.#vaults[v.path] || {}; this.pins = Array.isArray(saved.pins) ? saved.pins : []; + this.searches = Array.isArray(saved.searches) ? saved.searches.filter((x) => x && typeof x.q === 'string' && x.q.trim()).map((x) => ({ q: x.q.trim() })) : []; + this.dailyFolder = typeof saved.dailyFolder === 'string' && saved.dailyFolder ? saved.dailyFolder : null; + this.tagColors = saved.tagColors && typeof saved.tagColors === 'object' && !Array.isArray(saved.tagColors) ? { ...saved.tagColors } : {}; this.recent = Array.isArray(saved.recent) ? saved.recent : []; const first = this.pages.find((p) => p.id === last) || this.children(null).find((p) => p.exists) || this.pages[0]; if (first) await this.openPage(first.id); @@ -141,20 +227,86 @@ class Store { async refresh() { const v = await api.list(); - if (v) this.pages = v.pages; + if (!v) return; + // The open page may have edits that aren't on disk yet (a cover just chosen, a property just set): the list + // read from disk would take them back out, so what the page itself holds wins until it is saved. + if (this.doc && this.#dirty) { + const p = v.pages.find((x) => x.id === this.doc.id); + if (p) this.#syncListEntry(p, this.doc.meta); + } + this.pages = v.pages; + } + + // The fields of a page's list entry that come from its front matter, taken from `meta`. + #syncListEntry(p, meta) { + p.icon = meta.icon || null; + p.cover = meta.cover || null; + p.tags = Array.isArray(meta.tags) ? meta.tags : []; + const props = {}; + for (const [k, v] of Object.entries(meta)) if (!CORE_KEYS.includes(k)) props[k] = v; + p.props = props; + } + + /* navigation history */ + #pushNav(entry) { + const cur = this.navHistory[this.navIndex]; + if (cur && cur.view === entry.view && cur.id === entry.id) return; + if (this.navIndex < this.navHistory.length - 1) { + this.navHistory = this.navHistory.slice(0, this.navIndex + 1); + } + this.navHistory.push(entry); + if (this.navHistory.length > 80) { + this.navHistory.shift(); + } + this.navIndex = this.navHistory.length - 1; + } + + back() { return this.#go(-1); } + forward() { return this.#go(1); } + + // Moves through the history one step at a time; presses made while a move is still loading are queued + // rather than dropped, so rapid mouse-button clicks walk the history exactly as many steps. + #goQueue = Promise.resolve(); + #go(delta) { + this.#goQueue = this.#goQueue.then(() => this.#step(delta)).catch(() => {}); + return this.#goQueue; + } + async #step(delta) { + let i = this.navIndex + delta; + while (i >= 0 && i < this.navHistory.length) { + const entry = this.navHistory[i]; + this.#isNavigating = true; + try { + if (entry.view === 'graph') { + if (this.view !== 'graph') this.openGraph(); + this.navIndex = i; + return; + } + if (this.view === 'graph') this.closeGraph(); + await this.openPage(entry.id, { push: false }); + if (this.currentId === entry.id) { this.navIndex = i; return; } + } finally { + this.#isNavigating = false; + } + // The page could not be opened (deleted?): drop the entry and try the next one in that direction. + this.navHistory.splice(i, 1); + if (delta < 0) { i--; this.navIndex--; } + } } /* pages */ - async openPage(id) { + async openPage(id, { push = true } = {}) { this.view = 'page'; await this.settle(); let r; try { r = await api.read(id); } catch (err) { toast(errorText(err), 'x'); return; } for (const a of this.ancestors(id)) this.open[a.id] = true; this.currentId = id; + if (this.split === id) this.split = null; // the side pane would show the same file twice this.titleDraft = null; - this.#blocks = parseMarkdown(r.body); - this.#raw = isBoard(r.meta) ? r.body : null; + this.#raw = rawOf(id, r); + this.#base = canon(r.meta, r.body); + this.#blocks = isCanvasId(id) ? [] : parseMarkdown(r.body); this.#dirty = false; this.doc = { id, title: r.title, meta: r.meta || {}, blocks: this.#blocks, body: r.body, exists: r.exists, version: ++this.#version }; this.#derive(true); @@ -162,16 +314,18 @@ class Store { this.recent = [id, ...this.recent.filter((x) => x !== id)].slice(0, 30); this.#persist({ lastPage: id, open: $state.snapshot(this.open) }); this.#saveLists(); + if (push && !this.#isNavigating) { + this.#pushNav({ id, view: 'page' }); + } } async newPage(parent = null, templateName = null) { await this.flush(); const covers = Object.keys(COVERS); try { - let meta = { - icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)], - cover: covers[Math.floor(Math.random() * covers.length)] - }; + let meta = {}; + if (this.prefs.newPageIcon === 'random') meta.icon = PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)]; + if (this.prefs.newPageCover === 'random') meta.cover = covers[Math.floor(Math.random() * covers.length)]; let body = ''; let title = 'Untitled'; @@ -199,6 +353,7 @@ class Store { async removePage(id = this.currentId) { if (!id) return; const page = this.pages.find((p) => p.id === id); + if (this.prefs.confirmTrash && !confirm(`Move “${page ? page.title : id}” to the trash?`)) return; const parent = page ? page.parent : null; const within = (x) => x === id || x.startsWith(id + '/'); const count = this.pages.filter((p) => within(p.id)).length; @@ -220,36 +375,119 @@ class Store { else { this.doc = null; this.currentId = null; } } - async renameTag(oldTag, newTag) { - if (!oldTag || !newTag || oldTag === newTag) return; + // Several pages at once (a selection of database records), as one step: one toast, one Undo. + async removePages(ids) { + ids = [...new Set(ids)].filter((id) => !this.currentId || id !== this.currentId); + if (!ids.length) return; + if (this.prefs.confirmTrash && !confirm(ids.length === 1 ? 'Move this page to the trash?' : `Move ${ids.length} pages to the trash?`)) return; await this.flush(); - let updated = 0; + const entries = []; try { - for (const page of this.pages) { - if (page.tags && page.tags.includes(oldTag)) { - const r = await api.read(page.id); - const meta = r.meta || {}; - const tags = meta.tags || []; - const idx = tags.indexOf(oldTag); - if (idx !== -1) { - tags[idx] = newTag; - meta.tags = tags; - await api.write(page.id, meta, r.body); - updated++; - } - } - } - if (updated > 0) { - if (this.filterTag === oldTag) this.filterTag = newTag; - await this.refresh(); - if (this.currentId) { - const r = await api.read(this.currentId); - this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version }; - } - toast(`Renamed tag across ${updated} page${updated === 1 ? '' : 's'}`); - } + for (const id of ids) { const e = await api.remove(id); if (e) entries.push(e); } } catch (err) { - toast('Could not rename tag: ' + errorText(err), 'x'); + toast(errorText(err), 'x'); + } + await this.refresh(); + if (!entries.length) return; + const undo = { + label: 'Undo', + run: async () => { + try { for (const e of entries) await api.restore(e); await this.refresh(); toast('Restored', 'check'); } catch (err) { toast('Could not restore: ' + errorText(err), 'x'); } + } + }; + toast(entries.length === 1 ? 'Moved to the trash' : `Moved ${entries.length} pages to the trash`, 'trash', undo); + } + + // The colour of a tag (null removes it). + setTagColor(tag, color) { + const next = { ...this.tagColors }; + if (color) next[tag] = color; + else delete next[tag]; + this.tagColors = next; + this.#saveLists(); + } + + // Rename a tag in every page; into a tag that exists, it merges. An empty new name removes the tag. + async renameTag(oldTag, newTag = '') { + const to = String(newTag || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-'); + if (!oldTag || to === oldTag) return; + await this.flush(); + try { + const n = await api.renameTag(oldTag, to); + if (this.filterTag === oldTag) this.filterTag = to || null; + if (this.tagColors[oldTag]) { + if (to && !this.tagColors[to]) this.setTagColor(to, this.tagColors[oldTag]); + this.setTagColor(oldTag, null); + } + await this.refresh(); + if (this.currentId) { + const r = await api.read(this.currentId); + this.#base = canon(r.meta, r.body); + this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version }; + } + toast(`${to ? 'Renamed' : 'Removed'} #${oldTag} in ${n} page${n === 1 ? '' : 's'}`); + } catch (err) { + toast('Could not change the tag: ' + errorText(err), 'x'); + } + } + deleteTag(tag) { return this.renameTag(tag, ''); } + + /* bringing notes in and out */ + async importNotes(kind) { + try { + await this.flush(); + const r = await api.importNotes(kind); + if (!r) return; + await this.refresh(); + this.open[r.folder] = true; + toast(`Imported ${r.notes} note${r.notes === 1 ? '' : 's'}${r.files ? ` and ${r.files} file${r.files === 1 ? '' : 's'}` : ''} into “${r.folder}”`, 'download'); + if (this.pages.some((p) => p.id === r.folder)) await this.openPage(r.folder); + } catch (err) { + toast('Could not import: ' + errorText(err), 'x'); + } + } + async exportVaultZip() { + try { + await this.flush(); + const r = await api.exportVaultZip(); + if (r) toast(`Saved ${r.count} files to a zip`, 'save'); + } catch (err) { + toast('Could not export: ' + errorText(err), 'x'); + } + } + // A web page saved as a note: its main text as Markdown, with the address in front matter. + async clipWeb(address) { + const url = String(address || '').trim(); + if (!url) return; + try { + await this.flush(); + const page = await api.clipPage(/^[a-z]+:\/\//i.test(url) ? url : 'https://' + url); + const { title, markdown } = articleOf(page.html, { base: page.url }); + const name = (title || new URL(page.url).hostname).slice(0, 120); + const id = await api.create(null, name, { icon: 'globe', source: page.url }, markdown); + await this.refresh(); + await this.openPage(id); + toast('Clipped “' + name + '”', 'check'); + } catch (err) { + toast('Could not clip that page: ' + errorText(err), 'x'); + } + } + + // A copy of a page, board or canvas beside it (with its sub-pages), opened. + async duplicatePage(id) { + await this.flush(); + try { + const next = await api.duplicate(id); + await this.refresh(); + this.fresh = next; + await this.openPage(next); + setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500); + toast('Duplicated', 'copy', { + label: 'Undo', + run: async () => { await this.removePage(next); await this.openPage(id); } + }); + } catch (err) { + toast('Could not duplicate: ' + errorText(err), 'x'); } } @@ -260,9 +498,12 @@ class Store { if (title === page.title) return; await this.settle(); try { - await this.#relocated(id, await api.rename(id, title)); + const next = await api.rename(id, title); + await this.#relocated(id, next); + return next; // the new id (the file name may differ from `title`) } catch (err) { toast('Could not rename: ' + errorText(err), 'x'); + return null; } } @@ -295,28 +536,114 @@ class Store { #saveLists() { if (!this.vault) return; - this.#vaults = { ...this.#vaults, [this.vault.path]: { pins: $state.snapshot(this.pins), recent: $state.snapshot(this.recent) } }; + this.#vaults = { ...this.#vaults, [this.vault.path]: { pins: $state.snapshot(this.pins), recent: $state.snapshot(this.recent), tagColors: $state.snapshot(this.tagColors), dailyFolder: this.dailyFolder, searches: $state.snapshot(this.searches) } }; this.#persist({ vaults: this.#vaults }); } - /* daily note: Daily/YYYY-MM-DD */ - async openDaily() { - const d = new Date(); - const z = (n) => String(n).padStart(2, '0'); - const id = `Daily/${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; + /* saved searches: a query kept in the sidebar; opening one runs it in the search palette */ + isSearchSaved(q) { const t = String(q || '').trim().toLowerCase(); return this.searches.some((s) => s.q.toLowerCase() === t); } + saveSearch(q) { + const t = String(q || '').trim(); + if (!t || this.isSearchSaved(t)) return; + this.searches = [...this.searches, { q: t }]; + this.#saveLists(); + toast('Search saved to the sidebar', 'search'); + } + removeSearch(q) { + this.searches = this.searches.filter((s) => s.q !== q); + this.#saveLists(); + } + runSearch(q) { + this.paletteQuery = q; + this.paletteOpen = true; + } + + /* quick capture (see electron/main.cjs): text from the capture window, added to today's note or to Inbox */ + async takeCaptures() { + try { + for (const c of (await api.takeCaptures()) || []) await this.#capture(c); + } catch (err) { + toast('Could not capture: ' + errorText(err), 'x'); + } + } + async #capture({ text, to }) { + await this.flush(); + await this.refresh(); + let id, label; + if (to === 'inbox') { + id = 'Inbox'; + label = 'Inbox'; + if (!this.pages.some((p) => p.id === id && p.exists)) await api.write(id, { icon: 'inbox', created: dailyId('', new Date()).split('/').pop() }, ''); + } else { + id = (await this.#ensureDaily()).id; + label = "today's note"; + } + const r = await api.read(id); + const line = text.includes('\n') ? text : '- ' + text; + const base = String(r.body || '').replace(/\s+$/, ''); + const tight = !text.includes('\n') && /^\s*[-*+] /.test(base.slice(base.lastIndexOf('\n') + 1)); + await api.write(id, r.meta, (base ? base + (tight ? '\n' : '\n\n') : '') + line + '\n'); + await this.refresh(); + if (this.doc && this.doc.id === id) await this.#external([id]); + toast('Added to ' + label, 'check'); + } + + /* daily note: /YYYY-MM-DD */ + // The folder daily notes go in: the one chosen in Settings, else found (see daily.js). + get dailyFolderNow() { return this.dailyFolder || autoFolder(this.pages); } + setDailyFolder(text) { + const folder = cleanFolder(text); + this.dailyFolder = folder || null; + this.#saveLists(); + } + // Today's note, made (with its folder) if it isn't there yet: { id, folder, fresh }. + async #ensureDaily(date = null) { + const folder = this.dailyFolderNow; + const now = date || new Date(); + const id = dailyId(folder, now, this.prefs.dateFormat, this.prefs.locale); + const day = dailyId('', now).slice(dailyId('', now).lastIndexOf('/') + 1); // always YYYY-MM-DD: it is the page's created date + if (!this.pages.some((p) => p.id === folder && p.exists)) { + await api.write(folder, { icon: 'book', cover: 'sand', created: day }, ''); + } + const fresh = !this.pages.some((p) => p.id === id && p.exists); + if (fresh) { + const covers = Object.keys(COVERS); + await api.write(id, { icon: 'cup', cover: covers[Math.floor(Math.random() * covers.length)], created: day }, dailyBody(this.prefs.dailyTemplate, now, this.prefs.dateFormat, this.prefs.locale)); + await this.refresh(); + } + return { id, folder, fresh }; + } + // The daily notes that exist: { 'YYYY-MM-DD' -> page id }, for the calendar and the previous / next buttons. + get dailyDays() { + const folder = this.dailyFolderNow; + const out = new Map(); + for (const p of this.pages) { + if (!p.exists || p.parent !== folder || !/^\d{4}-\d{2}-\d{2}$/.test(p.created || '')) continue; + const d = new Date(p.created + 'T12:00:00'); + if (formatDate(d, this.prefs.dateFormat, this.prefs.locale) === p.title) out.set(p.created, p.id); + } + return out; + } + get currentDay() { + for (const [day, id] of this.dailyDays) if (id === this.currentId) return day; + return null; + } + // The nearest daily note before (-1) or after (+1) the one open, or today's when you go past the last one. + stepDaily(dir) { + const days = [...this.dailyDays.keys()].sort(); + const cur = this.currentDay; + if (!cur) return; + const next = dir < 0 ? days.filter((d) => d < cur).pop() : days.find((d) => d > cur); + if (next) return this.openPage(this.dailyDays.get(next)); + if (dir > 0) return this.openDaily(); + toast('That is the first daily note', 'calendar'); + } + async openDaily(date = null) { try { await this.flush(); await this.refresh(); - if (!this.pages.some((p) => p.id === 'Daily' && p.exists)) { - await api.write('Daily', { icon: 'book', cover: 'sand', created: id.slice(6) }, ''); - } - const fresh = !this.pages.some((p) => p.id === id && p.exists); - if (fresh) { - const covers = Object.keys(COVERS); - await api.write(id, { icon: 'cup', cover: covers[Math.floor(Math.random() * covers.length)], created: id.slice(6) }, ''); - await this.refresh(); - } - this.open.Daily = true; + const { id, folder, fresh } = await this.#ensureDaily(date); + this.open[folder] = true; this.filterTag = null; await this.openPage(id); if (fresh) requestAnimationFrame(() => this.editor?.focusFirst()); @@ -331,6 +658,19 @@ class Store { const page = this.pages.find((p) => p.id === id); if (!page || page.parent === parent || parent === id || (parent && parent.startsWith(id + '/'))) return; await this.settle(); + // A record that leaves its database keeps its property values in the file: say so, and offer to clean up. + const from = page.parent && this.pages.find((p) => p.id === page.parent && p.db); + const into = parent && this.pages.find((p) => p.id === parent && p.db); + let left = []; // names and keys of the old database's properties that this record has values for + if (from) { + try { + const has = new Set(Object.keys(page.props || {})); + const old = await this.readSchema(from.id); + const now = into ? await this.readSchema(into.id) : null; + const kept = new Set(((now && now.properties) || []).map((p) => p.key)); + left = ((old && old.properties) || []).filter((p) => has.has(p.key) && !kept.has(p.key)).map((p) => ({ key: p.key, name: p.name })); + } catch { /* not a database after all */ } + } try { const next = await api.move(id, parent); if (parent) this.open[parent] = true; @@ -338,12 +678,37 @@ class Store { this.fresh = next; setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500); const to = parent && this.pages.find((p) => p.id === parent); - toast(to ? `Moved into ${to.title}` : 'Moved to the top level', 'folder'); + const base = to ? `Moved into ${to.title}` : 'Moved to the top level'; + const undo = { label: 'Undo', run: () => this.movePage(next, page.parent) }; + if (!left.length) { toast(base, 'folder', undo); return; } + const n = left.length; + const what = `${n} propert${n === 1 ? 'y' : 'ies'} from ${from.title} (${left.map((p) => p.name).slice(0, 3).join(', ')}${n > 3 ? '…' : ''})`; + toast(`${base}. ${into ? `${what} ${n === 1 ? 'isn’t' : 'aren’t'} in ${into.title}` : `${what} stayed in the file`}`, 'folder', { + label: 'Remove', + run: () => this.dropProps(next, left.map((p) => p.key)) + }); } catch (err) { toast('Could not move: ' + errorText(err), 'x'); } } + // Take property values (front matter keys) off a page. + async dropProps(id, keys) { + try { + if (this.currentId === id) await this.flush(); + const r = await api.read(id); + if (!r.exists) return; + const meta = { ...(r.meta || {}) }; + for (const k of keys) delete meta[k]; + await api.write(id, meta, r.body); + await this.refresh(); + if (this.currentId === id) await this.openPage(id); + toast(`Removed ${keys.length} propert${keys.length === 1 ? 'y' : 'ies'}`, 'check'); + } catch (err) { + toast('Could not remove: ' + errorText(err), 'x'); + } + } + // Bookkeeping after page `id` became `next`, together with everything under it. async #relocated(id, next) { if (next === id) return; @@ -353,6 +718,7 @@ class Store { this.open = open; this.pins = this.pins.map(move); this.recent = this.recent.map(move); + this.navHistory = this.navHistory.map((e) => ({ ...e, id: move(e.id) })); this.#saveLists(); await this.refresh(); this.#persist({ open: $state.snapshot(this.open) }); @@ -416,7 +782,9 @@ class Store { if (id === this.currentId) return { state: 'self', id, title }; const r = await api.read(id); if (!r.exists) return { state: 'missing', name: title }; + if (isDatabase(r.meta)) return { state: 'database', id, title, view: heading, body: r.body }; if (isBoard(r.meta)) return { state: 'board', id, title }; + if (isCanvasId(id)) return { state: 'canvas', id, title }; let blocks = parseMarkdown(r.body); if (heading) { const section = sectionOf(blocks, heading); @@ -427,13 +795,16 @@ class Store { } // Make a top-level page for a link to something that doesn't exist yet. Returns its id. - async createLinked(title) { + createLinked(title) { return this.createNote(null, title); } + + // Make a page (inside `parent`, or at the top) with some text in it, without opening it. Returns its id. + async createNote(parent, title, body = '') { const covers = Object.keys(COVERS); try { - const id = await api.create(null, title || 'Untitled', { + const id = await api.create(parent || null, title || 'Untitled', { icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)], cover: covers[Math.floor(Math.random() * covers.length)] - }); + }, body); await this.refresh(); return id; } catch (err) { @@ -449,6 +820,32 @@ class Store { await this.renamePage(d.id, text); } + /* canvases */ + get isCanvas() { return isCanvasId(this.doc?.id); } + + // Canvas `id` changed: save its file text. (Ignored if another page is open by now.) + editCanvas(id, text) { + if (!this.isCanvas || this.doc.id !== id) return; + this.#raw = text; + this.#dirty = true; + this.#schedule(); + } + + async newCanvas(parent = null) { + await this.settle(); + try { + const id = await api.createCanvas(parent, 'Untitled canvas'); + if (parent) this.open[parent] = true; + this.filterTag = null; + await this.refresh(); + this.fresh = id; + await this.openPage(id); + setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500); + } catch (err) { + toast(errorText(err), 'x'); + } + } + /* boards */ get isBoard() { return isBoard(this.doc?.meta); } @@ -485,6 +882,152 @@ class Store { toast(on ? 'Showing as a board' : 'Showing as a document', on ? 'board' : 'file'); } + /* databases + A database is a page whose sub-pages are its records; the schema (properties, views) is in its body and each + record keeps its property values in its own front matter. See database.js. */ + get isDatabase() { return isDatabase(this.doc?.meta); } + + // The schema changed: save the database page's body. + editDatabase(body) { + this.#raw = body; + this.#dirty = true; + this.#schedule(); + } + + async newDatabase(parent = null) { + await this.flush(); + try { + const id = await api.create(parent, 'Database', { icon: 'table', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [DB_KEY]: 'basic' }, databaseToMd(defaultDatabase())); + if (parent) this.open[parent] = true; + this.filterTag = null; + await this.refresh(); + this.fresh = id; + await this.openPage(id); + setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500); + } catch (err) { + toast(errorText(err), 'x'); + } + } + + // The records of database `id`: its sub-pages that are files (not canvases, not folders without a page). + records(id) { return this.pages.filter((p) => p.parent === id && p.exists && p.kind !== 'canvas'); } + + // A new record in database `id`; `props` are stored values (see database.write). Returns the record's id. + async addRecord(id, title = 'Untitled', props = {}) { + try { + await this.flush(); + const rid = await api.create(id, title, { ...props }, ''); + await this.refresh(); + return rid; + } catch (err) { + toast(errorText(err), 'x'); + return null; + } + } + + #recordChain = Promise.resolve(); + // Set property values on a record (a patch of key -> stored value; '' or [] removes the key). Edits are + // applied one after another so two quick changes to the same record don't undo each other. + setRecordProps(id, patch) { + const p = this.pages.find((x) => x.id === id); + if (p) { + const props = { ...(p.props || {}) }; + for (const [k, v] of Object.entries(patch)) { + if (v === '' || v === undefined || (Array.isArray(v) && !v.length)) delete props[k]; + else props[k] = v; + } + p.props = props; + } + this.#recordChain = this.#recordChain.then(async () => { + try { + const r = await api.read(id); + if (!r.exists) return; + const meta = { ...(r.meta || {}), ...patch }; + for (const k of Object.keys(patch)) if (patch[k] === '' || patch[k] === undefined) delete meta[k]; + await api.write(id, meta, r.body); + } catch (err) { + toast('Could not save: ' + errorText(err), 'x'); + } + }); + return this.#recordChain; + } + + // Another page's database schema (read from disk): for a record's page, which shows its parent's properties. + async readSchema(id) { + const r = await api.read(id); + return isDatabase(r.meta) ? parseDatabase(r.body) : null; + } + async saveSchema(id, db) { + const r = await api.read(id); + await api.write(id, r.meta || {}, databaseToMd(db)); + } + + /* CSV in and out */ + // The open database's records as a CSV file (all of them, every property). + async exportCsv() { + const d = this.doc; + if (!d || !this.isDatabase) return; + await this.flush(); + try { + const schema = parseDatabase(this.#raw ?? d.body); + const csv = databaseCsv(schema, withFormulas(this.records(d.id), schema.properties)); + const where = await api.saveExport(`${d.title}.csv`, csv); + if (where) toast('Exported ' + this.records(d.id).length + ' records', 'export'); + } catch (err) { + toast('Could not export: ' + errorText(err), 'x'); + } + } + + // Choose a CSV file and open the import dialog for it. target: a database id, or null for a new database. + pickCsv(target = null) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.csv,.tsv,.txt,text/csv,text/tab-separated-values'; + input.onchange = async () => { + const f = input.files && input.files[0]; + if (!f) return; + try { + const text = await f.text(); + if (!parseCsv(text).rows.length) { toast('That file has no rows', 'x'); return; } + this.csvImport = { target, name: f.name.replace(/\.[^.]+$/, ''), text }; + } catch (err) { + toast('Could not read that file: ' + errorText(err), 'x'); + } + }; + input.click(); + } + + // Create what a planned import describes (see csv.planImport). Returns the database's id. + async importCsv(plan, { target, name }) { + await this.flush(); + try { + let dbId = target; + if (target) { + const s = await this.readSchema(target); + if (!s) throw new Error('That page is not a database.'); + for (const p of plan.properties) s.properties.push(p); + for (const [key, add] of Object.entries(plan.options)) { + const p = s.properties.find((x) => x.key === key); + if (p) p.options = [...new Set([...(p.options || []), ...add])]; + } + await this.saveSchema(target, s); + } else { + const views = [newView('table', 'Table')]; + const date = plan.properties.find((p) => p.type === 'date'); + if (date) views.push({ ...newView('calendar', 'Calendar'), dateProp: date.key }); + dbId = await api.create(null, name || 'Imported', { icon: 'table', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [DB_KEY]: 'basic' }, databaseToMd({ properties: plan.properties, views, rest: '' })); + } + await api.createMany(dbId, plan.items); + await this.refresh(); + if (this.currentId === dbId || !target) await this.openPage(dbId); + toast(`Imported ${plan.items.length} record${plan.items.length === 1 ? '' : 's'}`, 'check'); + return dbId; + } catch (err) { + toast('Could not import: ' + errorText(err), 'x'); + return null; + } + } + async newBoard(parent = null) { await this.flush(); try { @@ -533,6 +1076,7 @@ class Store { // Save, and bring links to renamed headings up to date. Used when you leave the page or the window. async settle() { + await this.splitSave?.(); await this.flush(); await this.#commitHeadingRenames(); } @@ -585,6 +1129,49 @@ class Store { } } + /* split view */ + openSplit(id) { + if (!id) return; + if (id === this.currentId) { toast('That page is already open on the left', 'columns'); return; } + this.view = 'page'; + this.split = id; + } + async closeSplit() { + await this.splitSave?.(); + this.split = null; + } + // The side page moves to the main pane and the main page takes its place. + async swapSplit() { + const side = this.split, main = this.currentId; + if (!side || !main) return; + await this.closeSplit(); + await this.openPage(side); + this.split = main; + } + // A page dragged from the sidebar onto the right half goes to the side pane; onto the left half it takes the + // main pane and the page that was there moves to the side. + async dropToSide(id, side) { + if (id === this.currentId) return; + if (side === 'right') { this.openSplit(id); return; } + const was = this.currentId; + await this.splitSave?.(); + this.split = null; + await this.openPage(id); + if (was) this.split = was; + } + // Ctrl/⌘ \ with a side pane open closes it; otherwise the page you visited before this one opens there. + toggleSplit() { + if (this.split) { this.closeSplit(); return; } + const other = this.recent.find((id) => id !== this.currentId && this.pages.find((p) => p.id === id && p.exists)); + if (other) this.openSplit(other); + else toast('Right-click a page in the sidebar and choose “Open in split”', 'columns'); + } + + rememberSlash(kind) { + this.slashRecent = [kind, ...this.slashRecent.filter((t) => t !== kind)].slice(0, 5); + this.#persist({ slashRecent: this.slashRecent }); + } + toggleFocusMode() { this.focusMode = !this.focusMode; this.#persist({ focusMode: this.focusMode }); @@ -600,7 +1187,20 @@ class Store { if (!d) return; this.doc = { ...d, meta: { ...d.meta, ...patch } }; const p = this.current; - if (p) for (const k of ['icon', 'cover', 'tags']) if (k in patch) p[k] = patch[k]; + if (p) { + for (const k of ['icon', 'cover', 'tags']) if (k in patch) p[k] = patch[k]; + // other front matter keys are a record's property values, as the database shows them + const rest = Object.keys(patch).filter((k) => !CORE_KEYS.includes(k)); + if (rest.length) { + const props = { ...(p.props || {}) }; + for (const k of rest) { + const v = patch[k]; + if (v === '' || v === undefined || (Array.isArray(v) && !v.length)) delete props[k]; + else props[k] = v; + } + p.props = props; + } + } this.#dirty = true; this.#schedule(); } @@ -608,7 +1208,7 @@ class Store { #schedule() { this.saving = true; clearTimeout(this.#timer); - this.#timer = setTimeout(() => this.flush(), 600); + this.#timer = setTimeout(() => this.flush(), this.prefs.autosaveDelay); } flush() { @@ -623,9 +1223,28 @@ class Store { this.#dirty = false; const body = this.#raw ?? toMarkdown(this.#blocks); try { + // Someone else (another editor, a sync tool) may have changed the file since this window read it. Saving + // would silently replace their text, so their version is put in version history first and you are told. + const mine = canon(d.meta, body); + if (this.#base !== null) { + const disk = await api.read(d.id).catch(() => null); + if (disk && disk.exists) { + const there = canon(disk.meta, disk.body); + if (there !== this.#base && there !== mine) { + await api.keepVersion(d.id).catch(() => {}); + toast('This note was also changed outside Ink. Your version was saved; the other one is in version history.', 'clock', { + label: 'Show versions', + run: () => { this.historyOpen = d.id; } + }); + } + } + } await api.write(d.id, { ...d.meta }, body); + this.#base = mine; const p = this.pages.find((x) => x.id === d.id); if (p && !p.exists) p.exists = true; + // what was just saved is what the list should show (a refresh that raced the save may have read the old file) + if (p) this.#syncListEntry(p, d.meta); this.justSaved = true; clearTimeout(this.#justSavedTimer); this.#justSavedTimer = setTimeout(() => { this.justSaved = false; }, 3000); @@ -647,9 +1266,10 @@ class Store { else { this.doc = null; this.currentId = null; } return; } - if (r.body === (this.#raw ?? toMarkdown(this.#blocks)) && isBoard(r.meta) === this.isBoard) return; - this.#blocks = parseMarkdown(r.body); - this.#raw = isBoard(r.meta) ? r.body : null; + if (r.body === (this.#raw ?? toMarkdown(this.#blocks)) && isBoard(r.meta) === this.isBoard && isDatabase(r.meta) === this.isDatabase) return; + this.#raw = rawOf(d.id, r); + this.#base = canon(r.meta, r.body); + this.#blocks = isCanvasId(d.id) ? [] : parseMarkdown(r.body); this.#derive(true); this.doc = { ...d, meta: r.meta || {}, blocks: this.#blocks, body: r.body, version: ++this.#version }; toast('Updated from disk', 'file'); @@ -658,7 +1278,9 @@ class Store { exportText(format) { const d = this.doc; if (!d) return ''; - return format === 'html' ? toHtmlDocument(d.title, this.#blocks) : `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`; + if (format === 'html') return toHtmlDocument(d.title, this.#blocks); + const md = `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`; + return this.prefs.exportLinks === 'markdown' ? wikiToMarkdownLinks(md) : md; } /* ui */ @@ -667,8 +1289,93 @@ class Store { this.#persist({ open: $state.snapshot(this.open) }); } + setQuickLayout(layout) { + this.quickLayout = layout === 'horizontal' ? 'horizontal' : 'vertical'; + this.#persist({ quickLayout: this.quickLayout }); + } + + setTagsOpen(on) { + this.tagsOpen = on; + this.#persist({ tagsOpen: on }); + } + + // Hover reveal for a hidden sidebar, driven by where the mouse is: + // - touching the thin edge strip opens it at once; + // - it stays while the mouse is over it (and a few pixels around it, so slipping off its border or into the gap + // beside it is fine); + // - once the mouse is clearly away it closes: quickly if it never got into the sidebar (just brushed the edge), + // after a short grace if it did. Coming back before that cancels it. + // It also stays while something of the sidebar is in use (a menu, a drag, a rename). + static PEEK_EDGE = 7; // trigger strip width (px) + static PEEK_LEFT = 10; // where the floating sidebar starts, and + static PEEK_RIGHT = 10 + 268; // where it ends + static PEEK_SLACK = 14; // a little beyond its right edge still counts as on it + static PEEK_TOP = 44; // below the window's drag bar + #closeTimer = null; + #peekEntered = false; // the mouse has been in the sidebar since it opened + #peekLocked = false; // closed with Escape while the mouse is still on the edge: stays closed until it moves off + #leftWindow = false; // the pointer went out of the window with the sidebar floating in + #canPeek() { return this.collapsed && !this.focusMode && !isMobile(); } + #peekBusy() { + return !!(this.menu || this.textMenu || this.renaming || document.body.classList.contains('page-dragging') || + document.querySelector('.sidebar input:focus')); + } + #cancelClose() { clearTimeout(this.#closeTimer); this.#closeTimer = null; } + // The pointer is in the trigger strip (pointerenter on it, or a move that lands there): open at once. + peekOn() { + if (this.#peekLocked || !this.#canPeek()) return; + this.#cancelClose(); + if (!this.peek) { this.peek = true; this.#peekEntered = false; } + } + // The pointer left the window (or the window lost focus) with the sidebar floating in: no more pointer moves will + // arrive to close it, so it goes after a short wait (long enough to find your way back, not to hang around). + peekWindowLeft() { + if (!this.peek) return; + this.#cancelClose(); + this.#leftWindow = true; + const tryClose = () => { + if (this.#peekBusy()) { this.#closeTimer = setTimeout(tryClose, 300); return; } + this.#closeTimer = null; + this.peek = false; + }; + this.#closeTimer = setTimeout(tryClose, 1500); + } + // Every pointer move. + peekMoved(x, y) { + if (this.#leftWindow) { this.#leftWindow = false; this.#cancelClose(); } // back in the window: judged by position again + if (this.#peekLocked) { if (x > 48) this.#peekLocked = false; return; } + if (!this.#canPeek()) return; + if (!this.peek) { + if (x <= Store.PEEK_EDGE && y >= Store.PEEK_TOP) this.peekOn(); + return; + } + if (x >= Store.PEEK_LEFT && x <= Store.PEEK_RIGHT) this.#peekEntered = true; + if (x <= Store.PEEK_RIGHT + Store.PEEK_SLACK) { this.#cancelClose(); return; } + if (this.#closeTimer) return; + const tryClose = () => { + if (this.#peekBusy()) { this.#closeTimer = setTimeout(tryClose, 300); return; } + this.#closeTimer = null; + this.peek = false; + }; + this.#closeTimer = setTimeout(tryClose, this.#peekEntered ? 400 : 150); + } + // Escape: closed now, and not opened again until the mouse has moved off the edge. + peekEscape() { + this.#cancelClose(); + this.peek = false; + this.#peekLocked = true; + } + toggleSidebar() { + this.#cancelClose(); + this.peek = false; if (isMobile()) { this.drawer = !this.drawer; return; } + if (this.focusMode) { + this.toggleFocusMode(); + this.collapsed = false; + this.#persist({ sidebarCollapsed: this.collapsed }); + return; + } this.collapsed = !this.collapsed; this.#persist({ sidebarCollapsed: this.collapsed }); } @@ -694,11 +1401,17 @@ class Store { this.paletteOpen = false; this.findOpen = 0; this.drawer = false; + if (!this.#isNavigating) { + this.#pushNav({ id: this.currentId, view: 'graph' }); + } } closeGraph() { // The page editor was unmounted meanwhile: remount it from the latest content, not what was first loaded. if (this.doc) this.doc = { ...this.doc, blocks: this.#blocks, body: this.#raw ?? this.doc.body, version: ++this.#version }; this.view = 'page'; + if (!this.#isNavigating && this.currentId) { + this.#pushNav({ id: this.currentId, view: 'page' }); + } } setGraph(patch) { this.graph = { ...this.graph, ...patch }; @@ -713,6 +1426,7 @@ class Store { this.theme = t; document.documentElement.dataset.theme = t; if (persist) { this.themePref = t; this.#persist({ theme: t }); } + this.#rememberGround(); } // Choose 'dark', 'light' or 'system' (follows the desktop). `origin`: element the theme spreads out from. @@ -759,6 +1473,118 @@ class Store { if (persist) this.#persist({ pageWidth: this.pageWidth }); } + static FONTS = { + sans: 'var(--f-ui)', + serif: "'Iowan Old Style','Palatino Linotype',Palatino,Georgia,'Noto Serif',serif", + mono: 'var(--f-mono)' + }; + static SPACING = { compact: '1.5', default: '1.7', relaxed: '1.95' }; + + setPageFont(f, persist = true) { + this.pageFont = Store.FONTS[f] ? f : 'sans'; + this.#applyFonts(); + if (persist) this.#persist({ pageFont: this.pageFont }); + } + + setLineSpacing(v, persist = true) { + this.lineSpacing = Store.SPACING[v] ? v : 'default'; + document.documentElement.style.setProperty('--lh', Store.SPACING[this.lineSpacing]); + if (persist) this.#persist({ lineSpacing: this.lineSpacing }); + } + + static PALETTES = ['default', 'midnight', 'nord', 'forest', 'sepia', 'catppuccin', 'catppuccin-dark', 'amoled']; + + setPalette(name, persist = true) { + this.palette = Store.PALETTES.includes(name) ? name : 'default'; + if (this.palette === 'default') document.documentElement.removeAttribute('data-palette'); + else document.documentElement.dataset.palette = this.palette; + if (persist) { this.#persist({ palette: this.palette }); this.#rememberGround(); } + } + + // The window's background before the page loads, so launch doesn't flash the wrong color. + #rememberGround() { + requestAnimationFrame(() => { + const bg = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim(); + if (bg) this.#persist({ ground: bg }); + }); + } + + // Change one or more of the settings in prefs.js. + setPrefs(patch) { + this.prefs = cleanPrefs({ ...$state.snapshot(this.prefs), ...patch }); + this.#applyPrefs(); + this.#persist({ prefs: $state.snapshot(this.prefs) }); + } + + // Everything in Settings back to how it came (the notes folder and page contents are not touched). + resetSettings() { + this.setThemePref('dark'); + this.setFontSize(Store.FONT.base); + this.setPageWidth('default'); + this.setQuickLayout('vertical'); + this.setPageFont('sans'); + this.setLineSpacing('default'); + this.setPalette('default'); + this.setAccent('blush'); + this.setReduceMotion(false); + this.setStartPage('last'); + this.setGraph({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, tags: false, spacing: 1 }); + this.prefs = { ...DEFAULTS }; + this.#applyPrefs(); + this.#persist({ prefs: { ...DEFAULTS } }); + } + + // The prefs that are only CSS: variables and classes on the root element. + // Page and code fonts: the choice in Settings, with an installed font typed by name in front of it. + #applyFonts() { + const root = document.documentElement; + const clean = (t) => String(t || '').replace(/[^\p{L}\p{N} _.-]/gu, '').trim(); + const page = clean(this.prefs?.customPageFont); + root.style.setProperty('--f-page', page ? `"${page}", ${Store.FONTS[this.pageFont]}` : Store.FONTS[this.pageFont]); + const code = clean(this.prefs?.customCodeFont); + if (code) root.style.setProperty('--f-mono', `"${code}", 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace`); + else root.style.removeProperty('--f-mono'); + } + + #applyPrefs() { + const p = this.prefs, root = document.documentElement; + this.#applyFonts(); + const fonts = { + inter: "'Inter Variable','Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif", + system: "system-ui,-apple-system,'Segoe UI',Roboto,'Noto Sans',sans-serif", + mono: "'JetBrains Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace" + }; + root.style.setProperty('--f-ui', fonts[p.uiFont]); + root.style.setProperty('--side-w', p.sidebarWidth + 'px'); + root.style.setProperty('--code-scale', String(p.codeSize / 100)); + root.dataset.density = p.density; + root.classList.toggle('hide-cover', !p.showCover); + root.classList.toggle('hide-icon', !p.showIcon); + root.classList.toggle('hide-backlinks', !p.showBacklinks); + root.classList.toggle('code-wrap', p.codeWrap); + root.classList.toggle('typewriter', p.typewriter); + } + + static ACCENTS = ['blush', 'amber', 'sage', 'sky', 'violet']; + + setAccent(name, persist = true) { + this.accent = Store.ACCENTS.includes(name) ? name : 'blush'; + if (this.accent === 'blush') document.documentElement.removeAttribute('data-accent'); + else document.documentElement.dataset.accent = this.accent; + if (persist) this.#persist({ accent: this.accent }); + } + + setReduceMotion(on, persist = true) { + this.reduceMotion = !!on; + document.documentElement.classList.toggle('reduce-motion', this.reduceMotion); + if (persist) this.#persist({ reduceMotion: this.reduceMotion }); + } + + setStartPage(v) { + this.startPage = v === 'today' ? 'today' : 'last'; + this.#persist({ startPage: this.startPage }); + } + #persist(patch) { Promise.resolve(api.setSettings(patch)).catch(() => {}); } diff --git a/src/lib/tagColors.js b/src/lib/tagColors.js new file mode 100644 index 0000000..882f067 --- /dev/null +++ b/src/lib/tagColors.js @@ -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; diff --git a/src/lib/text.js b/src/lib/text.js new file mode 100644 index 0000000..d9cb06e --- /dev/null +++ b/src/lib/text.js @@ -0,0 +1,28 @@ +export const esc = (s) => + String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); + +export const unesc = (s) => + s.replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + +// Plain text of an HTML fragment without a DOM (works in tests too). +export const htmlText = (h) => unesc(String(h || '').replace(//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)) + '' + esc(s.slice(j, j + q.length)) + ''; + i = j + q.length; + } + return out + esc(s.slice(i)); +} + +export const countWords = (t) => (String(t).trim().match(/\S+/g) || []).length; diff --git a/src/lib/toast.svelte.js b/src/lib/toast.svelte.js new file mode 100644 index 0000000..c2ffd01 --- /dev/null +++ b/src/lib/toast.svelte.js @@ -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(); +}