diff --git a/README.md b/README.md index fd915c8..e15ec22 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ Named for the basking shark, which swims along with its mouth open and takes in Rename a page and every `[[link]]` to it is updated. - **Type `/` for commands**: headings, to-do lists, quotes, code blocks, dividers, today's date, the time. Lists continue when you press Enter and indent with Tab. Ctrl+B and Ctrl+I work as you expect. -- **Write or Read.** Write shows the markdown; Read shows the page as it will look, with tasks you can - tick and links you can follow. Click a page in Read to start writing (Ctrl+E switches). +- **Live, Edit or View.** Live is the default: every line shows rendered except the one you are on, + which shows its markdown, as in Obsidian's live preview. Edit shows the whole page as plain + markdown, and View shows the whole page rendered, with tasks you can tick and links you can follow. + Click a page in View to start writing. Ctrl+E switches between editing and View. - **Quick find with Ctrl+K** searches titles and text as you type, and makes a new page if there is no match. - **A journal.** Today opens the page for the day, with an Insert time button. Days link to each @@ -169,7 +171,8 @@ lower-case letters, numbers and dashes). Restart Basking to see files added by h - Searching holds every page in memory, which is comfortable for tens of thousands of pages and not built for millions. - Pages are only reloaded from disk when Basking starts. -- Write mode is plain markdown, not a block editor. Read mode shows how it looks. +- Live preview works a line at a time (a code block counts as one), so a table or a quote spread over + several lines shows rendered line by line. View shows the whole page as one. ## Design diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 1717658..bf44a79 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -220,9 +220,9 @@ function pageView({ id, date, parent = '' }) { function drawActions() { const have = !!(note && note.id); - const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, - h('button', { class: 'seg-b' + (ed.mode === 'write' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'write'), onclick: () => ed.setMode('write') }, 'Write'), - h('button', { class: 'seg-b' + (ed.mode === 'read' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'read'), onclick: () => ed.setMode('read') }, 'Read')); + const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' }, + [['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) => + h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label))); fill(actions, ed.status, h('span', { class: 'grow' }), seg, date ? [btn('Insert time', 'clock', () => ed.stamp())] : null, btn(note?.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { @@ -259,10 +259,10 @@ function pageView({ id, date, parent = '' }) { }, }); ed.onMode = () => drawActions(); - view.toggleMode = () => ed.setMode(ed.mode === 'write' ? 'read' : 'write'); + view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read'); if (n?.backlinks) backlinks = n.backlinks; const pref = store.get('mode'); - const start = n && n.body.trim() ? pref || 'read' : 'write'; // an empty page is for writing + const start = pref === 'read' && !(n && n.body.trim()) ? 'live' : pref || 'live'; // an empty page is for writing const title = date ? null : h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr); fill(body, ed.notice, title, date ? h('h1', { class: 'day-title' }, ed.titleEl.value) : null, ed.wrap, tagsRow, asideBox, meta); ed.setMode(start); @@ -272,7 +272,7 @@ function pageView({ id, date, parent = '' }) { const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); }; if (!n) setTimeout(() => ed.titleEl.focus()); else if (!date && !n.body.trim() && !n.title) setTimeout(() => ed.titleEl.focus()); - else if (date && !n.body.trim() && start === 'write') setTimeout(() => ed.ta.focus()); + else if (date && !n.body.trim() && start !== 'read') setTimeout(() => ed.focusBody(true)); } function trashedView(n) { @@ -553,7 +553,7 @@ function settingsPage() { btn('Download all pages', 'download', () => { location.href = '/api/export'; })), card('Shortcuts', h('ul', { class: 'keys' }, row('Ctrl K', 'Search pages, or make a new one'), row('c', 'New page'), row('n', 'Jot a quick note'), - row('Ctrl E', 'Switch between Write and Read'), row('/', 'In a page: commands. Anywhere else: search'), + row('Ctrl E', 'Switch between editing and View'), row('/', 'In a page: commands. Anywhere else: search'), row('[[', 'In a page: link to another page'), row('Ctrl B, Ctrl I', 'Bold, italic'), row('Tab', 'In a list: indent'))), card('Privacy', h('p', { class: 'dim' }, 'Pages are stored as plain markdown files on the machine running Basking, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))), }; diff --git a/internal/web/static/editor.js b/internal/web/static/editor.js index f621a2c..4c3d0f1 100644 --- a/internal/web/static/editor.js +++ b/internal/web/static/editor.js @@ -147,11 +147,131 @@ function enhance(ta, wrap) { return pop; } +// ---------- live preview: every line shows rendered except the one being edited ---------- +// The text itself stays in the hidden text box `ta`; this view splices each edit back into it, so saving, +// conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole. +function makeLive(ta) { + const el = h('div', { class: 'live', hidden: true }); + let lines = [], act = null; // act: the block starting at line s is being edited, caret at col + + function blocks() { + const out = []; + for (let i = 0; i < lines.length;) { + if (/^\s*```/.test(lines[i])) { + let j = i + 1; + while (j < lines.length && !/^\s*```/.test(lines[j])) j++; + out.push({ s: i, e: Math.min(j, lines.length - 1), fence: true }); + i = j + 1; + } else out.push({ s: i, e: i, fence: false }), i++; + } + return out; + } + const blockAt = n => blocks().find(b => n >= b.s && n <= b.e); + const commit = () => { ta.value = lines.join('\n'); ta.dispatchEvent(new Event('input', { bubbles: true })); }; + + function rendered(b) { + const text = lines.slice(b.s, b.e + 1).join('\n'), box = h('div', { class: 'live-line' }); + if (text.trim()) { + const root = md(text, (_, checked) => { lines[b.s] = lines[b.s].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); commit(); draw(); }, true); + const m = /^\s*(\d+)[.)]\s/.exec(text); + if (m) root.querySelector('ol')?.setAttribute('start', m[1]); + box.append(root); + } + box.addEventListener('click', e => { if (!e.target.closest('a,input,button') && !inSelection()) activate(b.s, Infinity, true); }); + return box; + } + + function merge(at, col) { + const len = lines[at].length; + lines.splice(at, 2, lines[at] + lines[at + 1]); + commit(); act = { s: at, col: col ?? len, focus: true }; draw(); + } + + function editor(b) { + const empty = lines.length === 1 && !lines[0]; + const t = h('textarea', { class: 'live-edit', rows: 1, spellcheck: 'true', 'aria-label': 'Editing', placeholder: empty ? "Write something, or type '/' for commands, '[[' to link a page" : '' }); + t.value = lines.slice(b.s, b.e + 1).join('\n'); + enhance(t, el); + t.addEventListener('input', () => { + const fresh = t.value.split('\n'); + lines.splice(b.s, b.e - b.s + 1, ...fresh); + b.e = b.s + fresh.length - 1; + commit(); + if (!b.fence && fresh.length > 1) { // Enter: the line above is finished, the new one is being edited + const before = t.value.slice(0, t.selectionStart); + place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true); + draw(); + } else { t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; } + }); + t.addEventListener('keydown', e => { + if (e.isComposing) return; + if (e.key === 'Escape' && !e.defaultPrevented) { t.blur(); return; } + if (e.defaultPrevented || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || t.selectionStart !== t.selectionEnd) return; + const v = t.value, p = t.selectionStart, here = caretXY(t, p), lh = here.lh; + const firstRow = here.y < lh * 0.5, lastRow = caretXY(t, v.length).y - here.y < lh * 0.5, col = b.fence ? 0 : p; + if (e.key === 'ArrowUp' && firstRow && b.s > 0) { e.preventDefault(); activate(b.s - 1, col, true); } + else if (e.key === 'ArrowDown' && lastRow && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, col, true); } + else if (e.key === 'ArrowLeft' && p === 0 && b.s > 0) { e.preventDefault(); activate(b.s - 1, Infinity, true); } + else if (e.key === 'ArrowRight' && p === v.length && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, 0, true); } + else if (e.key === 'Backspace' && p === 0 && b.s > 0) { + e.preventDefault(); + if (b.fence || blockAt(b.s - 1).fence) activate(b.s - 1, Infinity, true); else merge(b.s - 1); + } else if (e.key === 'Delete' && p === v.length && b.e < lines.length - 1) { + e.preventDefault(); + if (b.fence || blockAt(b.e + 1).fence) activate(b.e + 1, 0, true); else merge(b.s); + } + }); + t.addEventListener('blur', () => setTimeout(() => { if (act && !el.contains(document.activeElement)) { act = null; draw(); } }, 0)); + return t; + } + + function draw() { + lines = ta.value.split('\n'); + fill(el, blocks().map(b => act && act.s === b.s ? editor(b) : rendered(b)), h('div', { class: 'live-tail', onclick: () => activate(lines.length - 1, Infinity, true) })); + const t = el.querySelector('.live-edit'); + if (!t) return; + t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; + if (act.focus) { + const c = Math.min(act.col, t.value.length); + t.focus(); t.setSelectionRange(c, c); t.scrollIntoView({ block: 'nearest' }); + act.focus = false; + } + } + // place chooses the block that holds line n; inside a code block the caret is counted from its first line + function place(n, col, focus) { + const b = blockAt(n); + if (!b) return false; + const c = b.fence && col !== Infinity ? lines.slice(b.s, n).join('\n').length + (n > b.s ? 1 : 0) + col : col; + act = { s: b.s, col: c, focus }; + return true; + } + function activate(n, col = Infinity, focus = false) { + lines = ta.value.split('\n'); + if (place(n, col, focus)) draw(); + } + return { + el, activate, + // show draws the page; with a place it starts editing there, and an empty page starts on its first line + show(at) { + lines = ta.value.split('\n'); + if (at) activate(at.n, at.col, at.focus); + else { act = ta.value ? null : { s: 0, col: 0, focus: false }; draw(); } + }, + hide() { act = null; }, + refresh() { draw(); }, + caretOffset() { + const t = el.querySelector('.live-edit'); + if (!act || !t) return null; + return lines.slice(0, act.s).join('\n').length + (act.s ? 1 : 0) + t.selectionStart; + }, + }; +} + // ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ---------- // With no note it starts a new page, which is only created once something is typed. function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false; - let timer = null, retry = null, badTitle = null, mode = 'write'; + let timer = null, retry = null, badTitle = null, mode = 'live', lastEdit = 'live'; const titleEl = h('input', { class: 'title-input', type: 'text', placeholder: 'Untitled', 'aria-label': 'Page title', maxlength: 120, autocomplete: 'off', spellcheck: 'true' }); titleEl.value = fixedTitle ?? note?.title ?? ''; @@ -160,7 +280,8 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" }); ta.value = note?.body ?? ''; const view = h('div', { class: 'view', hidden: true }); - const wrap = h('div', { class: 'editor-wrap' }, ta, view); + const live = makeLive(ta); + const wrap = h('div', { class: 'editor-wrap' }, ta, live.el, view); const status = h('span', { class: 'sub', role: 'status' }); const notice = h('div', { class: 'notice notice-warning', hidden: true }); const say = t => { status.textContent = t; }; @@ -227,7 +348,7 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = titleEl.value !== theirs.title; bodyDirty = ta.value !== theirs.body; flag(null); save(); } }, 'Keep my version'), h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = bodyDirty = false; titleEl.value = theirs.title; ta.value = theirs.body; - flag(null); say('Saved'); fit(); if (mode === 'read') render(); onSaved?.(cur, { renamed: true }); + flag(null); say('Saved'); refresh(); onSaved?.(cur, { renamed: true }); } }, 'Use the other version')]); } @@ -235,7 +356,7 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { titleEl.addEventListener('input', () => { titleDirty = true; titleErr.hidden = true; if (badTitle !== null && titleEl.value !== badTitle) badTitle = null; say('Unsaved'); later(); }); titleEl.addEventListener('blur', () => save()); titleEl.addEventListener('keydown', e => { - if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); setMode('write'); ta.focus(); ta.setSelectionRange(0, 0); } + if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); if (mode === 'read') setMode(lastEdit); focusBody(false); } }); ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); }); @@ -246,27 +367,48 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); ta.value = lines.join('\n'); bodyDirty = true; say('Unsaved'); render(); later(); } + // Live shows every line rendered except the one you are on; Edit shows the markdown; View shows the page. function setMode(m) { - mode = m; - if (m === 'read') { render(); fit(); } - ta.hidden = m === 'read'; view.hidden = m === 'write'; - if (m === 'write') fit(); + const was = mode, hadFocus = document.activeElement === ta, off = was === 'live' ? live.caretOffset() : null; + mode = m; if (m !== 'read') lastEdit = m; + ta.hidden = m !== 'write'; view.hidden = m !== 'read'; live.el.hidden = m !== 'live'; + if (was === 'live' && m !== 'live') live.hide(); + if (m === 'read') render(); + else if (m === 'write') { fit(); if (off != null) { ta.focus(); ta.setSelectionRange(off, off); } } + else if (hadFocus) { + const before = ta.value.slice(0, ta.selectionStart); + live.show({ n: before.split('\n').length - 1, col: before.length - before.lastIndexOf('\n') - 1, focus: true }); + } else live.show(); ctl.onMode?.(m); } + // refresh redraws whatever is showing after the text was replaced from outside + function refresh() { if (mode === 'read') render(); else if (mode === 'live') live.show(); else fit(); } + function focusBody(end) { + if (mode === 'live') live.show({ n: end ? ta.value.split('\n').length - 1 : 0, col: end ? Infinity : 0, focus: true }); + else { ta.focus(); const p = end ? ta.value.length : 0; ta.setSelectionRange(p, p); } + } view.addEventListener('click', e => { if (e.target.closest('a,input,button') || inSelection()) return; - setMode('write'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); + setMode(lastEdit); focusBody(true); }); window.addEventListener('resize', fit); // Getters must be defined on the object itself; Object.assign would copy their values once. - Object.defineProperties(ctl, { mode: { get: () => mode }, note: { get: () => cur }, body: { get: () => ta.value } }); + Object.defineProperties(ctl, { mode: { get: () => mode }, lastEdit: { get: () => lastEdit }, note: { get: () => cur }, body: { get: () => ta.value } }); return Object.assign(ctl, { - titleEl, titleErr, ta, view, wrap, status, notice, setMode, + titleEl, titleErr, ta, view, wrap, status, notice, setMode, focusBody, fit, flush: () => save(), - // Insert the time where the caret is, on a line of its own. + // Insert the time where the caret is, on a line of its own. In Live it goes at the end of the page. stamp() { - setMode('write'); ta.focus(); + if (mode === 'read') setMode(lastEdit); + if (mode === 'live') { + const text = ta.value.replace(/\s+$/, ''); + ta.value = (text ? text + '\n\n' : '') + hhmm(new Date()) + ' '; + ta.dispatchEvent(new Event('input', { bubbles: true })); + focusBody(true); + return; + } + ta.focus(); const at = ta.selectionStart, before = ta.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1); const lead = line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : ''; replaceRange(ta, at, ta.selectionEnd, lead + hhmm(new Date()) + ' '); diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 073ee1b..063740f 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -325,3 +325,14 @@ .note-title a:hover { text-decoration:underline; } .keys { list-style:none; margin:0; padding:0; display:grid; gap:var(--s2); } @media (max-width:640px) { .page-actions { justify-content:flex-start; } .shade-top { padding-top:var(--s4); } } + + /* live preview: lines render, the line being edited shows its markdown */ + .live { position:relative; line-height:1.7; font-size:var(--text-base); } + .live-line { min-height:1.7em; cursor:text; overflow-wrap:anywhere; } + .live .md p, .live .md ul, .live .md ol, .live .md pre, .live .md blockquote { margin:0; } + .live .md hr { margin:var(--s2) 0; } + .live .md .md-h { margin:var(--s2) 0 0; } + .live .md h3.md-h { font-size:var(--text-xl); } .live .md h4.md-h { font-size:var(--text-lg); } + textarea.live-edit { display:block; width:100%; min-height:1.7em; margin:0 0 0 calc(-1 * (var(--s2) + 2px)); padding:0 0 0 var(--s2); border:0; border-left:2px solid var(--accent); border-radius:0; background:var(--accent-soft); font-family:var(--font); font-size:var(--text-base); line-height:1.7; resize:none; overflow:hidden; } + textarea.live-edit:focus { outline:none; border-color:var(--accent); } + .live-tail { min-height:30vh; cursor:text; }