| 1 | // Dev-only: makes `[data-copy]` elements editable and saves edits to source. |
| 2 | // Served by the copy-editor Vite plugin during `vite serve`; never built. |
| 3 | // |
| 4 | // Two editing modes: |
| 5 | // - inline: the element's rendered text is edited in place and mapped back |
| 6 | // onto its source text runs; |
| 7 | // - markdown (`data-copy-format="markdown"`): focusing the section swaps in |
| 8 | // its markdown source; leaving it saves the markdown and shows a preview |
| 9 | // rendered with the section's style (`data-copy-style`) until the page's |
| 10 | // own renderer catches up. |
| 11 | |
| 12 | import config from "virtual:copy-editor/config"; |
| 13 | import { marked } from "marked"; |
| 14 | import type { MarkdownStyle } from "./index.ts"; |
| 15 | |
| 16 | const SAVE_ENDPOINT = "/__copy-editor/save"; |
| 17 | const SOURCE_ENDPOINT = "/__copy-editor/source"; |
| 18 | const REFRESH_EVENT = "copy-editor:refresh"; |
| 19 | const SELECTOR = "[data-copy]"; |
| 20 | const styles = config.styles as Record<string, MarkdownStyle>; |
| 21 | |
| 22 | const controller = new AbortController(); |
| 23 | const { signal } = controller; |
| 24 | const listen = <K extends keyof DocumentEventMap>( |
| 25 | type: K, |
| 26 | handler: (event: DocumentEventMap[K]) => void, |
| 27 | capture = false, |
| 28 | ) => document.addEventListener(type, handler, { signal, capture }); |
| 29 | |
| 30 | const style = document.createElement("style"); |
| 31 | style.textContent = ` |
| 32 | ${SELECTOR} { cursor: text; } |
| 33 | ${SELECTOR}:hover { outline: 1px dashed rgb(255 120 80 / 0.7); outline-offset: 3px; } |
| 34 | ${SELECTOR}:focus { |
| 35 | outline: 2px solid rgb(255 120 80); |
| 36 | outline-offset: 3px; |
| 37 | background: rgb(255 120 80 / 0.08); |
| 38 | } |
| 39 | /* Source keeps the element's own font so the text doesn't jump; only |
| 40 | multi-line markdown needs its line breaks shown. */ |
| 41 | :is([data-copy-editing], [data-copy-draft]) { |
| 42 | white-space: pre-wrap; |
| 43 | } |
| 44 | [data-copy-draft] { outline: 2px dashed #d33 !important; } |
| 45 | [data-copy-empty]:not([data-copy-editing]) { min-height: 1em; } |
| 46 | [data-copy-empty]:not([data-copy-editing]):is(:hover, :focus)::before { |
| 47 | content: "Add markdown…"; opacity: 0.45; font-style: italic; |
| 48 | } |
| 49 | #copy-editor-toast { |
| 50 | position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; |
| 51 | max-width: 360px; padding: 8px 12px; border-radius: 8px; |
| 52 | font: 600 12px/1.3 system-ui, sans-serif; color: #fff; background: #1f6f3a; |
| 53 | box-shadow: 0 2px 10px rgb(0 0 0 / 0.4); |
| 54 | opacity: 0; transition: opacity 0.2s; pointer-events: none; |
| 55 | } |
| 56 | #copy-editor-toast[data-error] { background: #8c1d18; } |
| 57 | #copy-editor-toast[data-show] { opacity: 1; } |
| 58 | ${Object.values(styles) |
| 59 | .map((s) => s.css ?? "") |
| 60 | .join("\n")} |
| 61 | `; |
| 62 | const toast = document.createElement("div"); |
| 63 | toast.id = "copy-editor-toast"; |
| 64 | document.head.append(style); |
| 65 | document.body.append(toast); |
| 66 | |
| 67 | let toastTimer: ReturnType<typeof setTimeout> | undefined; |
| 68 | const notify = (message: string, error = false) => { |
| 69 | toast.textContent = message; |
| 70 | toast.toggleAttribute("data-error", error); |
| 71 | toast.setAttribute("data-show", ""); |
| 72 | clearTimeout(toastTimer); |
| 73 | toastTimer = setTimeout( |
| 74 | () => toast.removeAttribute("data-show"), |
| 75 | error ? 6000 : 1800, |
| 76 | ); |
| 77 | }; |
| 78 | |
| 79 | const isMarkdown = (el: HTMLElement) => |
| 80 | el.dataset.copyFormat === "markdown" || isInline(el); |
| 81 | /** Template copy edited as one line of inline markdown. */ |
| 82 | const isInline = (el: HTMLElement) => el.dataset.copyFormat === "inline"; |
| 83 | |
| 84 | /** |
| 85 | * Renders inline markdown, copying attributes (classes, scoped styles) from |
| 86 | * the same tags in the element's previous rendering. |
| 87 | */ |
| 88 | const renderInline = (source: string, rendered: string) => { |
| 89 | const previous = document.createElement("template"); |
| 90 | previous.innerHTML = rendered; |
| 91 | const template = document.createElement("template"); |
| 92 | template.innerHTML = marked.parseInline(source, { |
| 93 | async: false, |
| 94 | gfm: true, |
| 95 | breaks: true, |
| 96 | }); |
| 97 | for (const node of template.content.querySelectorAll("*")) { |
| 98 | const sample = previous.content.querySelector(node.tagName); |
| 99 | for (const attr of sample?.attributes ?? []) { |
| 100 | if (attr.name !== "href") node.setAttribute(attr.name, attr.value); |
| 101 | } |
| 102 | } |
| 103 | return template.innerHTML; |
| 104 | }; |
| 105 | |
| 106 | const markEmpty = (el: HTMLElement) => { |
| 107 | if (isMarkdown(el) && !el.dataset.copyEditing) { |
| 108 | el.toggleAttribute("data-copy-empty", el.textContent?.trim() === ""); |
| 109 | } |
| 110 | }; |
| 111 | |
| 112 | const makeEditable = (el: HTMLElement) => { |
| 113 | markEmpty(el); |
| 114 | if (el.isContentEditable) return; |
| 115 | // "true", not "plaintext-only": Chrome forces `white-space: pre-wrap` on |
| 116 | // plaintext-only elements, which renders source line breaks. Rich-text |
| 117 | // input is blocked below instead. |
| 118 | el.contentEditable = "true"; |
| 119 | el.spellcheck = false; |
| 120 | }; |
| 121 | |
| 122 | // Content rendered outside Vite transforms (markdown, generated docs) marks |
| 123 | // its element with a trailing `<!--copy:ID-->` comment instead of an attribute. |
| 124 | const MARKER = /^copy:(.+)$/; |
| 125 | const claimMarkers = (root: Node) => { |
| 126 | const doc = root.ownerDocument ?? (root as Document); |
| 127 | const walker = doc.createTreeWalker(root, NodeFilter.SHOW_COMMENT); |
| 128 | const markers: Comment[] = []; |
| 129 | if (root.nodeType === Node.COMMENT_NODE) markers.push(root as Comment); |
| 130 | while (walker.nextNode()) markers.push(walker.currentNode as Comment); |
| 131 | for (const marker of markers) { |
| 132 | const id = MARKER.exec(marker.data)?.[1]; |
| 133 | const parent = marker.parentElement; |
| 134 | if (!id || !parent) continue; |
| 135 | parent.setAttribute("data-copy", id); |
| 136 | marker.remove(); |
| 137 | } |
| 138 | }; |
| 139 | |
| 140 | const enableWithin = (root: ParentNode) => { |
| 141 | claimMarkers(root as Node); |
| 142 | if (root instanceof HTMLElement && root.matches(SELECTOR)) makeEditable(root); |
| 143 | for (const el of root.querySelectorAll<HTMLElement>(SELECTOR)) |
| 144 | makeEditable(el); |
| 145 | }; |
| 146 | enableWithin(document); |
| 147 | |
| 148 | // Elements rendered later (client routing, framework HMR) become editable too. |
| 149 | const observer = new MutationObserver((records) => { |
| 150 | for (const record of records) { |
| 151 | if (record.type === "attributes") |
| 152 | enableWithin(record.target as HTMLElement); |
| 153 | for (const node of record.addedNodes) { |
| 154 | if (node instanceof HTMLElement) enableWithin(node); |
| 155 | else if (node.nodeType === Node.COMMENT_NODE) { |
| 156 | const parent = node.parentElement; |
| 157 | claimMarkers(node); |
| 158 | if (parent) enableWithin(parent); |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | }); |
| 163 | observer.observe(document.body, { |
| 164 | subtree: true, |
| 165 | childList: true, |
| 166 | attributes: true, |
| 167 | attributeFilter: ["data-copy"], |
| 168 | }); |
| 169 | |
| 170 | const target = (event: Event) => |
| 171 | event.target instanceof Element |
| 172 | ? event.target.closest<HTMLElement>(SELECTOR) |
| 173 | : null; |
| 174 | |
| 175 | const post = async (url: string, body: object) => { |
| 176 | const res = await fetch(url, { |
| 177 | method: "POST", |
| 178 | headers: { "content-type": "application/json" }, |
| 179 | body: JSON.stringify(body), |
| 180 | }).catch((error: unknown) => ({ |
| 181 | ok: false, |
| 182 | json: async () => ({ error: String(error) }), |
| 183 | })); |
| 184 | const json = (await res.json().catch(() => ({}))) as { |
| 185 | file?: string; |
| 186 | changed?: boolean; |
| 187 | reload?: boolean; |
| 188 | error?: string; |
| 189 | }; |
| 190 | return { ok: res.ok, ...json }; |
| 191 | }; |
| 192 | |
| 193 | // ── inline text edits ───────────────────────────────────────────────────── |
| 194 | |
| 195 | const textNodes = (el: HTMLElement): string[] => { |
| 196 | const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); |
| 197 | const out: string[] = []; |
| 198 | while (walker.nextNode()) out.push((walker.currentNode as Text).data); |
| 199 | return out; |
| 200 | }; |
| 201 | |
| 202 | /** Browsers type spaces as non-breaking ones at run edges; keep real ones. */ |
| 203 | const plainSpaces = (after: string[], before: string[]) => |
| 204 | after.map((text, i) => |
| 205 | before[i]?.includes("\u00a0") ? text : text.replace(/\u00a0/g, " "), |
| 206 | ); |
| 207 | |
| 208 | interface Snapshot { |
| 209 | html: string; |
| 210 | texts: string[]; |
| 211 | } |
| 212 | const snapshots = new WeakMap<HTMLElement, Snapshot>(); |
| 213 | |
| 214 | const saveInline = async (el: HTMLElement) => { |
| 215 | const snap = snapshots.get(el); |
| 216 | if (!snap) return; |
| 217 | snapshots.delete(el); |
| 218 | const after = plainSpaces(textNodes(el), snap.texts); |
| 219 | if (after.join("\u0000") === snap.texts.join("\u0000")) return; |
| 220 | const result = await post(SAVE_ENDPOINT, { |
| 221 | id: el.dataset.copy, |
| 222 | before: snap.texts, |
| 223 | after, |
| 224 | }); |
| 225 | if (result.ok) { |
| 226 | if (result.changed) notify(`Saved to ${result.file}`); |
| 227 | if (result.reload) needsReload(); |
| 228 | } else { |
| 229 | el.innerHTML = snap.html; |
| 230 | notify(`Not saved: ${result.error ?? "unknown error"}`, true); |
| 231 | } |
| 232 | }; |
| 233 | |
| 234 | // ── markdown sections ───────────────────────────────────────────────────── |
| 235 | |
| 236 | interface MarkdownState { |
| 237 | /** Rendered HTML to restore when leaving without changes. */ |
| 238 | rendered: string; |
| 239 | /** The source the edit started from (sent back to detect staleness). */ |
| 240 | base?: string; |
| 241 | /** Unsaved source kept after a failed save. */ |
| 242 | draft?: string; |
| 243 | /** The page as served when editing started, to tell when it's rebuilt. */ |
| 244 | servedAtFocus?: Promise<string>; |
| 245 | } |
| 246 | const sections = new WeakMap<HTMLElement, MarkdownState>(); |
| 247 | /** Elements that are themselves a single markdown block. */ |
| 248 | const BLOCK_TAGS = new Set([ |
| 249 | "P", |
| 250 | "H1", |
| 251 | "H2", |
| 252 | "H3", |
| 253 | "H4", |
| 254 | "H5", |
| 255 | "H6", |
| 256 | "UL", |
| 257 | "OL", |
| 258 | "BLOCKQUOTE", |
| 259 | "TABLE", |
| 260 | "PRE", |
| 261 | ]); |
| 262 | /** Sections showing a preview until the page's renderer catches up. */ |
| 263 | const previews = new Map< |
| 264 | HTMLElement, |
| 265 | { staleHtml: string; attempts: number } |
| 266 | >(); |
| 267 | /** An edit changed the page's structure; reload once the server has it. */ |
| 268 | let reloadPending = false; |
| 269 | /** The server has it, but an edit was in progress. */ |
| 270 | let reloadWhenIdle = false; |
| 271 | /** The page as served before a structural edit, to tell when it changed. */ |
| 272 | let servedBefore: Promise<string> | undefined; |
| 273 | const fetchPage = () => |
| 274 | fetch(location.href, { cache: "no-store" }) |
| 275 | .then((res) => (res.ok ? res.text() : "")) |
| 276 | .catch(() => ""); |
| 277 | /** `before`: the page as served before the edit, when already fetched. */ |
| 278 | const needsReload = (before?: Promise<string>) => { |
| 279 | reloadPending = true; |
| 280 | servedBefore ??= before ?? fetchPage(); |
| 281 | }; |
| 282 | const busy = () => |
| 283 | document.querySelector("[data-copy-editing], [data-copy-draft]") !== null; |
| 284 | |
| 285 | /** |
| 286 | * Moves stylesheets out of a section before its content is replaced: |
| 287 | * renderers such as Expressive Code load page-wide styles from inside the |
| 288 | * first block that needs them. |
| 289 | */ |
| 290 | const hoistStyles = (el: HTMLElement) => { |
| 291 | for (const node of el.querySelectorAll<HTMLElement>( |
| 292 | 'link[rel="stylesheet"], style', |
| 293 | )) { |
| 294 | const href = node.getAttribute("href"); |
| 295 | const present = href |
| 296 | ? document.head.querySelector( |
| 297 | `link[rel="stylesheet"][href="${CSS.escape(href)}"]`, |
| 298 | ) |
| 299 | : null; |
| 300 | if (!present) document.head.append(node.cloneNode(true)); |
| 301 | } |
| 302 | }; |
| 303 | |
| 304 | /** Normalized text of a code block, for matching rendered and preview code. */ |
| 305 | const codeText = (pre: Element) => |
| 306 | ( |
| 307 | pre.querySelector("[data-code]")?.getAttribute("data-code") ?? |
| 308 | (pre as HTMLElement).innerText ?? |
| 309 | pre.textContent ?? |
| 310 | "" |
| 311 | ).replace(/\s+/g, ""); |
| 312 | |
| 313 | const renderPreview = ( |
| 314 | source: string, |
| 315 | styleName: string | undefined, |
| 316 | rendered?: string, |
| 317 | ) => { |
| 318 | const preset = styleName ? styles[styleName] : undefined; |
| 319 | let markdown = source; |
| 320 | for (const [pattern, replacement] of preset?.rewrite ?? []) { |
| 321 | markdown = markdown.replace(new RegExp(pattern, "g"), replacement); |
| 322 | } |
| 323 | const template = document.createElement("template"); |
| 324 | template.innerHTML = marked.parse(markdown, { async: false, gfm: true }); |
| 325 | for (const [tag, attrs] of Object.entries(preset?.elements ?? {})) { |
| 326 | const selector = tag === "code" ? "code:not(pre code)" : tag; |
| 327 | for (const node of template.content.querySelectorAll(selector)) { |
| 328 | for (const [name, value] of Object.entries(attrs ?? {})) { |
| 329 | if (name === "class") |
| 330 | node.classList.add(...value.split(/\s+/).filter(Boolean)); |
| 331 | else node.setAttribute(name, value); |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | // Keep unchanged code blocks exactly as the page rendered them |
| 336 | // (highlighting, frames, copy buttons) instead of the plain preview. |
| 337 | if (rendered) { |
| 338 | const previous = document.createElement("template"); |
| 339 | previous.innerHTML = rendered; |
| 340 | const blocks = [...previous.content.children].filter((child) => |
| 341 | child.matches("pre, :has(pre)"), |
| 342 | ); |
| 343 | for (const pre of template.content.querySelectorAll(":scope > pre")) { |
| 344 | const text = codeText(pre); |
| 345 | const match = blocks.findIndex((block) => codeText(block) === text); |
| 346 | if (match === -1) continue; |
| 347 | pre.replaceWith(blocks[match]!); |
| 348 | blocks.splice(match, 1); |
| 349 | } |
| 350 | } |
| 351 | return template.innerHTML; |
| 352 | }; |
| 353 | |
| 354 | /** Text just before the caret, to find the same spot in the source. */ |
| 355 | const caretContext = (el: HTMLElement) => { |
| 356 | const selection = getSelection(); |
| 357 | const node = selection?.anchorNode; |
| 358 | if (!node || node.nodeType !== Node.TEXT_NODE || !el.contains(node)) |
| 359 | return ""; |
| 360 | return (node as Text).data.slice(0, selection!.anchorOffset); |
| 361 | }; |
| 362 | |
| 363 | const placeCaret = (el: HTMLElement, source: string, context: string) => { |
| 364 | let at = 0; |
| 365 | for (const size of [24, 12, 6]) { |
| 366 | const needle = context.slice(-size); |
| 367 | const i = needle.trim() ? source.indexOf(needle) : -1; |
| 368 | if (i !== -1) { |
| 369 | at = i + needle.length; |
| 370 | break; |
| 371 | } |
| 372 | } |
| 373 | const node = el.firstChild; |
| 374 | if (!node) return; |
| 375 | const range = document.createRange(); |
| 376 | range.setStart(node, Math.min(at, node.textContent?.length ?? 0)); |
| 377 | range.collapse(true); |
| 378 | getSelection()?.removeAllRanges(); |
| 379 | getSelection()?.addRange(range); |
| 380 | }; |
| 381 | |
| 382 | const enterSource = async (el: HTMLElement) => { |
| 383 | if (el.dataset.copyEditing) return; |
| 384 | const state: MarkdownState = sections.get(el) ?? { rendered: el.innerHTML }; |
| 385 | sections.set(el, state); |
| 386 | el.dataset.copyEditing = "loading"; |
| 387 | |
| 388 | if (state.draft === undefined) { |
| 389 | const res = await fetch( |
| 390 | `${SOURCE_ENDPOINT}?id=${encodeURIComponent(el.dataset.copy ?? "")}`, |
| 391 | ).catch(() => undefined); |
| 392 | const body = (await res?.json().catch(() => ({}))) as { |
| 393 | source?: string; |
| 394 | error?: string; |
| 395 | }; |
| 396 | if (!res?.ok || body.source === undefined) { |
| 397 | delete el.dataset.copyEditing; |
| 398 | notify(`Can't edit: ${body.error ?? "unknown error"}`, true); |
| 399 | return; |
| 400 | } |
| 401 | state.base = body.source; |
| 402 | state.rendered = el.innerHTML; |
| 403 | state.servedAtFocus = fetchPage(); |
| 404 | } |
| 405 | if (document.activeElement !== el) { |
| 406 | delete el.dataset.copyEditing; |
| 407 | return; |
| 408 | } |
| 409 | const context = caretContext(el); |
| 410 | const source = state.draft ?? state.base ?? ""; |
| 411 | hoistStyles(el); |
| 412 | el.dataset.copyEditing = "source"; |
| 413 | el.removeAttribute("data-copy-draft"); |
| 414 | el.removeAttribute("data-copy-empty"); |
| 415 | el.textContent = source; |
| 416 | placeCaret(el, source, context); |
| 417 | }; |
| 418 | |
| 419 | const cancelSource = (el: HTMLElement) => { |
| 420 | const state = sections.get(el); |
| 421 | if (!state || !el.dataset.copyEditing) return; |
| 422 | state.draft = undefined; |
| 423 | delete el.dataset.copyEditing; |
| 424 | el.removeAttribute("data-copy-draft"); |
| 425 | el.innerHTML = state.rendered; |
| 426 | markEmpty(el); |
| 427 | }; |
| 428 | |
| 429 | const leaveSource = async (el: HTMLElement) => { |
| 430 | const state = sections.get(el); |
| 431 | if (!state || el.dataset.copyEditing !== "source") { |
| 432 | if (el.dataset.copyEditing === "loading") delete el.dataset.copyEditing; |
| 433 | return; |
| 434 | } |
| 435 | const source = el.innerText.replace(/\u00a0/g, " ").replace(/\s+$/, ""); |
| 436 | delete el.dataset.copyEditing; |
| 437 | if (source === (state.base ?? "").replace(/\s+$/, "")) { |
| 438 | state.draft = undefined; |
| 439 | el.innerHTML = state.rendered; |
| 440 | markEmpty(el); |
| 441 | return; |
| 442 | } |
| 443 | |
| 444 | const id = el.dataset.copy ?? ""; |
| 445 | const staleHtml = state.rendered; |
| 446 | const result = await post(SOURCE_ENDPOINT, { id, source, base: state.base }); |
| 447 | if (!result.ok) { |
| 448 | state.draft = source; |
| 449 | el.setAttribute("data-copy-draft", ""); |
| 450 | el.textContent = source; |
| 451 | notify(`Not saved: ${result.error ?? "unknown error"}`, true); |
| 452 | return; |
| 453 | } |
| 454 | const preview = isInline(el) |
| 455 | ? renderInline(source, staleHtml) |
| 456 | : renderPreview(source, el.dataset.copyStyle, staleHtml); |
| 457 | if (isInline(el)) { |
| 458 | el.innerHTML = preview; |
| 459 | } else if (BLOCK_TAGS.has(el.tagName)) { |
| 460 | // The section IS one block (e.g. a paragraph): show the preview's |
| 461 | // contents in place, or reload if the edit turned it into other blocks. |
| 462 | const template = document.createElement("template"); |
| 463 | template.innerHTML = preview; |
| 464 | const [only, ...rest] = [...template.content.children]; |
| 465 | if (only?.tagName === el.tagName && rest.length === 0) { |
| 466 | el.innerHTML = only.innerHTML; |
| 467 | } else { |
| 468 | // Now several blocks (e.g. a paragraph and a list): show them in its |
| 469 | // place, then reload once the page is rebuilt so every block gets an id. |
| 470 | notify(`Saved to ${result.file}`); |
| 471 | el.replaceWith(template.content); |
| 472 | needsReload(state.servedAtFocus); |
| 473 | return; |
| 474 | } |
| 475 | } else { |
| 476 | el.innerHTML = preview; |
| 477 | } |
| 478 | state.draft = undefined; |
| 479 | state.base = source; |
| 480 | state.rendered = el.innerHTML; |
| 481 | markEmpty(el); |
| 482 | if (result.changed) { |
| 483 | notify(`Saved to ${result.file}`); |
| 484 | previews.set(el, { staleHtml, attempts: 0 }); |
| 485 | } |
| 486 | if (result.reload) needsReload(); |
| 487 | }; |
| 488 | |
| 489 | /** |
| 490 | * Swaps previews for the page's own rendering once the server has caught |
| 491 | * up, or reloads when an edit changed the page's structure. |
| 492 | */ |
| 493 | const refresh = async () => { |
| 494 | if (reloadPending) { |
| 495 | // Wait until the server serves the changed page (its watchers may lag |
| 496 | // behind the regeneration that triggered this refresh). |
| 497 | const before = await servedBefore; |
| 498 | for (let attempt = 0; attempt < 10; attempt++) { |
| 499 | if ((await fetchPage()) !== before) break; |
| 500 | await new Promise((resolve) => setTimeout(resolve, 1000)); |
| 501 | } |
| 502 | if (busy()) reloadWhenIdle = true; |
| 503 | else location.reload(); |
| 504 | return; |
| 505 | } |
| 506 | const res = await fetch(location.href, { cache: "no-store" }).catch( |
| 507 | () => undefined, |
| 508 | ); |
| 509 | if (!res?.ok) return; |
| 510 | const fresh = new DOMParser().parseFromString(await res.text(), "text/html"); |
| 511 | claimMarkers(fresh.body); |
| 512 | // Pair editable elements by position: ids encode source locations, which |
| 513 | // shift when an edit changes the length of the text before them. |
| 514 | const current = [...document.querySelectorAll<HTMLElement>(SELECTOR)]; |
| 515 | const next = [...fresh.querySelectorAll<HTMLElement>(SELECTOR)]; |
| 516 | if (current.length !== next.length) { |
| 517 | // The edit added or removed blocks. |
| 518 | if (busy()) reloadWhenIdle = true; |
| 519 | else location.reload(); |
| 520 | return; |
| 521 | } |
| 522 | let retry = false; |
| 523 | current.forEach((el, i) => { |
| 524 | const incoming = next[i]!; |
| 525 | if (el.dataset.copyEditing) return; |
| 526 | if (el.dataset.copy !== incoming.dataset.copy) { |
| 527 | el.dataset.copy = incoming.dataset.copy; |
| 528 | } |
| 529 | const preview = previews.get(el); |
| 530 | if (!preview) return; |
| 531 | if (incoming.innerHTML === preview.staleHtml && preview.attempts < 5) { |
| 532 | // The server hasn't picked up the edited source yet. |
| 533 | preview.attempts++; |
| 534 | retry = true; |
| 535 | return; |
| 536 | } |
| 537 | el.innerHTML = incoming.innerHTML; |
| 538 | sections.set(el, { rendered: el.innerHTML }); |
| 539 | markEmpty(el); |
| 540 | previews.delete(el); |
| 541 | }); |
| 542 | if (retry) setTimeout(refresh, 1000); |
| 543 | }; |
| 544 | |
| 545 | // ── events ──────────────────────────────────────────────────────────────── |
| 546 | |
| 547 | listen("focusin", (event) => { |
| 548 | const el = target(event); |
| 549 | if (!el) return; |
| 550 | if (isMarkdown(el)) void enterSource(el); |
| 551 | else snapshots.set(el, { html: el.innerHTML, texts: textNodes(el) }); |
| 552 | }); |
| 553 | |
| 554 | listen("keydown", (event) => { |
| 555 | const el = target(event); |
| 556 | if (!el) return; |
| 557 | if (isMarkdown(el)) { |
| 558 | if (event.key === "Escape") { |
| 559 | cancelSource(el); |
| 560 | el.blur(); |
| 561 | } else if (event.key === "Enter") { |
| 562 | event.preventDefault(); |
| 563 | // Markdown sections: Enter adds a line, Ctrl/Cmd+Enter saves. |
| 564 | // One-line copy: Enter saves, Shift+Enter adds a line break (<br>). |
| 565 | const save = |
| 566 | event.metaKey || event.ctrlKey || (isInline(el) && !event.shiftKey); |
| 567 | if (save) el.blur(); |
| 568 | else document.execCommand("insertLineBreak"); |
| 569 | } else if (event.key === "Tab") { |
| 570 | event.preventDefault(); |
| 571 | document.execCommand("insertText", false, " "); |
| 572 | } |
| 573 | return; |
| 574 | } |
| 575 | if (event.key === "Enter" && !event.shiftKey) { |
| 576 | event.preventDefault(); |
| 577 | el.blur(); |
| 578 | } else if (event.key === "Escape") { |
| 579 | const snap = snapshots.get(el); |
| 580 | if (snap) el.innerHTML = snap.html; |
| 581 | el.blur(); |
| 582 | } |
| 583 | }); |
| 584 | |
| 585 | // Clicking copy inside a link or button places the caret; Ctrl/Cmd+click |
| 586 | // still follows the link. |
| 587 | listen( |
| 588 | "click", |
| 589 | (event) => { |
| 590 | if (!target(event) || event.ctrlKey || event.metaKey) return; |
| 591 | if ((event.target as Element).closest("a, button")) event.preventDefault(); |
| 592 | }, |
| 593 | true, |
| 594 | ); |
| 595 | |
| 596 | // Editable elements are plain text: no bold/italic shortcuts or rich drops. |
| 597 | listen("beforeinput", (event) => { |
| 598 | if (!target(event)) return; |
| 599 | if ( |
| 600 | event.inputType.startsWith("format") || |
| 601 | event.inputType === "insertFromDrop" |
| 602 | ) { |
| 603 | event.preventDefault(); |
| 604 | } |
| 605 | }); |
| 606 | |
| 607 | // Paste as plain text. |
| 608 | listen("paste", (event) => { |
| 609 | if (!target(event) || !event.clipboardData) return; |
| 610 | event.preventDefault(); |
| 611 | document.execCommand( |
| 612 | "insertText", |
| 613 | false, |
| 614 | event.clipboardData.getData("text/plain"), |
| 615 | ); |
| 616 | }); |
| 617 | |
| 618 | listen("focusout", async (event) => { |
| 619 | const el = target(event); |
| 620 | if (!el) return; |
| 621 | if (isMarkdown(el)) await leaveSource(el); |
| 622 | else await saveInline(el); |
| 623 | if (reloadWhenIdle && !busy()) location.reload(); |
| 624 | }); |
| 625 | |
| 626 | // ── hot reload ──────────────────────────────────────────────────────────── |
| 627 | |
| 628 | if (import.meta.hot) { |
| 629 | import.meta.hot.on(REFRESH_EVENT, () => void refresh()); |
| 630 | // Editing this file hot-swaps the editor without a page reload. |
| 631 | import.meta.hot.accept(); |
| 632 | import.meta.hot.dispose(() => { |
| 633 | controller.abort(); |
| 634 | observer.disconnect(); |
| 635 | clearTimeout(toastTimer); |
| 636 | style.remove(); |
| 637 | toast.remove(); |
| 638 | }); |
| 639 | } |