diff --git a/docs/editing.md b/docs/editing.md index 5881ae3..e8310d9 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -6,12 +6,12 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner): -- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, anchored at a fixed window height), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. +- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, anchored at a fixed window height), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside` → `::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. - `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`. - `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into ``. - `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row. -Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (Escape too); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page. +Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (deliberately NOT Escape — it fired too easily by accident); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page. In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes. diff --git a/frontend/src/BannerEditor.vue b/frontend/src/BannerEditor.vue index 410106d..cf2b6e7 100644 --- a/frontend/src/BannerEditor.vue +++ b/frontend/src/BannerEditor.vue @@ -4,6 +4,8 @@ import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' +import { keymap } from '@codemirror/view' +import { indentWithTab } from '@codemirror/commands' import { html } from '@codemirror/lang-html' import { cmHighlight, cmTheme } from './cmtheme' import { dropPageCache, loadPlain, runScripts } from './swapdoc' @@ -261,6 +263,8 @@ onMounted(async () => { doc: '', extensions: [ basicSetup, + // Tab/Shift-Tab indent and dedent instead of moving focus. + keymap.of([indentWithTab]), html(), cmTheme, cmHighlight, diff --git a/frontend/src/EditorShell.vue b/frontend/src/EditorShell.vue index 7f24b76..064a23c 100644 --- a/frontend/src/EditorShell.vue +++ b/frontend/src/EditorShell.vue @@ -54,20 +54,16 @@ function onSwitchEvent(ev) { // Closing the shell hides it but keeps it mounted (main.js); the tabs stay // cached in KeepAlive the whole time, so no state is ever lost until a real // page reload. On re-show each active tab re-applies its window title and -// preview via its own pagerite:editor-shown listener. -function onKeydown(ev) { - if (ev.key === 'Escape' && document.body.classList.contains('editing')) close() -} +// preview via its own pagerite:editor-shown listener. No Escape-to-close: +// it fired too easily by accident (e.g. dismissing an editor popup). onMounted(() => { document.body.dataset.editorMode = activeMode.value addEventListener('pagerite:switch-editor', onSwitchEvent) - addEventListener('keydown', onKeydown) }) onUnmounted(() => { removeEventListener('pagerite:switch-editor', onSwitchEvent) - removeEventListener('keydown', onKeydown) }) diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index ae2c3b7..0ecdc27 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -16,6 +16,8 @@ import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' +import { keymap } from '@codemirror/view' +import { indentWithTab } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { cmHighlight, cmTheme } from './cmtheme' import { dropPageCache, loadPlain } from './swapdoc' @@ -131,11 +133,6 @@ function close() { if (dirty.value) loadPlain(path.value) } -function insertAtCursor(text) { - view.dispatch(view.state.replaceSelection(text)) - view.focus() -} - async function uploadImage(file) { if (!file) return const name = file.name.replace(/[^\w.-]/g, '-') @@ -143,7 +140,16 @@ async function uploadImage(file) { if (res.ok) { const { path: stored } = await res.json() const alt = name.replace(/\.[^.]+$/, '') - insertAtCursor(`![${alt}](${stored})`) + // Always include an empty caption (""), cursor inside the quotes: a + // lone image with a title renders as a captioned figure, and an empty + // caption is as good as none. + const insert = `![${alt}](${stored} "")` + const { from, to } = view.state.selection.main + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + insert.length - 2 }, + }) + view.focus() } } @@ -170,26 +176,165 @@ function wrapInline(mark) { view.focus() } -function insertCode() { - // On an empty line with no selection: a fenced code block, cursor inside. - // Otherwise an inline code wrap (toggling). +// --- Fenced blocks (``` code, ::: containers) ------------------------------ +// Fences are never nested, and both kinds behave identically in the +// toolbar: clicked with the cursor/selection inside a fence of its kind, +// the button REMOVES the fence lines and selects the whole content; +// otherwise it wraps the selection — expanded to whole lines, so partial +// line selections and a bare cursor on a line count as that line — in a +// fence, keeping the content selected. A cursor on an empty line inserts +// an empty fence with the cursor inside. + +// Find the fence block around a line range by parity (no nesting): an odd +// count of marker lines above the range means it is inside a block. The +// range's own first/last lines may be the fence lines themselves. +function enclosingFence(fromNo, toNo, markerRe) { + const doc = view.state.doc + const isFence = (n) => markerRe.test(doc.line(n).text.trimStart()) + let above = 0 + for (let n = 1; n < fromNo; n++) if (isFence(n)) above++ + let openNo = null + if (above % 2 === 1) { + for (let n = fromNo - 1; n >= 1; n--) { + if (isFence(n)) { openNo = n; break } + } + } else if (isFence(fromNo)) { + openNo = fromNo + } + if (openNo === null) return null + for (let n = Math.max(toNo, openNo + 1); n <= doc.lines; n++) { + if (isFence(n)) return { open: doc.line(openNo), close: doc.line(n) } + } + return null +} + +// Remove the fence block enclosing the selection, selecting its whole +// content. Returns true when there was one. +function removeEnclosingFence(markerRe) { + const doc = view.state.doc const { from, to } = view.state.selection.main - const line = view.state.doc.lineAt(from) - if (from === to && !line.text.trim()) { + const fence = enclosingFence(doc.lineAt(from).number, doc.lineAt(to).number, markerRe) + if (!fence) return false + const { open, close } = fence + // One atomic replace: fences out, content stays where it lands. + const hasAfter = close.to < doc.length + const end = hasAfter ? close.to + 1 : doc.length + const content = hasAfter + ? doc.sliceString(open.to + 1, close.from) // trailing newline kept + : doc.sliceString(open.to + 1, Math.max(open.to + 1, close.from - 1)) + const head = content.endsWith('\n') ? content.length - 1 : content.length + view.dispatch({ + changes: { from: open.from, to: end, insert: content }, + selection: { anchor: open.from, head: open.from + Math.max(0, head) }, + }) + view.focus() + return true +} + +// Wrap the selection — expanded to whole lines (a bare cursor counts as +// its line) — in a fence, cursor left at the end of the opener line. On +// an empty line with no selection, insert an empty fence with the cursor +// at the END of the opener line (no blank content line): for ``` a +// language word can be typed right away, for ::: the container name +// (aside) can be rewritten. +function wrapInFence(openText, closeText) { + const doc = view.state.doc + const { from, to } = view.state.selection.main + const fromLine = doc.lineAt(from) + if (from === to && !fromLine.text.trim()) { view.dispatch({ - changes: { from: line.from, to: line.to, insert: '```\n\n```' }, - selection: { anchor: line.from + 4 }, + changes: { from: fromLine.from, to: fromLine.to, insert: `${openText}\n${closeText}` }, + selection: { anchor: fromLine.from + openText.length }, }) - view.focus() + } else { + const bf = fromLine.from + // A selection ending exactly at a line start excludes that (possibly + // empty) line — only the selected lines go inside the fence. + let lastLine = doc.lineAt(to) + if (to === lastLine.from && to > from) lastLine = doc.line(lastLine.number - 1) + const bt = lastLine.to + view.dispatch({ + changes: [ + { from: bt, insert: `\n${closeText}` }, + { from: bf, insert: `${openText}\n` }, + ], + // Cursor at the end of the opening fence line, selection cleared — + // a language word (or container name) can be typed right away. + selection: { anchor: bf + openText.length }, + }) + } + view.focus() +} + +function insertCode() { + // Toggling, selection-preserving code helper: + // - inside a fenced block: remove the fences, content selected (above) + // - selection covering whole line(s) or spanning lines: fenced block + // - empty line, no selection: a fenced block, cursor inside + // - otherwise an inline wrap; the inner text stays selected both ways, + // and a repeated click removes the backtick run around it + const state = view.state + const doc = state.doc + const { from, to } = state.selection.main + const fromLine = doc.lineAt(from) + const toLine = doc.lineAt(to) + const inline = from !== to && fromLine.number === toLine.number + && !(from === fromLine.from && to === toLine.to) + if (!inline && removeEnclosingFence(/^```/)) return + if (from === to) { + if (!fromLine.text.trim()) wrapInFence('```', '```') + else wrapInline('`') return } - wrapInline('`') + if (!inline) { + wrapInFence('```', '```') + return + } + // Inline: a matching backtick run on both sides unwraps; otherwise wrap + // (double ticks when the text itself contains a backtick). + let l = 0 + while (l < from && doc.sliceString(from - l - 1, from - l) === '`') l++ + let r = 0 + while (doc.sliceString(to + r, to + r + 1) === '`') r++ + if (l > 0 && l === r) { + view.dispatch({ + changes: [{ from: to, to: to + r }, { from: from - l, to: from }], + selection: { anchor: from - l, head: to - l }, + }) + } else { + const mark = doc.sliceString(from, to).includes('`') ? '``' : '`' + view.dispatch({ + changes: { from, to, insert: mark + doc.sliceString(from, to) + mark }, + selection: { anchor: from + mark.length, head: to + mark.length }, + }) + } + view.focus() } function insertLink() { - // Selected text becomes the link label — or the URL if it looks like one. + // Toggle: with the cursor or selection anywhere inside an existing + // [label](url) on this line, unwrap it (the label stays selected). + // Otherwise the selected text becomes the label — or the URL if it + // looks like one. const { from, to } = view.state.selection.main - const text = view.state.sliceDoc(from, to) + const doc = view.state.doc + const line = doc.lineAt(from) + const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g + let m + while ((m = linkRe.exec(line.text))) { + if (line.text[m.index - 1] === '!') continue // image, not a link + const start = line.from + m.index + if (from >= start && to <= start + m[0].length) { + const label = m[1] + view.dispatch({ + changes: { from: start, to: start + m[0].length, insert: label }, + selection: { anchor: start, head: start + label.length }, + }) + view.focus() + return + } + } + const text = doc.sliceString(from, to) const isUrl = /^https?:\/\/\S+$/.test(text) const insert = isUrl ? `[](${text})` : `[${text}]()` const urlStart = from + insert.length - 1 // inside the parens @@ -200,12 +345,188 @@ function insertLink() { view.focus() } +// ::: aside container, toggling like code fences (shared machinery above): +// inside one it is removed (content selected); otherwise the selection — +// or the cursor's line — becomes the content, selected. The placement +// buttons below work on the ::: line itself. +function insertAside() { + if (!removeEnclosingFence(/^:::/)) wrapInFence('::: aside', ':::') +} + +// Block placement classes: .left/.right float, .wide full bleed, .margin +// a margin note; plus the text size classes .small/.large/.huge. The +// button toggles the class in the brace attributes of the block at the +// cursor (figure/image line, paragraph, code fence); classes within one +// group are mutually exclusive. ::: containers are the exception: a +// placement class replaces the container name (::: margin, etc.), a size +// class takes braces (::: aside {.small}). A blank cursor line targets +// the block above (a trailing {...} line applies there). +const PLACEMENTS = ['left', 'right', 'wide', 'margin'] +const SIZES = ['small', 'large', 'huge'] + +// Toggle .cls in a line's trailing brace attributes, preserving the other +// tokens (language, #id, other groups' classes) and the original spacing; +// returns the new text. +function toggleAttrClass(text, cls, group) { + const m = text.match(/(\s*)\{([^{}]*)\}(\s*)$/) + if (!m) { + // A lone image takes the braces directly attached, others spaced. + const tight = /^\s*!\[[^\]]*\]\([^)]*\)$/.test(text.trimEnd()) ? '' : ' ' + return text.trimEnd() + tight + `{.${cls}}` + } + const tokens = m[2].trim() ? m[2].trim().split(/\s+/) : [] + const tok = `.${cls}` + let next + if (tokens.includes(tok)) { + next = tokens.filter((t) => t !== tok) + } else { + next = tokens.filter((t) => !group.some((c) => t === `.${c}`)) + next.push(tok) + } + const base = text.slice(0, m.index).trimEnd() + return next.length ? base + (m[1] || ' ') + `{${next.join(' ')}}` : base +} + +// Locate where block classes live for the block at the cursor: +// { line } — trailing brace attributes on that line (paragraph, image, +// fence info line); { container } — a ::: container's opener line; or +// { fence, attrLine } — a code fence, whose classes live on a line of +// their own after the closing fence (attrLine null when not written yet). +// A blank cursor line targets the block above. Shared by the class +// toggles and the pickers' current-class indicator. +function classTarget() { + const doc = view.state.doc + let line = doc.lineAt(view.state.selection.main.head) + while (!line.text.trim() && line.number > 1) line = doc.line(line.number - 1) + if (!line.text.trim()) return null + // ``` fence context: an odd count of fence lines above means the cursor + // is inside the fence or on its closing fence. + let open = false + for (let n = 1; n < line.number; n++) { + if (doc.line(n).text.trimStart().startsWith('```')) open = !open + } + if (open) { + let n = line.number + while (n <= doc.lines && !doc.line(n).text.trimStart().startsWith('```')) n++ + if (n > doc.lines) return null // unclosed fence — nothing to attach to + const fence = doc.line(n) + const after = fence.number < doc.lines ? doc.line(fence.number + 1) : null + return { + fence, + attrLine: after && /^\s*\{[^{}]*\}\s*$/.test(after.text) ? after : null, + } + } + // ::: container context: same parity (containers are not nested) — on + // the opener, inside, or on the closing fence. + let above = 0 + for (let n = 1; n < line.number; n++) { + if (doc.line(n).text.trimStart().startsWith(':::')) above++ + } + if (above % 2 === 1) { + for (let n = line.number - 1; n >= 1; n--) { + if (doc.line(n).text.trimStart().startsWith(':::')) { + return { container: doc.line(n) } + } + } + return null + } + if (/^\s*:::\s*\w/.test(line.text)) return { container: line } + return { line } +} + +function togglePlacement(cls, group = PLACEMENTS) { + const t = classTarget() + if (!t) { + view.focus() + return + } + let line + if (t.container) { + // Placement replaces the container name (clicking the active one + // reverts to aside); sizes and other classes take brace attributes. + if (PLACEMENTS.includes(cls)) { + const m = t.container.text.match(/^(\s*:::\s*)(\w+)/) + const name = m[2] === cls ? 'aside' : cls + view.dispatch({ + changes: { from: t.container.from, to: t.container.to, insert: `${m[1]}${name}` }, + }) + view.focus() + return + } + line = t.container + } else if (t.fence) { + if (!t.attrLine) { + view.dispatch({ changes: { from: t.fence.to, insert: `\n{.${cls}}` } }) + view.focus() + return + } + line = t.attrLine + } else { + line = t.line + } + const text = toggleAttrClass(line.text, cls, group) + if (text !== line.text) { + view.dispatch({ changes: { from: line.from, to: line.to, insert: text } }) + } + view.focus() +} + +// The class set of the block at the cursor (names without the dot): brace +// tokens, plus the container name when it is a placement (::: margin). +function braceClasses(text) { + const m = text.match(/\{([^{}]*)\}\s*$/) + if (!m) return new Set() + return new Set( + m[1].split(/\s+/).filter((tok) => tok.startsWith('.')).map((tok) => tok.slice(1)), + ) +} + +function currentClasses() { + const t = classTarget() + if (!t) return new Set() + if (t.container) { + const s = braceClasses(t.container.text) + const name = t.container.text.match(/^\s*:::\s*(\w+)/)?.[1] + if (PLACEMENTS.includes(name)) s.add(name) + return s + } + if (t.fence) return t.attrLine ? braceClasses(t.attrLine.text) : new Set() + return braceClasses(t.line.text) +} + // Table size picker: a hover grid popup (cols × rows) under the toolbar. const tablePicker = ref(false) const tableSize = ref({ cols: 0, rows: 0 }) const TABLE_MAX_COLS = 8 const TABLE_MAX_ROWS = 6 +// Class pickers: popup listing the block class toggles (placement ↔︎, +// text size AA), closed after applying. The block's current class of the +// group is marked; choosing "normal" (or the current class) removes it. +const classPicker = ref(null) // 'place' | 'size' | null +const activeClasses = ref(new Set()) + +function openClassPicker(which) { + classPicker.value = classPicker.value === which ? null : which + if (classPicker.value) activeClasses.value = currentClasses() +} + +function isClassActive(cls, group) { + return cls === 'normal' + ? !group.some((c) => activeClasses.value.has(c)) + : activeClasses.value.has(cls) +} + +function applyClass(cls, group) { + if (cls === 'normal') { + const cur = group.find((c) => activeClasses.value.has(c)) + if (cur) togglePlacement(cur, group) // present → toggles off + } else { + togglePlacement(cls, group) + } + classPicker.value = null +} + function insertTable(cols, rows) { // A GFM table on its own blank-separated block, first header cell // selected. @@ -506,6 +827,8 @@ onMounted(() => { doc: '', extensions: [ basicSetup, + // Tab/Shift-Tab indent and dedent instead of moving focus. + keymap.of([indentWithTab]), markdown(), cmTheme, cmHighlight, @@ -589,17 +912,59 @@ onUnmounted(() => { >💾
- - - - + + - + >⊞ + + + + + + + + + + + + + + + +