Replace the old "patches" format (path:lang composite keys, ordered hunk lists, text-anchored matching) with Data.overrides: path -> lang -> LangEdits, keyed throughout so a save's database diff touches only the edited chunks. The old "patches" key is ignored on decode, discarding legacy data without a migration. - Whole-paragraph additions/deletions are structural: a drop flag on the original chunk hash, and additions in their own dict anchored from the neighboring chunks' before/after (first live referrer wins), so they stay in place across retranslation and one-sided original edits. - Within-paragraph edits (up to a full paragraph rewrite or split) are full-chunk replace patches applied by chunk hash alone: a retranslation is overridden wholesale, so user edits survive AI re-runs; editing the original changes the hash and orphans the patch. The old search-matching staleness gate is gone. - Saving a translation on a page without original chunks is rejected (REST 400 / WS error); emptying the original afterwards renders the translation empty, with the orphaned overrides inert.
1463 lines
54 KiB
Vue
1463 lines
54 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 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 per-chunk overrides — 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 { apiFetch } from 'paskia'
|
||
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, langSort } 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 in the lang
|
||
// tab's geographic grouping (./langs langSort).
|
||
const langOptions = computed(() => {
|
||
const others = langSort(
|
||
[...new Set([...siteLangs.value, ...pageLangs.value])]
|
||
.filter((l) => l && l !== primaryLang.value),
|
||
)
|
||
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 apiFetch(`/_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 overrides.
|
||
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 apiFetch(`/_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 = ``
|
||
// 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), the card stacks ({cards} tags expanded, or the children's
|
||
// cards appended when the page has no tag) — so the whole article
|
||
// content swaps as one. Only the edit pen survives: detach it before
|
||
// innerHTML wipes it. 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()
|
||
article.innerHTML = html
|
||
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 page’s 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></></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>
|