Cursor-driven editor→page scroll sync pinned the cursor's page position at a fixed window height, so every cursor move dragged the page along. Now the page scrolls only when the cursor's mapped position crosses a viewport edge margin, and just enough to bring it back inside.
1213 lines
42 KiB
Vue
1213 lines
42 KiB
Vue
<script setup>
|
||
// Page editor: CodeMirror for Markdown, live server-rendered preview
|
||
// applied straight into the visible article, saving over one WebSocket
|
||
// (/_api/ws/editor). Docked left of the article on the page itself.
|
||
// The socket connects when the editor is opened and reconnects with
|
||
// exponential backoff after a failure; unsaved text and pending saves
|
||
// survive a disconnect. Editor and article (window) scrolls are linked
|
||
// piecewise-linearly both ways, keyed on the section anchors' data-line
|
||
// (syncWindowToEditor / syncEditorToWindow).
|
||
// Saving (💾 / Ctrl+S) is explicit
|
||
// and refreshes the page regions in place — never a reload — so the editor
|
||
// state (unsaved text included) also survives closing the shell. The editor
|
||
// always follows the URL: navigating away retargets it to the new page,
|
||
// stashing unsaved text per path (unsavedStash) so returning to the page
|
||
// restores the working draft; stashes clear on save and on real reload.
|
||
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'
|
||
|
||
const props = defineProps({
|
||
pagePath: { type: String, default: '' },
|
||
})
|
||
const emit = defineEmits(['close', 'pathChange'])
|
||
|
||
const path = ref('')
|
||
const title = ref('')
|
||
const published = ref(true)
|
||
const saveError = ref('')
|
||
const editorEl = ref(null)
|
||
const fileInput = ref(null)
|
||
|
||
let ws = null
|
||
let view = null
|
||
let savedResolve = null
|
||
let pendingSave = null
|
||
let reconnectTimer = null
|
||
let reconnectDelay = 2000
|
||
const MAX_RECONNECT_DELAY = 16000
|
||
let everConnected = false
|
||
const dirty = ref(false) // unsaved text exists (drives the 💾 button)
|
||
let syncingScroll = false
|
||
|
||
function send(msg) {
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify(msg))
|
||
} else {
|
||
ensureConnected()
|
||
}
|
||
}
|
||
|
||
function ensureConnected() {
|
||
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
||
connect()
|
||
}
|
||
}
|
||
|
||
function normPath(p) {
|
||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||
}
|
||
|
||
function pageLabel() {
|
||
return title.value.trim() || ('/' + (path.value || ''))
|
||
}
|
||
|
||
// Show the article title (or path for a new page) plus the edit pen in the
|
||
// window title while editing; the server-rendered public title is restored on
|
||
// close.
|
||
function updateWindowTitle() {
|
||
document.title = `${pageLabel()} 🖊️`
|
||
}
|
||
|
||
watch([title, path], updateWindowTitle)
|
||
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
|
||
|
||
onActivated(() => {
|
||
updateWindowTitle()
|
||
// Re-shown with unsaved text: restore the working preview into the
|
||
// article (closing discarded it in favour of the server-rendered page).
|
||
if (dirty.value) requestRender()
|
||
})
|
||
|
||
function requestRender() {
|
||
// No debounce: server-side rendering is fast enough per keystroke.
|
||
if (!view) return
|
||
dirty.value = true
|
||
send({ type: 'render', path: path.value, title: title.value, markdown: view.state.doc.toString() })
|
||
}
|
||
|
||
function save() {
|
||
// Path is not editable here (that's the structure tab's job); saving
|
||
// never moves the page.
|
||
const markdown = view.state.doc.toString()
|
||
if (markdown.trim() === '') {
|
||
// Empty text means delete — an explicit choice made here, in the page
|
||
// editor; the save APIs (REST PUT / WS save) never delete on empty.
|
||
return fetch(`/_api/pages/${path.value}`, { method: 'DELETE' }).then((res) => {
|
||
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
||
if (res.ok) unsavedStash.delete(path.value)
|
||
})
|
||
}
|
||
const msg = {
|
||
type: 'save',
|
||
path: path.value,
|
||
title: title.value,
|
||
markdown,
|
||
published: published.value,
|
||
}
|
||
pendingSave = msg
|
||
send(msg)
|
||
return new Promise((resolve) => { savedResolve = resolve })
|
||
}
|
||
|
||
async function saveAndRefresh() {
|
||
await save()
|
||
dirty.value = false
|
||
// Refresh the page regions from the server so nav/sidebar changes apply
|
||
// (never a reload: the editor keeps its state). Drop the prefetch cache
|
||
// first: heading/title changes affect navigation on every page.
|
||
dropPageCache()
|
||
loadPlain(path.value)
|
||
}
|
||
|
||
function close() {
|
||
emit('close')
|
||
// Discard the unsaved preview by re-rendering the page from the server.
|
||
// The editor text itself is kept (the shell stays mounted while hidden)
|
||
// and can still be saved later.
|
||
if (dirty.value) loadPlain(path.value)
|
||
}
|
||
|
||
async function uploadImage(file) {
|
||
if (!file) return
|
||
const name = file.name.replace(/[^\w.-]/g, '-')
|
||
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
||
if (res.ok) {
|
||
const { path: stored } = await res.json()
|
||
const alt = name.replace(/\.[^.]+$/, '')
|
||
// 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()
|
||
}
|
||
}
|
||
|
||
// --- Format toolbar --------------------------------------------------------
|
||
// Small Markdown helpers for the hard-to-remember syntax; each leaves the
|
||
// relevant part selected so typing replaces it.
|
||
function wrapInline(mark) {
|
||
// Toggle: wrapped selection (or wrapping marks around it) is unwrapped.
|
||
const { from, to } = view.state.selection.main
|
||
const doc = view.state.doc
|
||
if (doc.sliceString(Math.max(0, from - mark.length), from) === mark
|
||
&& doc.sliceString(to, to + mark.length) === mark) {
|
||
view.dispatch({ changes: [
|
||
{ from: to, to: to + mark.length },
|
||
{ from: from - mark.length, to: from },
|
||
] })
|
||
} else {
|
||
const text = doc.sliceString(from, to)
|
||
view.dispatch({
|
||
changes: { from, to, insert: mark + text + mark },
|
||
selection: { anchor: from + mark.length, head: to + mark.length },
|
||
})
|
||
}
|
||
view.focus()
|
||
}
|
||
|
||
// --- 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 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: fromLine.from, to: fromLine.to, insert: `${openText}\n${closeText}` },
|
||
selection: { anchor: fromLine.from + openText.length },
|
||
})
|
||
} 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
|
||
}
|
||
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() {
|
||
// 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 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
|
||
view.dispatch({
|
||
changes: { from, to, insert },
|
||
selection: isUrl ? { anchor: from + 1 } : { anchor: urlStart },
|
||
})
|
||
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.
|
||
const { from, to } = view.state.selection.main
|
||
const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : ''
|
||
const row = (cells) => `| ${cells.join(' | ')} |`
|
||
const table = `${before}${row(Array(cols).fill('column'))}\n`
|
||
+ `${row(Array(cols).fill('---'))}\n`
|
||
+ `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n`
|
||
view.dispatch({
|
||
changes: { from, to, insert: table },
|
||
selection: { anchor: from + before.length + 2, head: from + before.length + 8 },
|
||
})
|
||
tablePicker.value = false
|
||
view.focus()
|
||
}
|
||
|
||
// Unsaved edits survive navigation within the session: leaving a page
|
||
// stashes its working text here, returning restores it (the server doc
|
||
// still arrives, for title/published and as the base underneath).
|
||
// Entries clear on save and on real reload (the shell is in-memory only).
|
||
const unsavedStash = new Map()
|
||
|
||
function openPath(p) {
|
||
if (dirty.value && path.value && p !== path.value) {
|
||
unsavedStash.set(path.value, view.state.doc.toString())
|
||
}
|
||
path.value = p
|
||
send({ type: 'open', path: p })
|
||
}
|
||
|
||
function setDocument(text, preserveSelection = false) {
|
||
const tr = { changes: { from: 0, to: view.state.doc.length, insert: text } }
|
||
if (preserveSelection) tr.selection = view.state.selection
|
||
view.dispatch(tr)
|
||
}
|
||
|
||
function runScripts(root) {
|
||
// Scripts injected via innerHTML do not execute; re-create them.
|
||
for (const old of root.querySelectorAll('script')) {
|
||
const s = document.createElement('script')
|
||
for (const a of old.attributes) s.setAttribute(a.name, a.value)
|
||
s.textContent = old.textContent
|
||
old.replaceWith(s)
|
||
}
|
||
}
|
||
|
||
function previewIntoArticle(html, multicol) {
|
||
const article = document.querySelector('#main article')
|
||
if (!article) return
|
||
// The server render owns the article completely — the injected title h1,
|
||
// the column layout (.multicol on the article, the .colseg/.cols
|
||
// segments) — so the whole article content swaps as one. Only the edit
|
||
// pen and the category cards survive: detach them before innerHTML wipes
|
||
// them. pagerite.js re-places the pen into the first visible h1 on
|
||
// pagerite:preview.
|
||
article.classList.toggle('multicol', multicol)
|
||
const pen = article.querySelector('button.edit-link')
|
||
if (pen) pen.remove()
|
||
const cards = article.querySelector(':scope > .cards')
|
||
if (cards) cards.remove()
|
||
article.innerHTML = html
|
||
if (cards) article.append(cards)
|
||
runScripts(article)
|
||
dispatchEvent(new CustomEvent('pagerite:preview'))
|
||
}
|
||
|
||
function onMessage(ev) {
|
||
const msg = JSON.parse(ev.data)
|
||
if (msg.type === 'doc' && msg.path === path.value) {
|
||
title.value = msg.title
|
||
published.value = msg.published
|
||
// Restore stashed unsaved edits over the server doc when returning
|
||
// to a page left dirty.
|
||
const stashed = unsavedStash.get(msg.path)
|
||
setDocument(stashed ?? msg.markdown)
|
||
dirty.value = stashed != null
|
||
requestRender()
|
||
// A section pen's target line survives the open/path-switch here.
|
||
consumePendingLine()
|
||
} else if (msg.type === 'html' && msg.path === path.value) {
|
||
previewIntoArticle(msg.html, msg.multicol)
|
||
} else if (msg.type === 'saved') {
|
||
saveError.value = ''
|
||
pendingSave = null
|
||
dirty.value = false
|
||
unsavedStash.delete(path.value)
|
||
savedResolve?.()
|
||
savedResolve = null
|
||
} else if (msg.type === 'error') {
|
||
saveError.value = '⚠️ changes could not be saved'
|
||
}
|
||
}
|
||
|
||
function onKeydown(ev) {
|
||
if (!(ev.ctrlKey || ev.metaKey)) return
|
||
if (ev.key === 's') {
|
||
ev.preventDefault()
|
||
save()
|
||
return
|
||
}
|
||
// Bold/italic only when typing in the CodeMirror editor itself.
|
||
if (!view?.hasFocus) return
|
||
if (ev.key === 'b') {
|
||
ev.preventDefault()
|
||
wrapInline('**')
|
||
} else if (ev.key === 'i') {
|
||
ev.preventDefault()
|
||
wrapInline('*')
|
||
}
|
||
}
|
||
|
||
// The shell stays mounted while hidden: when it is re-shown with this tab
|
||
// active, restore the window title (and the working preview if unsaved
|
||
// text exists — closing discarded it in favour of the server render).
|
||
function onEditorShown() {
|
||
if (document.body.dataset.editorMode !== 'page') return
|
||
updateWindowTitle()
|
||
// Always follow the URL: if the user navigated while the editor was
|
||
// hidden or on another tab, retarget (discarding unsaved text — its
|
||
// preview page is gone); otherwise restore the working preview.
|
||
const p = normPath(props.pagePath)
|
||
if (p !== path.value) openPath(p)
|
||
else if (dirty.value) requestRender()
|
||
consumePendingLine()
|
||
}
|
||
|
||
// Piecewise-linear scroll sync between the CodeMirror scroller and the
|
||
// window (the article's scroller, also while editing), keyed on the
|
||
// section anchors: the backend tags anchored h1/h2 headings with
|
||
// data-line (markdown source line), so each heading pairs a document
|
||
// position with a page position, and positions interpolate linearly
|
||
// between neighbouring headings. Endpoints are the article top (line 1)
|
||
// and the document bottom (last line).
|
||
//
|
||
// Editor → page follows the CURSOR, not the editor viewport: the cursor's
|
||
// fractional line (soft-wrap included, so moving inside a wrapped
|
||
// paragraph tracks smoothly) maps to its page position. The page only
|
||
// scrolls when that position leaves the viewport (with an edge margin),
|
||
// and then just enough to bring it back inside — cursor movement within
|
||
// view never drags the page along. Only cursor/selection changes drive
|
||
// this direction: editor wheel-scrolling repositions the text, not the
|
||
// page, which removes the scroll→scroll echo entirely.
|
||
// Page → editor anchors a viewport fraction that grows with page progress
|
||
// (0 = heading at viewport top when the page is at the top, 1 = viewport
|
||
// bottom at the page's end), so both document ends line up exactly.
|
||
//
|
||
// Both directions apply instantly (never smooth — a smooth window scroll
|
||
// feeds its intermediate positions back into the editor and fights the
|
||
// user's scrolling) and coalesce to one update per frame. Loops are
|
||
// broken two ways: a driver flag held until one frame AFTER the write
|
||
// (the scroll event a programmatic write dispatches arrives
|
||
// asynchronously — clearing the flag in the writing frame would let the
|
||
// echo through and the two directions would chase each other, which
|
||
// showed up as random jumping whenever layout shifted the targets
|
||
// mid-scroll), and a 1px tolerance so residual rounding is a no-op. When
|
||
// the panel's height changes mid-scroll (its top tracks the banner), the
|
||
// page is the driver: the editor is re-matched to the page's position,
|
||
// never vice versa.
|
||
|
||
// [markdown line (1-based), window Y] control points, ascending in both.
|
||
function syncPoints() {
|
||
const article = document.querySelector('#main article')
|
||
if (!article || !view) return null
|
||
const pts = [[1, article.getBoundingClientRect().top + scrollY]]
|
||
for (const h of article.querySelectorAll('[data-line]')) {
|
||
pts.push([+h.dataset.line + 1, h.getBoundingClientRect().top + scrollY])
|
||
}
|
||
pts.push([view.state.doc.lines, document.documentElement.scrollHeight])
|
||
return pts.sort((a, b) => a[0] - b[0])
|
||
}
|
||
|
||
// Piecewise-linear map of v from column `from` to column `to`, clamped to
|
||
// the segment ends.
|
||
function interp(pts, v, from, to) {
|
||
let i = 1
|
||
while (i < pts.length - 1 && pts[i][from] < v) i++
|
||
const [a0, b0] = [pts[i - 1][from], pts[i - 1][to]]
|
||
const [a1, b1] = [pts[i][from], pts[i][to]]
|
||
const t = a1 > a0 ? (v - a0) / (a1 - a0) : 0
|
||
return b0 + Math.max(0, Math.min(1, t)) * (b1 - b0)
|
||
}
|
||
|
||
// Editor scroller top showing the fractional markdown line.
|
||
function editorTopFor(line) {
|
||
const scroller = view.scrollDOM
|
||
const max = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||
if (line >= view.state.doc.lines) return max
|
||
const n = Math.max(1, Math.floor(line))
|
||
const block = view.lineBlockAt(view.state.doc.line(n).from)
|
||
return Math.min(max, block.top + (line - n) * block.height)
|
||
}
|
||
|
||
//: Edge margin (window height fraction) for cursor-driven page scrolls.
|
||
const CURSOR_MARGIN = 1 / 8
|
||
|
||
function syncWindowToEditor() {
|
||
if (syncingScroll || !view) return
|
||
syncingScroll = true
|
||
requestAnimationFrame(() => {
|
||
const pts = syncPoints()
|
||
if (pts) {
|
||
// Scroll the page only when the cursor's page position leaves the
|
||
// viewport (minus an edge margin): while it stays visible the page
|
||
// keeps its position, so cursor movement does not drag the page
|
||
// along; crossing an edge scrolls just enough to bring it back.
|
||
const pos = view.state.selection.main.head
|
||
const coords = view.coordsAtPos(pos)
|
||
if (coords) {
|
||
const scroller = view.scrollDOM
|
||
const block = view.lineBlockAt(pos)
|
||
const docY = coords.top - scroller.getBoundingClientRect().top + scroller.scrollTop
|
||
const frac = block.height > 0
|
||
? Math.max(0, Math.min(1, (docY - block.top) / block.height))
|
||
: 0
|
||
const line = view.state.doc.lineAt(pos).number + frac
|
||
const y = interp(pts, line, 0, 1)
|
||
const margin = CURSOR_MARGIN * innerHeight
|
||
let target = null
|
||
if (y < scrollY + margin) target = y - margin
|
||
else if (y > scrollY + innerHeight - margin) target = y - innerHeight + margin
|
||
if (target !== null && Math.abs(scrollY - target) > 1) {
|
||
scrollTo({ top: Math.max(0, target), behavior: 'instant' })
|
||
}
|
||
}
|
||
}
|
||
requestAnimationFrame(() => { syncingScroll = false })
|
||
})
|
||
}
|
||
|
||
function syncEditorToWindow() {
|
||
if (syncingScroll || !view) return
|
||
syncingScroll = true
|
||
requestAnimationFrame(() => {
|
||
const pts = syncPoints()
|
||
if (pts) {
|
||
// Anchor fraction grows with page progress: the mapped line sits at
|
||
// the viewport top when the page is at its top, at the bottom when
|
||
// scrolled all the way down.
|
||
const pageMax = Math.max(0, document.documentElement.scrollHeight - innerHeight)
|
||
const a = pageMax > 0 ? scrollY / pageMax : 0
|
||
const line = interp(pts, scrollY + a * innerHeight, 1, 0)
|
||
const scroller = view.scrollDOM
|
||
const top = editorTopFor(line) - a * scroller.clientHeight
|
||
const max = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||
const clamped = Math.max(0, Math.min(max, top))
|
||
if (Math.abs(scroller.scrollTop - clamped) > 1) scroller.scrollTop = clamped
|
||
}
|
||
requestAnimationFrame(() => { syncingScroll = false })
|
||
})
|
||
}
|
||
|
||
// Jump both views to a markdown source line (0-based, as carried by the
|
||
// section pens' data-line / window.__pageriteEditLine).
|
||
function scrollToSourceLine(line) {
|
||
if (!view || line == null) return
|
||
const n = Math.max(1, Math.min(line + 1, view.state.doc.lines))
|
||
const pos = view.state.doc.line(n).from
|
||
view.dispatch({
|
||
selection: { anchor: pos },
|
||
effects: EditorView.scrollIntoView(pos, { y: 'start', yMargin: 8 }),
|
||
})
|
||
const h = document.querySelector(`#main article [data-line="${line}"]`)
|
||
if (h) scrollTo({ top: h.getBoundingClientRect().top + scrollY, behavior: 'instant' })
|
||
}
|
||
|
||
// A section pen carries its line in window.__pageriteEditLine; consume it
|
||
// once the document is here (fresh open, path switch, re-shown shell).
|
||
function consumePendingLine() {
|
||
const line = window.__pageriteEditLine
|
||
if (line == null) return
|
||
delete window.__pageriteEditLine
|
||
scrollToSourceLine(line)
|
||
}
|
||
|
||
function connect() {
|
||
ws = new WebSocket(
|
||
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
|
||
)
|
||
ws.onmessage = onMessage
|
||
ws.onopen = () => {
|
||
reconnectDelay = 2000
|
||
if (everConnected) {
|
||
// Reconnected: local text is authoritative — don't re-open (that
|
||
// would clobber the editor), just resync preview and pending saves.
|
||
requestRender()
|
||
if (pendingSave) send(pendingSave)
|
||
} else {
|
||
openPath(normPath(props.pagePath))
|
||
}
|
||
everConnected = true
|
||
}
|
||
ws.onclose = () => {
|
||
clearTimeout(reconnectTimer)
|
||
reconnectTimer = setTimeout(() => {
|
||
connect()
|
||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||
}, reconnectDelay)
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
connect()
|
||
updateWindowTitle()
|
||
|
||
view = new EditorView({
|
||
state: EditorState.create({
|
||
doc: '',
|
||
extensions: [
|
||
basicSetup,
|
||
// Tab/Shift-Tab indent and dedent instead of moving focus.
|
||
keymap.of([indentWithTab]),
|
||
markdown(),
|
||
cmTheme,
|
||
cmHighlight,
|
||
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
|
||
EditorView.updateListener.of((u) => {
|
||
if (u.docChanged) requestRender()
|
||
// Cursor moves (typing included) drive the page scroll sync.
|
||
if (u.selectionSet) syncWindowToEditor()
|
||
}),
|
||
EditorView.domEventHandlers({
|
||
paste(ev) {
|
||
// Paste an image straight into the article: upload + insert
|
||
const file = [...(ev.clipboardData?.files || [])]
|
||
.find((f) => f.type.startsWith('image/'))
|
||
if (file) {
|
||
ev.preventDefault()
|
||
uploadImage(file)
|
||
}
|
||
},
|
||
}),
|
||
],
|
||
}),
|
||
parent: editorEl.value,
|
||
})
|
||
// Page → editor: window scroll (and resizes, e.g. the panel growing when
|
||
// the banner scrolls away) re-match the editor to the page's position.
|
||
// The other direction is cursor-driven (updateListener above), never
|
||
// scroll-driven — an editor scroll moves text, not the page.
|
||
addEventListener('scroll', syncEditorToWindow, { passive: true })
|
||
addEventListener('resize', syncEditorToWindow)
|
||
// Opening the editor means you want to write: start focused.
|
||
view.focus()
|
||
window.__pageritePageEditor = {
|
||
getMarkdown: () => view.state.doc.toString(),
|
||
setMarkdown: (text) => setDocument(text, true),
|
||
path: () => path.value,
|
||
}
|
||
addEventListener('keydown', onKeydown)
|
||
addEventListener('pagerite:editor-shown', onEditorShown)
|
||
// A section pen clicked while the page editor is already open.
|
||
addEventListener('pagerite:edit-section', consumePendingLine)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
clearTimeout(reconnectTimer)
|
||
if (ws) {
|
||
ws.onclose = null // intentional close, no reconnect
|
||
ws.close()
|
||
}
|
||
view?.destroy()
|
||
delete window.__pageritePageEditor
|
||
removeEventListener('scroll', syncEditorToWindow)
|
||
removeEventListener('resize', syncEditorToWindow)
|
||
removeEventListener('keydown', onKeydown)
|
||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||
removeEventListener('pagerite:edit-section', consumePendingLine)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-editor">
|
||
<header class="toolbar">
|
||
<label class="title-field">
|
||
<span class="field-label">title</span>
|
||
<input v-model="title" class="title" @input="requestRender" />
|
||
</label>
|
||
<label><input v-model="published" type="checkbox" /> published</label>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
@change="(ev) => { uploadImage(ev.target.files[0]); ev.target.value = '' }"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="save"
|
||
title="save (Ctrl+S)"
|
||
:disabled="!dirty"
|
||
@click="saveAndRefresh"
|
||
>💾</button>
|
||
</header>
|
||
<div class="format-bar">
|
||
<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 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
|
||
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
|
||
:key="n"
|
||
type="button"
|
||
class="tp-cell"
|
||
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||
@click="insertTable(tableSize.cols, tableSize.rows)"
|
||
/>
|
||
</div>
|
||
<div class="tp-size">{{ tableSize.cols || '–' }} × {{ tableSize.rows || '–' }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="saveError">{{ saveError }}</div>
|
||
<div class="panes">
|
||
<div ref="editorEl" class="editor" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-editor {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.8rem;
|
||
padding: 0.5rem 1rem;
|
||
border-bottom: 1px solid var(--line);
|
||
background: var(--surface);
|
||
}
|
||
|
||
.toolbar .title-field {
|
||
flex: 1;
|
||
min-width: 4rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.toolbar .field-label {
|
||
color: var(--muted);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.toolbar .title {
|
||
flex: 1;
|
||
min-width: 4rem;
|
||
font: inherit;
|
||
padding: 0.2rem 0.5rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border: 1px solid var(--line);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.toolbar label {
|
||
color: var(--muted);
|
||
font-size: 0.85rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Borderless icon button; greyed out (desaturated) while there is nothing
|
||
to save. */
|
||
.toolbar button.save {
|
||
padding: 0 0.3rem;
|
||
font-size: 1rem;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Disabled = black & white only; an explicit color overrides the browser's
|
||
built-in dimmed disabled-button color. */
|
||
.toolbar button.save:disabled {
|
||
color: var(--text);
|
||
filter: saturate(0);
|
||
cursor: default;
|
||
}
|
||
|
||
/* Markdown helpers: plain icon buttons under the toolbar. */
|
||
.format-bar {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
padding: 0.25rem 1rem;
|
||
border-bottom: 1px solid var(--line);
|
||
background: var(--surface);
|
||
}
|
||
|
||
.format-bar button {
|
||
min-width: 1.7rem;
|
||
padding: 0.05rem 0.2rem;
|
||
font: inherit;
|
||
font-size: 1.05rem;
|
||
color: var(--muted);
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Hover and selected (active) states: text color alone, no borders. */
|
||
.format-bar button:hover,
|
||
.format-bar button.active {
|
||
color: var(--text);
|
||
}
|
||
|
||
/* 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
|
||
cell and everything up-left of it is the table to insert. */
|
||
.table-picker {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 6.5rem;
|
||
z-index: 20;
|
||
padding: 0.5rem;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 16px #0004;
|
||
}
|
||
|
||
.tp-grid {
|
||
display: grid;
|
||
gap: 2px;
|
||
}
|
||
|
||
.tp-cell {
|
||
width: 1.05rem;
|
||
height: 1.05rem;
|
||
min-width: 0;
|
||
padding: 0;
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.tp-cell.on {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.tp-size {
|
||
margin-top: 0.35rem;
|
||
color: var(--muted);
|
||
font-size: 0.8rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.panes {
|
||
flex: 1;
|
||
display: flex;
|
||
min-height: 0;
|
||
padding: 0.6rem 1rem;
|
||
gap: 1rem;
|
||
background: var(--surface); /* dialog body, same as the toolbar */
|
||
}
|
||
|
||
/* CodeMirror sits inside a bordered box, like a dialog's input area, with
|
||
a slight margin to the panel edges. Wheel scroll stays in the editor
|
||
(overscroll-behavior) instead of double-scrolling the page. */
|
||
.editor {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
overscroll-behavior: contain;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.editor :deep(.cm-editor) {
|
||
height: 100%;
|
||
}
|
||
|
||
/* No line numbers / gutter chrome. */
|
||
.editor :deep(.cm-gutters) {
|
||
display: none;
|
||
}
|
||
</style>
|