Article editor toolbar: toggling fences/links, class pickers, sizes, Tab indent
- Image insert always adds an empty "" caption with the cursor inside. - Fenced blocks (``` code, ::: aside) share one toggle: clicked inside one it is removed and the content selected; otherwise the selection (expanded to whole lines) is wrapped, cursor left on the opener line. - Code button: inline wrap toggles (selection preserved, backtick runs), line-spanning selections make fenced blocks. - Link button toggles: clicked inside [label](url) it unwraps. - Block classes via two pickers (placement, AA size) with current-class indication and a "normal" reset; placement replaces ::: container names, fences get their own attribute line. - New .small/.large/.huge text-size classes (0.7/1.5/3em); base body is exactly 1rem so the scale is uniform. - .left/.right floats generalized from figures to any block. - Toolbar polish: non-emoji glyphs, scaled-up symbols, borderless hover/active states, reordered (B/i and size last). - Editor panel no longer closes on Escape. - CodeMirror editors capture Tab/Shift-Tab for indent/dedent.
This commit is contained in:
+2
-2
@@ -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 `<head id="pagerite-user">`.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
+439
-26
@@ -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(``)
|
||||
// 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 = ``
|
||||
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(() => {
|
||||
>💾</button>
|
||||
</header>
|
||||
<div class="format-bar">
|
||||
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
||||
<button type="button" title="italic" @click="wrapInline('*')"><i>I</i></button>
|
||||
<button type="button" title="code (empty line: code block)" @click="insertCode"><code></></code></button>
|
||||
<button type="button" title="link" @click="insertLink">🔗</button>
|
||||
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code></></code></button>
|
||||
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
||||
<button
|
||||
type="button"
|
||||
title="table"
|
||||
:class="{ active: tablePicker }"
|
||||
@click="tablePicker = !tablePicker"
|
||||
>▦</button>
|
||||
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼️</button>
|
||||
>⊞</button>
|
||||
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼︎</button>
|
||||
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside">◧</button>
|
||||
<span class="picker">
|
||||
<button
|
||||
type="button"
|
||||
title="block placement class"
|
||||
:class="{ active: classPicker === 'place' }"
|
||||
@click="openClassPicker('place')"
|
||||
>↔︎</button>
|
||||
<span v-if="classPicker === 'place'" class="picker-pop">
|
||||
<button
|
||||
v-for="c in ['normal', ...PLACEMENTS]"
|
||||
:key="c"
|
||||
type="button"
|
||||
:class="{ active: isClassActive(c, PLACEMENTS), normal: c === 'normal' }"
|
||||
:title="c === 'normal'
|
||||
? 'remove the block\'s placement class'
|
||||
: `${c} on the block at the cursor`"
|
||||
@click="applyClass(c, PLACEMENTS)"
|
||||
>{{ c }}</button>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
||||
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
|
||||
<span class="picker">
|
||||
<button
|
||||
type="button"
|
||||
title="text size class"
|
||||
class="aa"
|
||||
:class="{ active: classPicker === 'size' }"
|
||||
@click="openClassPicker('size')"
|
||||
><span>A</span>A</button>
|
||||
<span v-if="classPicker === 'size'" class="picker-pop">
|
||||
<button
|
||||
v-for="c in ['small', 'normal', 'large', 'huge']"
|
||||
:key="c"
|
||||
type="button"
|
||||
:class="{ active: isClassActive(c, SIZES), normal: c === 'normal' }"
|
||||
:title="c === 'normal'
|
||||
? 'remove the block\'s size class'
|
||||
: `${c} on the block at the cursor`"
|
||||
@click="applyClass(c, SIZES)"
|
||||
>{{ c }}</button>
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
|
||||
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
|
||||
<button
|
||||
@@ -699,9 +1064,9 @@ onUnmounted(() => {
|
||||
|
||||
.format-bar button {
|
||||
min-width: 1.7rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
padding: 0.05rem 0.2rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
@@ -709,10 +1074,58 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover and selected (active) states: text color alone, no borders. */
|
||||
.format-bar button:hover,
|
||||
.format-bar button.active {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* The glyphs are small relative to the button boxes; scaling them up
|
||||
(transform, so layout is unaffected) fills the empty space between
|
||||
symbols. The code symbol is larger than the rest, so it scales less. */
|
||||
.format-bar > button,
|
||||
.picker > button {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.format-bar > button.code-btn {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
/* Class pickers: a button opening a small popup of class toggles (like
|
||||
the table picker), anchored under its own button. */
|
||||
.picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The size icon: two capital As at different sizes. */
|
||||
.aa span {
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.picker-pop {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
padding: 0.3rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.picker-pop button {
|
||||
font-family: var(--font-code, monospace);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* "normal" (the reset entry) reads as text, not a class name. */
|
||||
.picker-pop button.normal {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Table size picker: hover grid popup below the format bar; the hovered
|
||||
|
||||
@@ -5,6 +5,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 { css } from '@codemirror/lang-css'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
@@ -487,6 +489,8 @@ onMounted(async () => {
|
||||
doc: '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
// Tab/Shift-Tab indent and dedent instead of moving focus.
|
||||
keymap.of([indentWithTab]),
|
||||
css(),
|
||||
cmTheme,
|
||||
cmHighlight,
|
||||
@@ -506,6 +510,8 @@ onMounted(async () => {
|
||||
doc: '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
// Tab/Shift-Tab indent and dedent instead of moving focus.
|
||||
keymap.of([indentWithTab]),
|
||||
html(),
|
||||
cmTheme,
|
||||
cmHighlight,
|
||||
|
||||
@@ -78,6 +78,21 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Text size classes for any block ({.small} {.large} {.huge}, set via the
|
||||
format bar or by hand): em units on the single 1rem base scale, so they
|
||||
compose with the theme's typography. */
|
||||
.small {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.huge {
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
/* Links never underline — including SVG link text, which the UA stylesheet
|
||||
underlines by default. */
|
||||
a {
|
||||
@@ -110,7 +125,10 @@ html {
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.05rem;
|
||||
/* 1rem exactly: the text scale (headings, .small/.large/.huge) keys off
|
||||
one base size — no per-context tweaking, or consistent sizing becomes
|
||||
impossible. */
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
/* Tabular numerals wherever the active font supports them; avoids
|
||||
numbers jumping in width as counters/values change. */
|
||||
@@ -1280,6 +1298,24 @@ figure:has(.left) {
|
||||
margin: 0.3rem 1em 1rem 0;
|
||||
}
|
||||
|
||||
/* The same floats for other blocks: ::: left / ::: right containers
|
||||
(rendered div.left/right), paragraphs, code fences, blockquotes and
|
||||
tables all take the class directly ({.right} at the end of a
|
||||
paragraph's last line, a trailing {.left} line after a fence, ...). */
|
||||
:is(div, p, pre, blockquote, table).right {
|
||||
float: right;
|
||||
width: 30%;
|
||||
max-width: 50%;
|
||||
margin: 0.3rem 0 1rem 1em;
|
||||
}
|
||||
|
||||
:is(div, p, pre, blockquote, table).left {
|
||||
float: left;
|
||||
width: 30%;
|
||||
max-width: 50%;
|
||||
margin: 0.3rem 1em 1rem 0;
|
||||
}
|
||||
|
||||
/* An image with an explicit width attribute shrink-wraps instead: the
|
||||
figure fits the image and, per the auto inline margins above, centers
|
||||
in the column. Placed after the percentage widths above so it
|
||||
@@ -1460,8 +1496,8 @@ article h2 {
|
||||
|
||||
/* Phones and other narrow viewports: single-column layout with the
|
||||
sidebar lifted above the article as a wrapping link strip, and no
|
||||
floats at all — .left/.right/.margin figures fall back to plain
|
||||
centered figures (explicit img widths still shrink-wrap), margin boxes
|
||||
floats at all — .left/.right/.margin figures and blocks fall back to
|
||||
plain full-width (explicit img widths still shrink-wrap), margin boxes
|
||||
go full width, while .wide keeps its full viewport bleed. */
|
||||
@media (max-width: 48rem) {
|
||||
|
||||
@@ -1535,6 +1571,14 @@ article h2 {
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
/* Non-figure floated blocks flatten to plain full-width blocks too. */
|
||||
:is(div, p, pre, blockquote, table):is(.right, .left) {
|
||||
float: none;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* Margin boxes go full width too — no room for side floats on a
|
||||
phone. */
|
||||
.aside,
|
||||
|
||||
Reference in New Issue
Block a user