feat(app): editor engine, markdown, store and domain modules

Block editor, markdown round trip, formulas/databases/kanban/canvas
models, graph simulation (time-budgeted ticks for big vaults), shortcut
registry, dialog focus handling, HTML to Markdown for paste and clipping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 313c41f867
commit c0587cb267
29 files changed
+5274 -169

No files matched your search

+307
View File
@@ -0,0 +1,307 @@
// Browser-only stand-in for the Electron bridge. Same method names and shapes as preload.cjs.
// Keeps notes in memory (and localStorage when available) so `npm run dev:web` is usable.
import { resolveLink } from './links.js';
const KEY = 'notes-web-mock-v1';
const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
const today = () => new Date().toISOString().slice(0, 10);
const SEED = {
'Getting started': {
meta: { icon: 'sparkle', cover: 'blush', tags: ['guide'], created: today() },
body: `> [!note]
> This is the browser preview. In the desktop app every page is a .md file in your notes folder.
## Write the way you think
- Type \`/\` on any line to add a heading, to-do, quote, toggle or code block.
- Start a line with \`#\`, \`-\`, \`[]\` or \`>\` and it turns into that block as you type.
- Press <kbd>Ctrl</kbd> <kbd>K</kbd> to search every note.
- [x] Pick a folder for your notes
- [ ] Write one page, however short
`
},
Projects: { meta: { icon: 'compass', cover: 'sage', created: today() }, body: 'Things in motion.\n' },
'Projects/Note app roadmap': {
meta: { icon: 'code', cover: 'dusk', tags: ['dev', 'todo'], created: today() },
body: `An offline notes app built on Electron and SvelteKit.
### v0.1
- [x] Block editor with a slash menu
- [ ] Full-text search with SQLite FTS5
\`\`\`js
const id = await window.vault.create(null, 'Untitled')
\`\`\`
`
}
};
export function createMock() {
let files = null;
let settings = { theme: 'dark' };
try {
const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
if (saved) { files = saved.files; settings = saved.settings || settings; }
} catch { /* storage unavailable */ }
let opened = !!files;
files = files || structuredClone(SEED);
const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ files, settings })); } catch { /* ignore */ } };
const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null);
const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1);
const pages = () => {
const ids = new Set(Object.keys(files));
for (const id of [...ids]) { let p = parentOf(id); while (p) { ids.add(p); p = parentOf(p); } }
return [...ids].map((id) => {
const f = files[id];
return {
id, parent: parentOf(id), title: titleOf(id), exists: !!f, mtime: 0,
icon: f?.meta.icon || null, cover: f?.meta.cover || null, tags: f?.meta.tags || [], created: f?.meta.created || null,
...(f?.meta['ink-database'] ? { db: true } : {}),
props: Object.fromEntries(Object.entries(f?.meta || {}).filter(([k]) => !['icon', 'cover', 'tags', 'created'].includes(k)))
};
});
};
const snap = () => ({ path: '~/Notes', name: 'Notes', pages: pages() });
const free = (parent, base) => {
for (let n = 1; ; n++) {
const id = (parent ? parent + '/' : '') + (n === 1 ? base : `${base} ${n}`);
if (!files[id] && !pages().some((p) => p.id === id)) return id;
}
};
const listeners = new Set();
const images = new Map();
const trash = []; // [{ entry, id, title, icon, pages, deletedAt, gone: { id: file } }], kept for this session
return {
platform: 'web',
async listTemplates() {
return ['Daily Note', 'Meeting'];
},
async duplicate(id) {
const f = files[id];
if (!f) throw new Error('That page has no file to copy.');
let next = id + ' copy';
for (let n = 2; files[next]; n++) next = `${id} copy ${n}`;
files[next] = structuredClone(f);
save();
return next;
},
async saveImageFromUrl() { throw new Error('Adding a picture from a link needs the desktop app.'); },
async pickFile() { throw new Error('Attaching files needs the desktop app.'); },
async openFile() { throw new Error('Opening files needs the desktop app.'); },
async createMany(parent, items) {
const ids = [];
for (const it of items || []) ids.push(await this.create(parent, it.title, it.meta, ''));
return ids;
},
async createCanvas() { throw new Error('Canvases need the desktop app.'); },
async openTemplatesFolder() { return 'The templates folder needs the desktop app.'; },
async getTemplate(name) {
if (name === 'Daily Note') return { meta: { tags: ['daily'] }, body: '# Daily Note\n\n' };
if (name === 'Meeting') return { meta: { tags: ['meeting'] }, body: '# Meeting\n\n' };
return null;
},
async get() { return opened ? snap() : null; },
async list() { return snap(); },
async pick() { opened = true; save(); return snap(); },
async read(id) {
const f = files[id];
return { id, title: titleOf(id), meta: f ? structuredClone(f.meta) : {}, body: f ? f.body : '', exists: !!f, mtime: 0 };
},
async write(id, meta, body) { files[id] = { meta: structuredClone(meta || {}), body: body || '' }; save(); return Date.now(); },
async create(parent, title, meta, body) {
const id = free(parent, (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled');
files[id] = { meta: { created: today(), ...(meta || {}) }, body: body || '' };
save();
return id;
},
async rename(id, title) {
const clean = (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled';
if (clean === titleOf(id)) return id;
const next = free(parentOf(id), clean);
for (const k of Object.keys(files)) {
if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
}
save();
return next;
},
async move(id, parent) {
if ((parent || null) === parentOf(id)) return id;
if (parent && (parent === id || parent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.');
const next = free(parent || null, titleOf(id));
for (const k of Object.keys(files)) {
if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
}
save();
return next;
},
// No version history in the browser preview.
async keepVersion() {},
async listHistory() { return []; },
async readHistory() { throw new Error('Version history needs the desktop app.'); },
async restoreHistory() { throw new Error('Version history needs the desktop app.'); },
async graph() {
const all = pages();
const nodes = all.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon, parent: p.parent, tags: p.tags }));
const edges = [], ghosts = new Map(), seen = new Set();
for (const [id, f] of Object.entries(files)) {
for (const m of f.body.matchAll(/(!?)\[\[([^\[\]\n|#]*)/g)) {
// ![[Page]] embeds count as links; ![[photo.png]] is a file, and [[#Heading]] points at its own page
if ((m[1] && FILE.test(m[2].trim())) || !m[2].trim()) continue;
let to = resolveLink(m[2], all);
if (!to) { to = '?' + m[2].trim().toLowerCase(); ghosts.set(to, { id: to, title: m[2].trim() }); }
if (to !== id && !seen.has(id + to)) { seen.add(id + to); edges.push([id, to]); }
}
}
return { nodes, edges, ghosts: [...ghosts.values()] };
},
// A heading of page `id` was renamed: update [[Page#from]] links and ![[Page#from]] embeds elsewhere (browser preview).
async renameHeading(id, from, to) {
const all = pages();
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const safe = String(to).replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
const touched = [];
let count = 0;
if (!safe) return { pages: touched, count };
for (const [k, f] of Object.entries(files)) {
if (k === id) continue;
let n = 0;
const body = f.body.replace(/(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g, (m, bang, ref, alias) => {
const at = ref.lastIndexOf('#');
if (at < 0 || !ref.slice(0, at).replace(/#.*/, '').trim() || resolveLink(ref, all) !== id || norm(ref.slice(at + 1)) !== norm(from)) return m;
n++;
return `${bang}[[${ref.slice(0, at + 1)}${safe}${alias || ''}]]`;
});
if (n) { f.body = body; touched.push(k); count += n; }
}
if (touched.length) save();
return { pages: touched, count };
},
async tasks() {
const out = [];
for (const [id, f] of Object.entries(files)) {
if (f.meta['kanban-plugin'] || id.endsWith('.canvas')) continue;
f.body.split('\n').forEach((l, line) => {
const m = l.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/);
if (!m) return;
const due = m[2].match(/(?:📅|\bdue:)\s*(\d{4}-\d{2}-\d{2})/);
const tags = new Set(f.meta.tags || []);
for (const t of m[2].matchAll(/(?:^|\s)#([\w/-]+)/g)) tags.add(t[1]);
out.push({ id, title: titleOf(id), icon: f.meta.icon || null, line, done: m[1] !== ' ', text: m[2].trim(), due: due ? due[1] : '', tags: [...tags], depth: 0 });
});
}
return out;
},
async setTask(id, line, text, done) {
const rows = files[id].body.split('\n');
const m = rows[line] && rows[line].match(/^(\s*[-*+]\s+)\[[ xX]\](\s+)(.*)$/);
if (!m || m[3].trim() !== text.trim()) throw new Error('That task changed. Reopen the list.');
rows[line] = m[1] + (done ? '[x]' : '[ ]') + m[2] + m[3];
files[id].body = rows.join('\n');
save();
},
async unlinked(id) {
const title = titleOf(id);
if (title.length < 3) return [];
const all = pages();
const re = new RegExp('(?<![\\p{L}\\p{N}_])' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\p{L}\\p{N}_])', 'iu');
return Object.entries(files)
.filter(([k, f]) => k !== id && re.test(f.body.replace(/\[\[[^\]]*\]\]/g, '')) && ![...f.body.matchAll(/\[\[([^\[\]\n|#]+)/g)].some((m) => resolveLink(m[1], all) === id))
.map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => re.test(l)) || '').trim() }));
},
async linkMention(id, target) {
const title = titleOf(target);
const re = new RegExp('(?<![\\p{L}\\p{N}_])' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\p{L}\\p{N}_])', 'iu');
files[id].body = files[id].body.replace(re, (m) => (m === title ? `[[${title}]]` : `[[${title}|${m}]]`));
save();
},
async openCapture() {},
async takeCaptures() { return []; },
onCapture() { return () => {}; },
async backlinks(id) {
const all = pages();
return Object.entries(files)
.filter(([k, f]) => k !== id && [...f.body.matchAll(/(!?)\[\[([^\[\]\n|]+)/g)].some((m) => !(m[1] && FILE.test(m[2].trim())) && resolveLink(m[2], all) === id))
.map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => l.includes('[[')) || '').replace(/[[\]]/g, '') }));
},
// Images live in memory only here; the desktop app writes them next to the note.
async saveImage(_id, name, bytes) {
const key = `${Date.now().toString(36)}-${name}`;
images.set(key, URL.createObjectURL(new Blob([bytes])));
return key;
},
async pickImage(id) {
const input = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/*' });
const file = await new Promise((done) => { input.onchange = () => done(input.files[0] || null); input.click(); });
return file ? this.saveImage(id, file.name, new Uint8Array(await file.arrayBuffer())) : null;
},
mediaUrl: (_id, src) => images.get(src) || src,
async remove(id) {
const gone = {};
for (const k of Object.keys(files)) if (k === id || k.startsWith(id + '/')) { gone[k] = files[k]; delete files[k]; }
const entry = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
trash.unshift({ entry, id, title: titleOf(id), icon: gone[id]?.meta.icon || null, pages: Object.keys(gone).length, deletedAt: Date.now(), gone });
save();
return entry;
},
async trash() { return trash.map(({ gone, ...t }) => t); },
async restore(entry) {
const t = trash.find((x) => x.entry === entry);
if (!t) throw new Error('Unknown trash item.');
const next = free(parentOf(t.id), titleOf(t.id));
for (const [k, f] of Object.entries(t.gone)) files[next + k.slice(t.id.length)] = f;
trash.splice(trash.indexOf(t), 1);
save();
return next;
},
async purge(entry) {
const keep = entry ? trash.filter((x) => x.entry !== entry) : [];
trash.splice(0, trash.length, ...keep);
},
async search(q) {
const terms = String(q).toLowerCase().match(/[\p{L}\p{N}_]+/gu) || [];
if (!terms.length) return [];
return Object.entries(files)
.filter(([id, f]) => terms.every((t) => (id + ' ' + f.body).toLowerCase().includes(t)))
.slice(0, 20)
.map(([id, f]) => {
const lo = f.body.toLowerCase();
const at = lo.indexOf(terms[0]);
const start = Math.max(0, at - 30);
const snip = at < 0 ? f.body.slice(0, 90)
: (start ? '…' : '') + f.body.slice(start, at) + '\u0001' + f.body.slice(at, at + terms[0].length) + '\u0002' + f.body.slice(at + terms[0].length, at + 70);
return { id, title: titleOf(id), snip };
});
},
async reveal() {},
async filePath(id) { return `~/Notes/${id}.md`; },
async saveExport(name, content) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
return name;
},
async linkPreview() { return null; },
async getSettings() { return { ...settings }; },
async setSettings(patch) { settings = { ...settings, ...patch }; save(); return settings; },
async getSpellcheck() { return { enabled: settings.spellcheck !== false, languages: [], available: [], system: true }; },
async setSpellcheck(enabled) { settings = { ...settings, spellcheck: !!enabled }; save(); return true; },
async spellWords() { return Array.isArray(settings.words) ? settings.words : []; },
async addSpellWord(word) { settings = { ...settings, words: [...new Set([...(settings.words || []), word])] }; save(); },
async removeSpellWord(word) { settings = { ...settings, words: (settings.words || []).filter((w) => w !== word) }; save(); },
async backupNow() { throw new Error('Backups need the desktop app.'); },
async backupInfo() { return { folder: '', last: 0 }; },
async pickBackupFolder() { return null; },
async revealBackups() {},
onNav(cb) { return () => {}; },
onChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); },
onFlush() { return () => {}; }
};
}