Files
pagerite/frontend/src/PageEditor.vue
T

1462 lines
53 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 and language (stashes) so returning to the
// page restores the working draft; stashes clear on save and on real reload.
//
// Languages: the editor always starts in the primary language; the
// toolbar's LangSelect (shared with the structure tab via ./editorLang)
// switches between the primary language and its translations. A translation is
// edited as its effective (hybrid) Markdown; the hybrid the session
// started from is kept as a shadow copy (shadowBase) and sent along at
// save time, so the server diffs the user's changes only and stores them
// as a patch — edits to a translation never touch the original, while
// edits to the primary language re-chunk the original (and thereby
// invalidate the affected translation fragments). The live preview always
// renders the version being edited, whichever language the page itself
// was loaded in.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePopup } from './dropdown'
import { EditorView, basicSetup } from 'codemirror'
import { Compartment, 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 { flagFor, langName } from './langs'
import { editorLang, pagePrimary } from './editorLang'
import LangSelect from './LangSelect.vue'
import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
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)
// The language being edited: "" = the primary language, where the editor
// always starts (a served translation does not follow it into the editor;
// the picker switches). The server normalizes the primary to "" anyway.
// Shared with the other tabs (./editorLang) — one selection for the whole
// shell, and for the page preview while the shell is open.
const lang = editorLang
const primaryLang = ref('en')
const pageLangs = ref([]) // translations this page has
const siteLangs = ref([]) // site-wide configured target languages
// The shadow copy: the Markdown this editing session started from, sent as
// "base" on translated saves so the server diffs the user's changes only.
let shadowBase = ''
let titleTouched = false
// The (path, lang) the editor's current content came from: set when a doc
// is accepted, and the test for whether a reconnected socket must re-open
// (a dropped open would otherwise leave the editor empty/stale forever).
let sessionDoc = null
// Connection state drives the note above the editor (ConnNote), and locks
// input until the page's document has arrived: typing before the accept
// would be clobbered by it. While merely DISconnected the editor stays
// editable — text stashes and pending saves flush on reconnect.
const conn = ref('connecting') // connecting | open | waiting
const retryIn = ref(0)
const docReady = ref(false)
const editable = new Compartment()
const connNote = computed(() =>
conn.value === 'connecting' ? 'connecting to the server…'
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
: docReady.value ? '' : 'loading the page…',
)
let ws = null
let view = null
let savedResolve = null
let pendingSave = null
let reconnectTimer = null
let connectWatchdog = null
// Reconnection pacing lives in ./reconnect (shared with the other sockets):
// doubling backoff with jitter, reset only by a healthy connection — rapid
// retries trip the browser's WebSocket throttling (sockets stuck "pending"
// for minutes), which is what kept hollowing out the editor.
const reconnects = reconnectPolicy()
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, '')
}
// --- Language picker -------------------------------------------------------
// Flag icons and display names come from ./langs (shared with the
// localization settings tab).
// The picker's options: the primary language first, then the union of the
// page's translations and the site-wide configured targets, sorted.
const langOptions = computed(() => {
const others = [...new Set([...siteLangs.value, ...pageLangs.value])]
.filter((l) => l && l !== primaryLang.value)
.sort()
return [primaryLang.value, ...others].map((code) => ({
tag: code === primaryLang.value ? '' : code,
code,
name: langName(code),
flag: flagFor(code),
primary: code === primaryLang.value,
}))
})
const currentLang = computed(
() => langOptions.value.find((o) => o.tag === lang.value)
?? { tag: '', code: lang.value || primaryLang.value, name: langName(lang.value || primaryLang.value), flag: flagFor(lang.value || primaryLang.value), primary: !lang.value },
)
// The picker's selection is the shell-wide shared language (./editorLang):
// a change stashes the working text under the PREVIOUS language (the view
// still holds that doc) and opens the current page in the new one.
watch(lang, (tag, prev) => {
if (!view) return
stashAs(prev || '')
openPath(path.value)
})
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() === '') {
if (lang.value) {
// Emptying a translation would render it as a blank page; deleting
// pages is a primary-language action.
saveError.value = '⚠️ a translation cannot be emptied'
return Promise.resolve()
}
// 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) stashes.delete(stashKey(path.value, lang.value))
})
}
const msg = {
type: 'save',
path: path.value,
title: title.value,
markdown,
published: published.value,
}
if (lang.value) {
msg.lang = lang.value
// The shadow copy this session started from: the server diffs base →
// markdown and stores only the user's changes as a patch.
msg.base = shadowBase
// An untouched title field is not sent: it holds the served
// translation, which a save must not freeze into an override fragment.
if (!titleTouched) delete msg.title
}
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. A
// translation save keeps the preview as-is — it already shows the saved
// text, and loadPlain would swap the article to the header-selected
// language's render.
dropPageCache()
if (!lang.value) 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 = `![${alt}](${stored} "")`
// 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: pos, insert: text },
selection: { anchor: pos + text.indexOf(insert) + 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.
// All popups share the close behavior of ./dropdown (outside click /
// Escape; never mouseleave).
const classPicker = ref(null) // 'place' | 'size' | null
const activeClasses = ref(new Set())
const placeRoot = ref(null)
const sizeRoot = ref(null)
const tableRoot = ref(null)
usePopup(classPicker, computed(() => (classPicker.value === 'place' ? placeRoot : sizeRoot).value))
usePopup(tablePicker, tableRoot)
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. 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 grid = `${row(Array(cols).fill('column'))}\n`
+ `${row(Array(cols).fill('---'))}\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: pos, insert: text },
selection: { anchor, head: anchor + 6 },
})
tablePicker.value = false
view.focus()
}
// Unsaved edits survive navigation within the session: leaving a page (or
// switching the language) stashes its working text and shadow base here,
// returning restores them (the server doc still arrives, for
// title/published and language metadata). Entries clear on save and on
// real reload (the shell is in-memory only).
const stashes = new Map()
const stashKey = (p, l) => `${p}|${l}`
function stashAs(l) {
if (dirty.value && path.value) {
stashes.set(stashKey(path.value, l), {
text: view.state.doc.toString(),
base: shadowBase,
})
}
}
function stashCurrent() {
stashAs(lang.value)
}
function openPath(p) {
if (p !== path.value) stashCurrent()
path.value = p
// Lock input until the doc arrives (typing would be clobbered by it).
docReady.value = false
view?.dispatch({ effects: editable.reconfigure(EditorView.editable.of(false)) })
send({ type: 'open', path: p, lang: lang.value })
}
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)
// The doc must answer the current path and language; before the first
// doc the language is not yet server-normalized (the primary arrives as
// "" while the picker may have started from an explicit code), so the
// first doc is accepted on path alone and adopts the echoed language.
if (msg.type === 'doc' && msg.path === path.value
&& (!sessionDoc || (msg.lang || '') === lang.value)) {
title.value = msg.title
published.value = msg.published
primaryLang.value = msg.primary_lang || 'en'
pagePrimary.value = primaryLang.value // the page's own — the shell pins the preview by it
pageLangs.value = msg.langs || []
siteLangs.value = msg.translate_langs || []
lang.value = msg.lang || ''
sessionDoc = { path: msg.path, lang: lang.value }
docReady.value = true
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
titleTouched = false
// Restore stashed unsaved edits over the server doc when returning
// to a page left dirty.
const stashed = stashes.get(stashKey(msg.path, lang.value))
setDocument(stashed ? stashed.text : msg.markdown)
shadowBase = stashed ? stashed.base : msg.markdown
dirty.value = stashed != null
requestRender()
// A section pen's target line survives the open/path-switch here.
consumePendingLine()
} else if (msg.type === 'doc') {
// A doc that answered neither path nor language of the current session
// (a late reply to a pre-switch open) — visible because it leaves the
// editor empty when it's the only doc that ever arrives.
console.warn(
'[pagerite] doc dropped:', msg.path, msg.lang || '(primary)',
'— editor is on', path.value, lang.value || '(primary)',
)
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.multicol)
} else if (msg.type === 'saved') {
// A late "saved" for a save sent before a path/language switch must not
// clear the current session's state.
if (pendingSave && pendingSave.path === path.value
&& (pendingSave.lang || '') === lang.value) {
saveError.value = ''
// The saved text becomes the shadow base for further saves.
shadowBase = pendingSave.markdown
pendingSave = null
dirty.value = false
titleTouched = false
stashes.delete(stashKey(path.value, lang.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() {
clearTimeout(reconnectTimer)
conn.value = 'connecting'
if (ws) {
// Replacing a stale socket: detach its handlers so its close is silent.
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
if (ws.readyState !== WebSocket.CLOSED) ws.close()
}
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
ws.onerror = (ev) => {
console.error('[pagerite] editor socket error', ev)
}
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'editor')
ws.onopen = () => {
conn.value = 'open'
reconnects.opened()
if (sessionDoc && sessionDoc.path === path.value && sessionDoc.lang === lang.value) {
// 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 {
// No doc behind the current page+language (first connect, or its
// open went down with a previous socket): open fresh, or the editor
// would stay empty forever.
openPath(normPath(props.pagePath))
}
}
ws.onclose = (ev) => {
// 1006 = abnormal (e.g. the dev proxy refused/dropped the upgrade, or
// the connecting watchdog fired); worth seeing since a dead socket
// before the first doc bricks the editor until a retry lands one.
// The wait is the policy's: doubling backoff with jitter (./reconnect).
console.warn('[pagerite] editor socket closed:', ev.code, ev.reason || '')
const wait = reconnects.closed()
retryIn.value = Math.max(1, Math.round(wait / 1000))
conn.value = 'waiting'
reconnectTimer = setTimeout(connect, wait)
}
}
onMounted(() => {
// The first connection takes a staggered slot (see ./reconnect): page
// load opens several sockets at once, and the burst trips throttling.
reconnectTimer = setTimeout(connect, socketSlot())
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
// Locked until the page's document arrives (docReady/ConnNote).
editable.of(EditorView.editable.of(false)),
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)
clearTimeout(connectWatchdog)
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">
<LangSelect v-model="lang" :options="langOptions" />
<label class="title-field">
<span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender(); titleTouched = true" />
</label>
<label :title="lang ? 'translations follow the original pages published state' : ''"><input v-model="published" type="checkbox" :disabled="!!lang" /> 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>
<ConnNote :text="connNote" />
<div v-if="langOptions.length > 1" class="lang-note">
<template v-if="lang">
{{ currentLang.name }} translation edits affect only this language.
</template>
<template v-else>
{{ currentLang.name }} is the primary language edits here affect all translations.
</template>
</div>
<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>&lt;/&gt;</code></button>
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
<span class="picker" ref="tableRoot">
<button
type="button"
title="table"
:class="{ active: tablePicker }"
@click="tablePicker = !tablePicker"
></button>
<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>
</span>
<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" ref="placeRoot">
<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" ref="sizeRoot">
<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;
}
/* The language selector (toolbar, left) is LangSelect.vue — its styles
live there. */
/* Why a language is highlighted: primary edits fan out to translations,
translation edits stay local to that language. */
.lang-note {
padding: 0.2rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: 0.8rem;
}
/* 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: 0;
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>