/** * The CodeMirror half of live preview: descriptors → decorations, * two widgets, and a theme that reads Charcuterie tokens. * * `livePreviewRanges.ts` decided *what* to decorate and is pure. * This file owns everything that needs `@codemirror/view`, which is * also everything that cannot be tested in this package's Node-only * Vitest project — so it is kept deliberately thin and mechanical. * * ### Why the theme uses `var(--color-…)` and not Tailwind classes * * Every other visual in this package is a Tailwind utility reading * a token. CodeMirror is the exception, and for a structural * reason: it generates its own scoped class names and injects its * own stylesheet, so the DOM these decorations land on is not DOM * Tailwind ever scans. Referencing the custom properties directly * keeps a scheme flip a repaint — the same behaviour, one layer * lower — and avoids a `safelist` that would rot the first time a * class here changed. * * The properties below are the generated token names, not invented * ones. That distinction has already cost this fleet a release: * Docket shipped invisible priority bars from `--color-danger-9`, a * scale that does not exist, and nothing errored. A custom property * that resolves to nothing paints nothing, silently. */ import { syntaxTree } from "@codemirror/language" import type { EditorState, Extension, Range, } from "@codemirror/state" import { Facet, Prec, StateEffect, StateField, } from "@codemirror/state" import type { DecorationSet, ViewUpdate, } from "@codemirror/view" import { Decoration, EditorView, keymap, ViewPlugin, WidgetType, } from "@codemirror/view" import type { LivePreviewLineKind, LivePreviewMarkKind, LivePreviewTableRow, LivePreviewTableSegment, } from "./livePreviewRanges.ts" import { toLivePreviewRanges, toLivePreviewTableRanges, } from "./livePreviewRanges.ts" /** Marks a rendered link so the click handler can find its target. */ const URL_ATTRIBUTE = "data-charcuterie-url" /** * A rendered cell's offset into its own table, so a click can put * the caret back in the markdown it was drawn from. * * Relative, not absolute, and for the same reason `TaskWidget` reads * its position back from the DOM: a widget outlives the offsets it * was built at. Editing a paragraph *above* a table moves every * offset in it without changing a character of the table, and the * widget is reused — `eq` compares the markdown, which did not * change. Relative plus `posAtDOM` at click time is correct in both * cases; a captured absolute offset silently is not. */ const CELL_OFFSET_ATTRIBUTE = "data-charcuterie-cell-offset" /** * What kind of surface this is, and what its checkboxes may do. * * A facet rather than closure state, for the same reason raw mode is * a `StateField`: the decoration builders below are module-level * singletons — one `ViewPlugin`, one `StateField` — and rebuilding * them per instance would mean a second copy of the whole live * preview for every surface on the page. They read the answer out of * the state they were already handed. * * It is also what lets `MarkdownView` put the task flag in a * `Compartment` and flip it without discarding the view, exactly as * the editors already flip editability. */ export type LivePreviewOptions = { /** * Draw a **document** rather than an editing surface: real * anchors, heading roles, and a table that is a table instead of a * way back into the markdown behind it. * * Off by default, because the editors are the surface this module * was written for and every one of these would be wrong inside a * `contenteditable`. */ hasDocumentSemantics?: boolean /** Whether a `- [ ]` checkbox can be ticked. Defaults to `true`. */ isTaskListInteractive?: boolean /** * Re-point a link before it becomes an `href` — see * `ToLivePreviewRangesOptions.resolveUrl`, which this is handed * to. Links only, and the document's URL has already been through * the scheme guard when it arrives. */ resolveUrl?: (url: string) => string | undefined } /** * The facet itself is exported so a consumer can put **only this** * in a `Compartment` and flip one answer without reconfiguring the * whole extension — which would tear down and recreate the * `StateField`s beside it. * * A later input wins per key, and a key nobody stated keeps the * default. That is a merge rather than "the last one wins outright", * so an override that only cares about checkboxes cannot silently * turn document semantics back off. */ export const livePreviewOptions = Facet.define< LivePreviewOptions, Required >({ combine: (values) => values.reduce>( (merged, value) => ({ hasDocumentSemantics: value.hasDocumentSemantics ?? merged.hasDocumentSemantics, isTaskListInteractive: value.isTaskListInteractive ?? merged.isTaskListInteractive, resolveUrl: value.resolveUrl ?? merged.resolveUrl, }), { hasDocumentSemantics: false, isTaskListInteractive: true, resolveUrl: () => undefined, }, ), }) const toOptions = ( state: EditorState, ): Required => state.facet(livePreviewOptions) /** Toggle the "edit Markdown" mode. */ export const setLivePreviewRawMode = StateEffect.define() /** * Raw mode as editor state rather than a React prop threaded down. * * It has to live here because the decoration plugin reads it on * every update, and because a keybinding should be able to flip it * without a round trip through React. */ export const livePreviewRawModeField = StateField.define({ create: () => false, update: (isRawMode, transaction) => { let isNextRawMode = isRawMode for (const effect of transaction.effects) { if (effect.is(setLivePreviewRawMode)) { isNextRawMode = effect.value } } return isNextRawMode }, }) const setIsEditorFocused = StateEffect.define() /** * Focus, as editor state. * * The plugin can read `view.hasFocus` directly; the block pass is a * `StateField` and has no view to ask. It still needs the answer, * because an unfocused editor reveals nothing — without it the * caret's default resting place, offset 0, counts as "inside" a * table that opens the document, and a description that starts with * a table loads showing pipes. */ const isEditorFocusedField = StateField.define({ create: () => false, update: (isFocused, transaction) => { let isNextFocused = isFocused for (const effect of transaction.effects) { if (effect.is(setIsEditorFocused)) { isNextFocused = effect.value } } return isNextFocused }, }) /** * The facet CodeMirror provides for exactly this: turn a focus * change into an effect, so state can depend on it. */ const focusTracker = EditorView.focusChangeEffect.of( (_state, isFocusing) => setIsEditorFocused.of(isFocusing), ) /** * How the read view says "heading" to something that is not looking * at the font size. * * The editing surface deliberately has none of this. Its content is * a `role="textbox"`, and ARIA flattens a textbox's contents to * text — a heading role inside one is not exposed, so adding it * there would be a decoration that lies about what it does. The read * view is a `role="article"`, where the same markup is a real * heading and the reader's H key steps between sections. * * A `Decoration.line` carries the role, which is exactly right for * an ATX heading: the parser only ever emits `ATXHeading1`…`6`, and * one of those is one line by definition. */ const HEADING_LEVELS: Partial< Record > = { heading1: "1", heading2: "2", heading3: "3", heading4: "4", heading5: "5", heading6: "6", } const toLineAttributes = ( lineKind: LivePreviewLineKind, hasDocumentSemantics: boolean, ) => { const level = HEADING_LEVELS[lineKind] return hasDocumentSemantics && level ? { "aria-level": level, role: "heading" } : undefined } /** * A link's DOM, in a surface where a real anchor is allowed. * * The editor cannot have one. Its content is `contenteditable`, and * an `` inside an editable region is a focus target the * caret has to share the line with — which is why the editor paints * a `` and opens the URL from a `mousedown` handler instead, * and why clicking link *text* there cannot place a caret. * * A read view has no caret to protect, so the link is simply a link: * tabbable, activated by Enter, copyable from the context menu, and * announced as a link rather than as coloured text. That difference * is the one place the two surfaces deliberately emit different DOM, * and it is a strictly larger set of things a reader can do. */ const toLinkAttributes = (url: string) => ({ href: url, // Not optional with `target="_blank"`: without it the opened page // gets a live `window.opener` back into this one. rel: "noopener noreferrer", target: "_blank", }) const LINE_CLASSES: Record = { blockquote: "cm-md-blockquote", code: "cm-md-codeblock", heading1: "cm-md-heading1", heading2: "cm-md-heading2", heading3: "cm-md-heading3", heading4: "cm-md-heading4", heading5: "cm-md-heading5", heading6: "cm-md-heading6", table: "cm-md-table", } const MARK_CLASSES: Record = { autolink: "cm-md-link", code: "cm-md-code", emphasis: "cm-md-emphasis", linkText: "cm-md-link", strikethrough: "cm-md-strikethrough", strong: "cm-md-strong", url: "cm-md-url", } /** * An image, standing in for its own markup. * * `eq` compares by URL *and* alt so scrolling past an image reuses * the same `` instead of refetching it, while an edit to * either still rebuilds. Without it every keystroke elsewhere in * the document flashes every image on screen. */ class ImageWidget extends WidgetType { constructor( private readonly url: string, private readonly alt: string, ) { super() } eq(other: ImageWidget) { return other.url === this.url && other.alt === this.alt } toDOM(view: EditorView) { const image = document.createElement("img") image.setAttribute("src", this.url) image.setAttribute("alt", this.alt) /** * A remote image in a document nobody here wrote is a beacon: * fetching it tells its host that this reader opened this file, * and the default `Referer` tells it which page they opened it * from. The image is still fetched — that is what rendering it * means — but it learns nothing about where from. */ image.setAttribute("referrerpolicy", "no-referrer") image.className = "cm-md-image" // The document's height is unknown until the image decodes, and // CodeMirror caches line heights. Without a remeasure the lines // after a tall image overlap it until something else forces a // layout. image.addEventListener("load", () => { view.requestMeasure() }) return image } } /** * A task checkbox, standing in for `[ ]` / `[x]`. * * The position is read back from the DOM with `posAtDOM` rather * than captured in the constructor: a widget outlives the document * offsets it was built at, and a stale `from` writes the checkbox * into whatever moved into that slot. */ class TaskWidget extends WidgetType { constructor( private readonly isChecked: boolean, private readonly label: string, private readonly isInteractive: boolean, ) { super() } eq(other: TaskWidget) { return ( other.isChecked === this.isChecked && other.isInteractive === this.isInteractive && other.label === this.label ) } toDOM(view: EditorView) { const checkbox = document.createElement("input") checkbox.type = "checkbox" checkbox.checked = this.isChecked checkbox.className = "cm-md-task" /** * The item's own text, as the checkbox's name. * * A bare `` inside a `contenteditable` * has no wrapping `