Insert images and tables as block-level fresh lines

uploadImage and insertTable no longer inject at the cursor: on a
non-empty line (e.g. inside an existing image tag) the block goes on a
fresh blank-separated line after it, never into it.
This commit is contained in:
2026-08-29 06:09:51 +00:00
parent 6058853341
commit fd75a260b5
2 changed files with 39 additions and 11 deletions
+1 -1
View File
@@ -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): 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`. - `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 `<head id="pagerite-user">`. - `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 `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row. - `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
+38 -10
View File
@@ -144,10 +144,25 @@ async function uploadImage(file) {
// lone image with a title renders as a captioned figure, and an empty // lone image with a title renders as a captioned figure, and an empty
// caption is as good as none. // caption is as good as none.
const insert = `![${alt}](${stored} "")` 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({ view.dispatch({
changes: { from, to, insert }, changes: { from: pos, insert: text },
selection: { anchor: from + insert.length - 2 }, selection: { anchor: pos + text.indexOf(insert) + insert.length - 2 },
}) })
view.focus() view.focus()
} }
@@ -529,16 +544,29 @@ function applyClass(cls, group) {
function insertTable(cols, rows) { function insertTable(cols, rows) {
// A GFM table on its own blank-separated block, first header cell // A GFM table on its own blank-separated block, first header cell
// selected. // selected. Like images, a table is block-level: on a fresh line of its
const { from, to } = view.state.selection.main // own — a cursor on a non-empty line (e.g. inside an image tag) inserts
const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : '' // 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 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` + `${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({ view.dispatch({
changes: { from, to, insert: table }, changes: { from: pos, insert: text },
selection: { anchor: from + before.length + 2, head: from + before.length + 8 }, selection: { anchor, head: anchor + 6 },
}) })
tablePicker.value = false tablePicker.value = false
view.focus() view.focus()