diff --git a/docs/editing.md b/docs/editing.md index 5330869..c3420dd 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -6,7 +6,7 @@ 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, scrolling only when the cursor's page position leaves the viewport, with an edge margin), 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. +- `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, scrolling only when the cursor's page position leaves the viewport, with an edge margin), 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 block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; 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. diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index 96b5ca8..e85a282 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -144,10 +144,25 @@ async function uploadImage(file) { // 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 + // Images are never inline: the image always goes on a fresh line of + // its own, blank-separated from other content. On a non-empty line — + // notably when the cursor sits inside an existing image tag — the new + // image goes AFTER that line, never into it. + const doc = view.state.doc + const line = doc.lineAt(view.state.selection.main.from) + const prevNonEmpty = line.number > 1 && doc.line(line.number - 1).text.trim() + const nextNonEmpty = line.number < doc.lines && doc.line(line.number + 1).text.trim() + let pos, text + if (line.text.trim()) { + pos = line.to + text = '\n' + insert + (nextNonEmpty ? '\n' : '') + } else { + pos = line.from + text = (prevNonEmpty ? '\n' : '') + insert + (nextNonEmpty ? '\n' : '') + } view.dispatch({ - changes: { from, to, insert }, - selection: { anchor: from + insert.length - 2 }, + changes: { from: pos, insert: text }, + selection: { anchor: pos + text.indexOf(insert) + insert.length - 2 }, }) view.focus() } @@ -529,16 +544,29 @@ function applyClass(cls, group) { function insertTable(cols, rows) { // A GFM table on its own blank-separated block, first header cell - // selected. - const { from, to } = view.state.selection.main - const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : '' + // selected. Like images, a table is block-level: on a fresh line of its + // own — a cursor on a non-empty line (e.g. inside an image tag) inserts + // after that line, never into it. + const doc = view.state.doc + const line = doc.lineAt(view.state.selection.main.from) + const prevNonEmpty = line.number > 1 && doc.line(line.number - 1).text.trim() + const nextNonEmpty = line.number < doc.lines && doc.line(line.number + 1).text.trim() const row = (cells) => `| ${cells.join(' | ')} |` - const table = `${before}${row(Array(cols).fill('column'))}\n` + const grid = `${row(Array(cols).fill('column'))}\n` + `${row(Array(cols).fill('---'))}\n` - + `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n` + + `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}` + let pos, text + if (line.text.trim()) { + pos = line.to + text = '\n' + grid + (nextNonEmpty ? '\n' : '') + } else { + pos = line.from + text = (prevNonEmpty ? '\n' : '') + grid + (nextNonEmpty ? '\n' : '') + } + const anchor = pos + text.indexOf(grid) + 2 view.dispatch({ - changes: { from, to, insert: table }, - selection: { anchor: from + before.length + 2, head: from + before.length + 8 }, + changes: { from: pos, insert: text }, + selection: { anchor, head: anchor + 6 }, }) tablePicker.value = false view.focus()