1// Obsidian-style live preview: the markdown text stays the source of truth and a decoration layer
2// renders formatting over it. Delimiter marks hide unless the selection touches their construct,
3// where they mute rather than vanish so the raw syntax stays editable.
4//
5// Layout-shift rule: every style that changes line metrics (heading sizes, quote borders, callout
6// backgrounds) rides a *line* decoration applied in both the revealed and hidden state — toggling
7// the caret in and out of a construct may reflow horizontally (marks appear) but never vertically.
8import { defaultHighlightStyle, syntaxHighlighting, syntaxTree } from "@codemirror/language";
9import type { Extension, Range } from "@codemirror/state";
10import { EditorSelection, EditorState, Prec, StateEffect, StateField } from "@codemirror/state";
11import {
12 Decoration,
13 type DecorationSet,
14 EditorView,
15 keymap,
16 ViewPlugin,
17 type ViewUpdate,
18} from "@codemirror/view";
19import type { SyntaxNode, Tree } from "@lezer/common";
20
21import { classifyHtmlBlock, htmlTokens } from "./html.ts";
22import {
23 BrWidget,
24 CommentWidget,
25 BulletWidget,
26 CALLOUT_TYPES,
27 CalloutLabelWidget,
28 HrWidget,
29 HtmlWidget,
30 ImageWidget,
31 type TableData,
32 TableWidget,
33 TaskCheckboxWidget,
34 VideoWidget,
35 type VideoPreviewRenderer,
36} from "./widgets.ts";
37
38export interface LivePreviewOptions {
39 /**
40 * Replace whole blocks with rendered widgets when the caret is outside them: GFM tables, images,
41 * and horizontal rules. On in the document preset; off in the composer, where the input should
42 * stay text-shaped.
43 */
44 blockWidgets?: boolean;
45 /** Source ranges painted atomically by consumer decorations. */
46 exclude?: (state: EditorState) => readonly { from: number; to: number }[];
47 /**
48 * Preview readme-subset HTML (requires the grammar to include HTMLBlock/HTMLTag): balanced
49 * blocks render sanitized, centering wrappers absorb, inline tags style, and the caret entering
50 * any of it reveals the raw source. Document preset only.
51 */
52 html?: boolean;
53 /** Maps relative media URLs to something fetchable (host seam); identity when omitted. */
54 resolveUrl?: (url: string) => string;
55 /** Hosts video playback without importing a UI framework into the editor. */
56 renderVideo?: VideoPreviewRenderer;
57}
58
59// Reveals require the editor to be focused: an unfocused pane (the future document viewer) shows
60// pure preview no matter where its selection sits. The field defaults to unfocused; the DOM
61// focus/blur handlers in `livePreview` keep it current. Headless states without the field (plain
62// `formattingDecorations` calls) count as focused so selection-only tests exercise reveals.
63const setPreviewFocus = StateEffect.define<boolean>();
64
65const previewFocus = StateField.define<boolean>({
66 create: () => false,
67 update: (value, transaction) => {
68 for (const effect of transaction.effects) if (effect.is(setPreviewFocus)) value = effect.value;
69 return value;
70 },
71});
72
73function editorFocused(state: EditorState): boolean {
74 return state.field(previewFocus, false) ?? true;
75}
76
77/** A range of the focused editor's selection touches the span, so its raw syntax reveals. */
78function revealed(state: EditorState, from: number, to: number): boolean {
79 if (!editorFocused(state)) return false;
80 return state.selection.ranges.some((range) => range.from <= to && range.to >= from);
81}
82
83/** Reveal scope for line-anchored marks: the selection touches the mark's line. */
84function lineRevealed(state: EditorState, pos: number): boolean {
85 const line = state.doc.lineAt(pos);
86 return revealed(state, line.from, line.to);
87}
88
89function decorateDelimited(
90 state: EditorState,
91 node: SyntaxNode,
92 markName: string,
93 className: string,
94 ranges: Range<Decoration>[],
95): void {
96 const marks = node.getChildren(markName);
97 const open = marks[0];
98 const close = marks[marks.length - 1];
99 const innerFrom = open ? open.to : node.from;
100 const innerTo = close ? close.from : node.to;
101 if (innerTo > innerFrom)
102 ranges.push(Decoration.mark({ class: className }).range(innerFrom, innerTo));
103 if (revealed(state, node.from, node.to)) {
104 if (open) ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(open.from, open.to));
105 if (close && close !== open)
106 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(close.from, close.to));
107 return;
108 }
109 if (open) ranges.push(Decoration.replace({}).range(open.from, open.to));
110 if (close && close !== open) ranges.push(Decoration.replace({}).range(close.from, close.to));
111}
112
113const DELIMITED: Record<string, { mark: string; className: string }> = {
114 StrongEmphasis: { mark: "EmphasisMark", className: "cm-lp-strong" },
115 Emphasis: { mark: "EmphasisMark", className: "cm-lp-em" },
116 Strikethrough: { mark: "StrikethroughMark", className: "cm-lp-strike" },
117 InlineCode: { mark: "CodeMark", className: "cm-lp-code" },
118};
119
120const ATX_LEVEL: Record<string, number> = {
121 ATXHeading1: 1,
122 ATXHeading2: 2,
123 ATXHeading3: 3,
124 ATXHeading4: 4,
125 ATXHeading5: 5,
126 ATXHeading6: 6,
127};
128
129function decorateHeading(
130 state: EditorState,
131 node: SyntaxNode,
132 level: number,
133 ranges: Range<Decoration>[],
134): void {
135 const line = state.doc.lineAt(node.from);
136 ranges.push(Decoration.line({ class: `cm-lp-heading cm-lp-h${level}` }).range(line.from));
137 const show = revealed(state, node.from, node.to);
138 for (const mark of node.getChildren("HeaderMark")) {
139 if (show) {
140 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(mark.from, mark.to));
141 continue;
142 }
143 // Swallow the adjacent space with the mark so hidden `# ` leaves no stray gap.
144 const leading = mark.from === line.from;
145 const from = leading ? mark.from : Math.max(line.from, mark.from - 1);
146 const to = leading && state.sliceDoc(mark.to, mark.to + 1) === " " ? mark.to + 1 : mark.to;
147 ranges.push(Decoration.replace({}).range(from, to));
148 }
149}
150
151const CALLOUT_MARKER = /^\[!(\w+)\]\s?/;
152
153/** The `[!type] ` marker right after this blockquote's first quote mark, if it is a callout. */
154function calloutMarker(
155 state: EditorState,
156 node: SyntaxNode,
157): { type: string; from: number; to: number } | null {
158 const mark = node.getChild("QuoteMark");
159 if (!mark) return null;
160 const line = state.doc.lineAt(mark.from);
161 const contentFrom = mark.to + (state.sliceDoc(mark.to, mark.to + 1) === " " ? 1 : 0);
162 const match = CALLOUT_MARKER.exec(state.sliceDoc(contentFrom, line.to));
163 if (!match) return null;
164 const type = match[1]?.toLowerCase() ?? "";
165 if (!(type in CALLOUT_TYPES)) return null;
166 return { type, from: contentFrom, to: contentFrom + match[0].length };
167}
168
169function decorateBlockquote(
170 state: EditorState,
171 node: SyntaxNode,
172 ranges: Range<Decoration>[],
173): { from: number; to: number } | null {
174 const callout = calloutMarker(state, node);
175 const first = state.doc.lineAt(node.from).number;
176 const last = state.doc.lineAt(node.to).number;
177 for (let n = first; n <= last; n++) {
178 let cls = callout ? `cm-lp-quote cm-lp-callout cm-lp-callout-${callout.type}` : "cm-lp-quote";
179 if (n === first) cls += " cm-lp-quote-top";
180 if (n === last) cls += " cm-lp-quote-bottom";
181 ranges.push(Decoration.line({ class: cls }).range(state.doc.line(n).from));
182 }
183 if (!callout) return null;
184 if (lineRevealed(state, callout.from)) {
185 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(callout.from, callout.to));
186 } else {
187 ranges.push(
188 Decoration.replace({ widget: new CalloutLabelWidget(callout.type) }).range(
189 callout.from,
190 callout.to,
191 ),
192 );
193 }
194 return callout;
195}
196
197function decorateQuoteMark(
198 state: EditorState,
199 node: SyntaxNode,
200 ranges: Range<Decoration>[],
201): void {
202 if (lineRevealed(state, node.from)) {
203 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(node.from, node.to));
204 return;
205 }
206 const to = state.sliceDoc(node.to, node.to + 1) === " " ? node.to + 1 : node.to;
207 ranges.push(Decoration.replace({}).range(node.from, to));
208}
209
210function decorateListMark(state: EditorState, node: SyntaxNode, ranges: Range<Decoration>[]): void {
211 const text = state.sliceDoc(node.from, node.to);
212 const parsedTask = node.parent?.getChild("Task") ?? null;
213 const indeterminateTask = /^\s*\[~\](?=\s)/.test(
214 state.sliceDoc(node.to, state.doc.lineAt(node.to).to),
215 );
216 const task = parsedTask !== null || indeterminateTask;
217 const indent = /^\s*/.exec(state.doc.lineAt(node.from).text)?.[0].length ?? 0;
218 if (indent > 0)
219 ranges.push(
220 Decoration.line({ class: "cm-lp-list-nested" }).range(state.doc.lineAt(node.from).from),
221 );
222 if (/^[-*+]$/.test(text)) {
223 if (lineRevealed(state, node.from)) {
224 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(node.from, node.to));
225 } else if (task) {
226 // Task items render their checkbox; the bullet disappears with its trailing space.
227 const to = state.sliceDoc(node.to, node.to + 1) === " " ? node.to + 1 : node.to;
228 ranges.push(Decoration.replace({}).range(node.from, to));
229 } else {
230 ranges.push(Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to));
231 }
232 return;
233 }
234 ranges.push(Decoration.mark({ class: "cm-lp-list-num" }).range(node.from, node.to));
235}
236
237function decorateTask(state: EditorState, node: SyntaxNode, ranges: Range<Decoration>[]): void {
238 const marker = node.getChild("TaskMarker");
239 if (!marker) return;
240 const taskState = /x/i.test(state.sliceDoc(marker.from, marker.to)) ? "checked" : "unchecked";
241 if (taskState === "checked")
242 ranges.push(
243 Decoration.line({ class: "cm-lp-task-done" }).range(state.doc.lineAt(node.from).from),
244 );
245 if (lineRevealed(state, marker.from)) {
246 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(marker.from, marker.to));
247 return;
248 }
249 ranges.push(
250 Decoration.replace({
251 widget: new TaskCheckboxWidget(taskState, marker.from, marker.to, state.readOnly),
252 }).range(marker.from, marker.to),
253 );
254}
255
256/** Package extension to GFM tasks: `[~]` renders as a native indeterminate checkbox. */
257function decorateIndeterminateTask(
258 state: EditorState,
259 node: SyntaxNode,
260 ranges: Range<Decoration>[],
261): void {
262 const line = state.doc.lineAt(node.from);
263 const match = /^(\s*)\[~\](?=\s)/.exec(state.sliceDoc(node.from, line.to));
264 if (!match) return;
265 const from = node.from + (match[1]?.length ?? 0);
266 const to = from + 3;
267 if (lineRevealed(state, from)) {
268 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(from, to));
269 return;
270 }
271 ranges.push(
272 Decoration.replace({
273 widget: new TaskCheckboxWidget("indeterminate", from, to, state.readOnly),
274 }).range(from, to),
275 );
276}
277
278function decorateLink(state: EditorState, node: SyntaxNode, ranges: Range<Decoration>[]): void {
279 const marks = node.getChildren("LinkMark");
280 const url = node.getChild("URL");
281 const title = node.getChild("LinkTitle");
282 const open = marks[0];
283 const close = marks[1];
284 if (open && close && close.from > open.to)
285 ranges.push(Decoration.mark({ class: "cm-lp-link" }).range(open.to, close.from));
286 if (revealed(state, node.from, node.to)) {
287 for (const mark of marks)
288 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(mark.from, mark.to));
289 if (url) ranges.push(Decoration.mark({ class: "cm-lp-url" }).range(url.from, url.to));
290 return;
291 }
292 for (const mark of marks) ranges.push(Decoration.replace({}).range(mark.from, mark.to));
293 if (url) ranges.push(Decoration.replace({}).range(url.from, url.to));
294 if (title) ranges.push(Decoration.replace({}).range(title.from, title.to));
295}
296
297/** `![alt](url)` → inline media; falls back to link-style marks when revealed. */
298function decorateImage(
299 state: EditorState,
300 node: SyntaxNode,
301 ranges: Range<Decoration>[],
302 blockWidgets: boolean,
303 resolve: (url: string) => string,
304 renderVideo: VideoPreviewRenderer | undefined,
305): boolean {
306 const sameLine = state.doc.lineAt(node.from).number === state.doc.lineAt(node.to).number;
307 if (blockWidgets && sameLine && !revealed(state, node.from, node.to)) {
308 const url = node.getChild("URL");
309 const marks = node.getChildren("LinkMark");
310 const alt =
311 marks[0] && marks[1] && marks[1].from > marks[0].to
312 ? state.sliceDoc(marks[0].to, marks[1].from)
313 : "";
314 const source = url ? state.sliceDoc(url.from, url.to).replace(/^<|>$/g, "") : "";
315 const video = /\.(?:avi|m4v|mkv|mov|mp4|ogv|webm)$/i.test(source.split(/[?#]/, 1)[0] ?? "");
316 const resolved = resolve(source);
317 ranges.push(
318 Decoration.replace({
319 widget: video
320 ? new VideoWidget(source, resolved, alt, renderVideo)
321 : new ImageWidget(resolved, alt),
322 }).range(node.from, node.to),
323 );
324 return false;
325 }
326 decorateLink(state, node, ranges);
327 return false;
328}
329
330function tableData(state: EditorState, node: SyntaxNode): TableData {
331 const cellText = (row: SyntaxNode) =>
332 row.getChildren("TableCell").map((cell) => state.sliceDoc(cell.from, cell.to).trim());
333 const header = node.getChild("TableHeader");
334 const delimiter = node.getChild("TableDelimiter");
335 const align = (delimiter ? state.sliceDoc(delimiter.from, delimiter.to) : "")
336 .split("|")
337 .map((column) => column.trim())
338 .filter((column) => column !== "")
339 .map((column) => {
340 const left = column.startsWith(":");
341 const right = column.endsWith(":");
342 if (left && right) return "center";
343 if (right) return "right";
344 if (left) return "left";
345 return "";
346 });
347 return {
348 align,
349 header: header ? cellText(header) : [],
350 rows: node.getChildren("TableRow").map(cellText),
351 };
352}
353
354/** Rendered table when the caret is outside; mono source lines while editing. */
355function decorateTable(
356 state: EditorState,
357 node: SyntaxNode,
358 ranges: Range<Decoration>[],
359 blockWidgets: boolean,
360 centered: boolean,
361): boolean | undefined {
362 if (!blockWidgets) return undefined;
363 if (revealed(state, node.from, node.to)) {
364 const first = state.doc.lineAt(node.from).number;
365 const last = state.doc.lineAt(node.to).number;
366 for (let n = first; n <= last; n++)
367 ranges.push(Decoration.line({ class: "cm-lp-table-src" }).range(state.doc.line(n).from));
368 return true;
369 }
370 ranges.push(
371 Decoration.replace({
372 widget: new TableWidget(tableData(state, node), centered),
373 block: true,
374 }).range(node.from, node.to),
375 );
376 return false;
377}
378
379function decorateCodeBlock(
380 state: EditorState,
381 node: SyntaxNode,
382 ranges: Range<Decoration>[],
383): void {
384 const { doc } = state;
385 const first = doc.lineAt(node.from).number;
386 const last = doc.lineAt(node.to).number;
387 for (let n = first; n <= last; n++) {
388 let cls = "cm-lp-codeblock";
389 if (n === first) cls += " cm-lp-codeblock-top";
390 if (n === last) cls += " cm-lp-codeblock-bottom";
391 ranges.push(Decoration.line({ class: cls }).range(doc.line(n).from));
392 }
393 for (const mark of node.getChildren("CodeMark"))
394 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(mark.from, mark.to));
395 const info = node.getChild("CodeInfo");
396 if (info) ranges.push(Decoration.mark({ class: "cm-lp-code-lang" }).range(info.from, info.to));
397}
398
399// ---------------------------------------------------------------------------
400// HTML preview (document preset)
401
402interface Span {
403 from: number;
404 to: number;
405}
406
407type HtmlBlockMode = "render" | "absorb" | "collapse" | "source";
408
409interface HtmlPlan {
410 /** Per HTMLBlock (keyed by node start): how the preview treats it. */
411 blocks: Map<number, HtmlBlockMode>;
412 /** Content spans between paired centering wrappers. */
413 centers: Span[];
414 /** For each absorbed wrapper half (by node start): its partner's span, so they reveal together. */
415 partners: Map<number, Span>;
416}
417
418/**
419 * Classify every HTMLBlock and pair split wrappers (GFM's markdown-in-HTML rule separates a
420 * wrapper's open and close into different blocks). Paired wrappers absorb; an unmatched lone
421 * open/close stays visible source rather than hiding a tag that never closes.
422 */
423function planHtml(state: EditorState, tree: Tree): HtmlPlan {
424 const blocks = new Map<number, HtmlBlockMode>();
425 const centers: Span[] = [];
426 const partners = new Map<number, Span>();
427 const stack: Array<{ tag: string; center: boolean; span: Span }> = [];
428 tree.iterate({
429 enter: (nodeRef) => {
430 if (nodeRef.name !== "HTMLBlock") return undefined;
431 const span = { from: nodeRef.from, to: nodeRef.to };
432 const shape = classifyHtmlBlock(state.sliceDoc(span.from, span.to));
433 switch (shape.kind) {
434 case "comment":
435 blocks.set(span.from, "collapse");
436 break;
437 case "balanced":
438 blocks.set(span.from, "render");
439 break;
440 case "open":
441 blocks.set(span.from, "source");
442 stack.push({ tag: shape.tag, center: shape.center, span });
443 break;
444 case "close": {
445 const top = stack[stack.length - 1];
446 if (top && top.tag === shape.tag) {
447 stack.pop();
448 blocks.set(top.span.from, "absorb");
449 blocks.set(span.from, "absorb");
450 partners.set(top.span.from, span);
451 partners.set(span.from, top.span);
452 if (top.center) centers.push({ from: top.span.to, to: span.from });
453 } else {
454 blocks.set(span.from, "source");
455 }
456 break;
457 }
458 default:
459 blocks.set(span.from, "source");
460 }
461 return false;
462 },
463 });
464 return { blocks, centers, partners };
465}
466
467function centeredAt(centers: Span[], pos: number): boolean {
468 return centers.some((span) => pos >= span.from && pos <= span.to);
469}
470
471/** Inline HTML pair tags the preview styles by hiding the tags and classing the content. */
472const INLINE_STYLE: Record<string, string> = {
473 b: "cm-lp-strong",
474 strong: "cm-lp-strong",
475 i: "cm-lp-em",
476 em: "cm-lp-em",
477 code: "cm-lp-code",
478 samp: "cm-lp-code",
479 var: "cm-lp-code",
480 kbd: "cm-html-kbd",
481 sub: "cm-html-sub",
482 sup: "cm-html-sup",
483 del: "cm-lp-strike",
484 s: "cm-lp-strike",
485 strike: "cm-lp-strike",
486 ins: "cm-html-ins",
487 u: "cm-html-ins",
488 mark: "cm-html-mark",
489 small: "cm-html-small",
490 a: "cm-lp-link",
491 span: "",
492 picture: "",
493};
494
495const TAG_HEAD = /^<(\/?)([a-zA-Z][\w-]*)/;
496
497interface InlineHtmlContext {
498 state: EditorState;
499 ranges: Range<Decoration>[];
500 htmlOptions: { resolveUrl?: (url: string) => string };
501 /** Close tags already claimed by their opener. */
502 consumedCloses: Set<number>;
503 /** End of a whole-range widget render; tags before this are already covered. */
504 consumedUntil: number;
505}
506
507function inlineSource(ctx: InlineHtmlContext, from: number, to: number): void {
508 ctx.ranges.push(Decoration.mark({ class: "cm-lp-html-inline" }).range(from, to));
509 highlightHtmlSource(ctx.state, ctx.ranges, from, to);
510}
511
512/** The named siblings strictly between two tags are all HTMLTags and the rest is whitespace. */
513function onlyHtmlBetween(state: EditorState, open: SyntaxNode, close: SyntaxNode): boolean {
514 let pos = open.to;
515 for (let sib = open.nextSibling; sib && sib.from < close.from; sib = sib.nextSibling) {
516 if (sib.name !== "HTMLTag") return false;
517 if (state.sliceDoc(pos, sib.from).trim() !== "") return false;
518 pos = sib.to;
519 }
520 return state.sliceDoc(pos, close.from).trim() === "";
521}
522
523function decorateInlineHtml(ctx: InlineHtmlContext, node: SyntaxNode): void {
524 const { state, ranges } = ctx;
525 if (node.from < ctx.consumedUntil || ctx.consumedCloses.has(node.from)) return;
526 const text = state.sliceDoc(node.from, node.to);
527 const show = lineRevealed(state, node.from);
528
529 if (text.startsWith("<!--")) {
530 if (show) inlineSource(ctx, node.from, node.to);
531 else ranges.push(Decoration.replace({ widget: new CommentWidget() }).range(node.from, node.to));
532 return;
533 }
534 const head = TAG_HEAD.exec(text);
535 if (!head) {
536 inlineSource(ctx, node.from, node.to);
537 return;
538 }
539 const tag = head[2]!.toLowerCase();
540 if (head[1] === "/") {
541 // An unconsumed close tag has no opener in this run: visible source.
542 inlineSource(ctx, node.from, node.to);
543 return;
544 }
545 if (tag === "br") {
546 if (show) inlineSource(ctx, node.from, node.to);
547 else ranges.push(Decoration.replace({ widget: new BrWidget() }).range(node.from, node.to));
548 return;
549 }
550 if (tag === "img") {
551 if (show) inlineSource(ctx, node.from, node.to);
552 else
553 ranges.push(
554 Decoration.replace({ widget: new HtmlWidget(text, true, false, ctx.htmlOptions) }).range(
555 node.from,
556 node.to,
557 ),
558 );
559 return;
560 }
561 const style = INLINE_STYLE[tag];
562 if (style === undefined) {
563 inlineSource(ctx, node.from, node.to);
564 return;
565 }
566
567 // Find the matching close among following siblings, tracking same-tag nesting.
568 let depth = 0;
569 let close: SyntaxNode | null = null;
570 for (let sib = node.nextSibling; sib; sib = sib.nextSibling) {
571 if (sib.name !== "HTMLTag") continue;
572 const match = TAG_HEAD.exec(state.sliceDoc(sib.from, sib.to));
573 if (!match || match[2]!.toLowerCase() !== tag) continue;
574 if (match[1] === "/") {
575 if (depth === 0) {
576 close = sib;
577 break;
578 }
579 depth--;
580 } else {
581 depth++;
582 }
583 }
584 if (!close) {
585 inlineSource(ctx, node.from, node.to);
586 return;
587 }
588 ctx.consumedCloses.add(close.from);
589
590 if (revealed(state, node.from, close.to)) {
591 inlineSource(ctx, node.from, node.to);
592 inlineSource(ctx, close.from, close.to);
593 if (style && close.from > node.to)
594 ranges.push(Decoration.mark({ class: style }).range(node.to, close.from));
595 return;
596 }
597 // A pair whose content is itself pure HTML (badge rows: `<a href><img></a>`, `<picture>…`)
598 // renders whole as one sanitized widget. Same-line only: the inline pass runs in a view plugin,
599 // which may not replace across line breaks; multi-line pairs fall through to tag styling.
600 const sameLine = state.doc.lineAt(node.from).number === state.doc.lineAt(close.to).number;
601 if (sameLine && (tag === "picture" || tag === "a") && onlyHtmlBetween(state, node, close)) {
602 ranges.push(
603 Decoration.replace({
604 widget: new HtmlWidget(state.sliceDoc(node.from, close.to), true, false, ctx.htmlOptions),
605 }).range(node.from, close.to),
606 );
607 ctx.consumedUntil = close.to;
608 return;
609 }
610 ranges.push(Decoration.replace({}).range(node.from, node.to));
611 ranges.push(Decoration.replace({}).range(close.from, close.to));
612 if (style && close.from > node.to)
613 ranges.push(Decoration.mark({ class: style }).range(node.to, close.from));
614}
615
616const TOKEN_CLASS: Record<string, string> = {
617 comment: "cm-html-tok-comment",
618 bracket: "cm-html-tok-bracket",
619 tag: "cm-html-tok-tag",
620 attr: "cm-html-tok-attr",
621 value: "cm-html-tok-value",
622};
623
624/** Mark decorations coloring revealed HTML source, from the pure tokenizer in html.ts. */
625function highlightHtmlSource(
626 state: EditorState,
627 ranges: Range<Decoration>[],
628 from: number,
629 to: number,
630): void {
631 for (const token of htmlTokens(state.sliceDoc(from, to))) {
632 if (token.to > token.from)
633 ranges.push(
634 Decoration.mark({ class: TOKEN_CLASS[token.type]! }).range(
635 from + token.from,
636 from + token.to,
637 ),
638 );
639 }
640}
641
642function htmlSourceLines(
643 state: EditorState,
644 ranges: Range<Decoration>[],
645 from: number,
646 to: number,
647): void {
648 const first = state.doc.lineAt(from).number;
649 const last = state.doc.lineAt(to).number;
650 for (let n = first; n <= last; n++)
651 ranges.push(Decoration.line({ class: "cm-lp-html-src" }).range(state.doc.line(n).from));
652 highlightHtmlSource(state, ranges, from, to);
653}
654
655function emptyPlan(): HtmlPlan {
656 return { blocks: new Map(), centers: [], partners: new Map() };
657}
658
659/**
660 * The whole-document pass: multi-line block replacements only (tables, HTML blocks, block
661 * comments). These decide which lines exist, so they must be stable regardless of the viewport —
662 * scoping them would make document height jump under scroll. The walk prunes leaf containers that
663 * cannot hold them, so its cost is the block skeleton, not the text.
664 */
665function collectBlockFormatting(
666 state: EditorState,
667 tree: Tree,
668 ranges: Range<Decoration>[],
669 options: LivePreviewOptions,
670): void {
671 const blockWidgets = options.blockWidgets ?? false;
672 const html = options.html ?? false;
673 const htmlPlan = html ? planHtml(state, tree) : emptyPlan();
674 const htmlOptions = options.resolveUrl ? { resolveUrl: options.resolveUrl } : {};
675 tree.iterate({
676 enter: (nodeRef) => {
677 if (ATX_LEVEL[nodeRef.name]) return false;
678 switch (nodeRef.name) {
679 case "Paragraph":
680 case "FencedCode":
681 case "CodeBlock":
682 case "SetextHeading1":
683 case "SetextHeading2":
684 return false;
685 case "Table":
686 return decorateTable(
687 state,
688 nodeRef.node,
689 ranges,
690 blockWidgets,
691 centeredAt(htmlPlan.centers, nodeRef.from),
692 );
693 case "HTMLBlock": {
694 const mode = htmlPlan.blocks.get(nodeRef.from);
695 if (!mode) return false; // html preview off: raw text stays visible
696 // Absorbed wrapper halves reveal together: touching either the open or the close block
697 // shows both as source.
698 const partner = htmlPlan.partners.get(nodeRef.from);
699 const show =
700 revealed(state, nodeRef.from, nodeRef.to) ||
701 (partner !== undefined && revealed(state, partner.from, partner.to));
702 if (mode === "source" || show) {
703 htmlSourceLines(state, ranges, nodeRef.from, nodeRef.to);
704 } else if (mode === "render") {
705 ranges.push(
706 Decoration.replace({
707 widget: new HtmlWidget(
708 state.sliceDoc(nodeRef.from, nodeRef.to),
709 false,
710 centeredAt(htmlPlan.centers, nodeRef.from),
711 htmlOptions,
712 ),
713 block: true,
714 }).range(nodeRef.from, nodeRef.to),
715 );
716 } else if (mode === "collapse") {
717 // Comment block: a collapsed `<!-- ⋯ -->` placeholder that expands on caret entry.
718 ranges.push(
719 Decoration.replace({ widget: new CommentWidget(), block: true }).range(
720 nodeRef.from,
721 nodeRef.to,
722 ),
723 );
724 } else {
725 // absorb (paired wrapper tag): the lines fold away entirely.
726 ranges.push(Decoration.replace({ block: true }).range(nodeRef.from, nodeRef.to));
727 }
728 return false;
729 }
730 case "CommentBlock": {
731 // Block-level `<!-- -->` gets its own node type; collapse it like a comment HTMLBlock.
732 if (!html) return false;
733 if (revealed(state, nodeRef.from, nodeRef.to))
734 htmlSourceLines(state, ranges, nodeRef.from, nodeRef.to);
735 else
736 ranges.push(
737 Decoration.replace({ widget: new CommentWidget(), block: true }).range(
738 nodeRef.from,
739 nodeRef.to,
740 ),
741 );
742 return false;
743 }
744 default:
745 return undefined;
746 }
747 },
748 });
749}
750
751/**
752 * The viewport pass: marks, line classes, and single-line widgets over `[from, to]`. Nothing here
753 * may change which lines exist (no block widgets, no cross-line replaces) — that is what lets the
754 * view plugin scope it to the visible ranges on large documents.
755 */
756function collectInlineFormatting(
757 state: EditorState,
758 tree: Tree,
759 ranges: Range<Decoration>[],
760 options: LivePreviewOptions,
761 from: number,
762 to: number,
763): void {
764 const blockWidgets = options.blockWidgets ?? false;
765 const html = options.html ?? false;
766 const resolve = options.resolveUrl ?? ((url: string) => url);
767 const htmlPlan = html ? planHtml(state, tree) : emptyPlan();
768 const inlineCtx: InlineHtmlContext = {
769 state,
770 ranges,
771 htmlOptions: options.resolveUrl ? { resolveUrl: options.resolveUrl } : {},
772 consumedCloses: new Set(),
773 consumedUntil: 0,
774 };
775 // `[!type]` callout markers parse as shortcut-reference Link nodes; remember their ranges so
776 // the link decorator leaves them to the callout label.
777 const calloutMarkers: Array<{ from: number; to: number }> = [];
778 const excluded = options.exclude?.(state) ?? [];
779 tree.iterate({
780 from,
781 to,
782 enter: (nodeRef) => {
783 if (excluded.some((range) => range.from <= nodeRef.from && range.to >= nodeRef.to))
784 return false;
785 const delimited = DELIMITED[nodeRef.name];
786 if (delimited) {
787 decorateDelimited(state, nodeRef.node, delimited.mark, delimited.className, ranges);
788 return undefined;
789 }
790 const level = ATX_LEVEL[nodeRef.name];
791 if (level) {
792 decorateHeading(state, nodeRef.node, level, ranges);
793 return undefined;
794 }
795 switch (nodeRef.name) {
796 case "FencedCode":
797 case "CodeBlock":
798 decorateCodeBlock(state, nodeRef.node, ranges);
799 return false;
800 case "Blockquote": {
801 const marker = decorateBlockquote(state, nodeRef.node, ranges);
802 if (marker) calloutMarkers.push(marker);
803 return undefined;
804 }
805 case "QuoteMark":
806 decorateQuoteMark(state, nodeRef.node, ranges);
807 return undefined;
808 case "ListMark":
809 decorateListMark(state, nodeRef.node, ranges);
810 return undefined;
811 case "Task":
812 decorateTask(state, nodeRef.node, ranges);
813 return undefined;
814 case "Paragraph":
815 decorateIndeterminateTask(state, nodeRef.node, ranges);
816 return undefined;
817 case "Link":
818 if (calloutMarkers.some((m) => nodeRef.from >= m.from && nodeRef.to <= m.to))
819 return false;
820 decorateLink(state, nodeRef.node, ranges);
821 return false;
822 case "Image":
823 return decorateImage(
824 state,
825 nodeRef.node,
826 ranges,
827 blockWidgets,
828 resolve,
829 options.renderVideo,
830 );
831 case "HorizontalRule":
832 if (blockWidgets && !lineRevealed(state, nodeRef.from)) {
833 ranges.push(
834 Decoration.replace({ widget: new HrWidget() }).range(nodeRef.from, nodeRef.to),
835 );
836 }
837 return undefined;
838 case "HTMLTag":
839 if (html) decorateInlineHtml(inlineCtx, nodeRef.node);
840 return false;
841 case "Comment":
842 if (!html) return false;
843 if (lineRevealed(state, nodeRef.from)) inlineSource(inlineCtx, nodeRef.from, nodeRef.to);
844 else
845 ranges.push(
846 Decoration.replace({ widget: new CommentWidget() }).range(nodeRef.from, nodeRef.to),
847 );
848 return false;
849 // Multi-line blocks belong to the whole-document pass.
850 case "Table":
851 return undefined; // inline formatting inside revealed table source still applies
852 case "HTMLBlock":
853 case "CommentBlock":
854 return false;
855 default:
856 return undefined;
857 }
858 },
859 });
860 // Centering wrappers absorb into a text-align on every content line between the pair.
861 for (const span of htmlPlan.centers) {
862 if (span.to < from || span.from > to) continue;
863 const first = state.doc.lineAt(Math.min(span.from + 1, state.doc.length)).number;
864 const last = state.doc.lineAt(span.to).number;
865 for (let n = first; n <= last; n++) {
866 const line = state.doc.line(n);
867 if (line.from >= span.from && line.to <= span.to)
868 ranges.push(Decoration.line({ class: "cm-lp-center" }).range(line.from));
869 }
870 }
871}
872
873/** The whole-document block decorations — exposed for headless tests. */
874export function blockDecorations(
875 state: EditorState,
876 options: LivePreviewOptions = {},
877): Range<Decoration>[] {
878 const ranges: Range<Decoration>[] = [];
879 collectBlockFormatting(state, syntaxTree(state), ranges, options);
880 return ranges;
881}
882
883/** The complete live-preview decoration set for a state — exposed for headless tests. */
884export function formattingDecorations(
885 state: EditorState,
886 options: LivePreviewOptions = {},
887): Range<Decoration>[] {
888 const ranges: Range<Decoration>[] = [];
889 const tree = syntaxTree(state);
890 collectBlockFormatting(state, tree, ranges, options);
891 collectInlineFormatting(state, tree, ranges, options, 0, state.doc.length);
892 return ranges;
893}
894
895const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace";
896
897const livePreviewTheme = EditorView.theme({
898 ".cm-lp-mark": { color: "var(--markdown-muted, var(--muted))" },
899 ".cm-lp-strong": { fontWeight: "600" },
900 ".cm-lp-em": { fontStyle: "italic" },
901 ".cm-lp-strike": { textDecoration: "line-through" },
902 ".cm-lp-code": {
903 fontFamily: MONO,
904 fontSize: "0.9em",
905 backgroundColor: "var(--markdown-panel, var(--panel-2))",
906 borderRadius: "4px",
907 padding: "0.1em 0.3em",
908 margin: "0 -0.15em",
909 },
910 ".cm-lp-codeblock": {
911 fontFamily: MONO,
912 fontSize: "13px",
913 backgroundColor: "var(--markdown-panel, var(--panel-2))",
914 borderLeft: "1px solid var(--border)",
915 borderRight: "1px solid var(--border)",
916 padding: "0 0.6rem",
917 },
918 ".cm-lp-codeblock-top": {
919 borderTop: "1px solid var(--border)",
920 borderTopLeftRadius: "6px",
921 borderTopRightRadius: "6px",
922 paddingTop: "0.25rem",
923 },
924 ".cm-lp-codeblock-bottom": {
925 borderBottom: "1px solid var(--border)",
926 borderBottomLeftRadius: "6px",
927 borderBottomRightRadius: "6px",
928 paddingBottom: "0.25rem",
929 },
930 ".cm-lp-code-lang": { color: "var(--markdown-muted, var(--muted))", fontSize: "0.85em" },
931
932 // Headings size the whole line (marks included) so caret-in/caret-out never shifts vertically.
933 ".cm-lp-heading": { fontWeight: "650", lineHeight: "1.3" },
934 ".cm-lp-h1": { fontSize: "1.6em" },
935 ".cm-lp-h2": { fontSize: "1.4em" },
936 ".cm-lp-h3": { fontSize: "1.2em" },
937 ".cm-lp-h4": { fontSize: "1.05em" },
938 ".cm-lp-h5": { fontSize: "1em" },
939 ".cm-lp-h6": { fontSize: "0.95em", color: "var(--markdown-muted, var(--muted))" },
940
941 ".cm-lp-quote": {
942 borderLeft: "3px solid var(--border)",
943 paddingLeft: "0.7rem",
944 },
945 // GitHub alerts are deliberately spare: no card fill or radius, just a semantic bar and title.
946 ".cm-lp-callout": {
947 borderLeftWidth: "0.25rem",
948 paddingLeft: "1rem",
949 },
950 ".cm-lp-callout-note": { borderLeftColor: "#4c8dff" },
951 ".cm-lp-callout-tip": { borderLeftColor: "#2fbf71" },
952 ".cm-lp-callout-important": { borderLeftColor: "#8b5cf6" },
953 ".cm-lp-callout-warning": { borderLeftColor: "#f5a623" },
954 ".cm-lp-callout-caution": { borderLeftColor: "#ef4444" },
955 ".cm-lp-callout-label": {
956 display: "inline-flex",
957 alignItems: "center",
958 gap: "0.4em",
959 fontWeight: "600",
960 marginRight: "0.45em",
961 },
962 ".cm-lp-callout-icon": { width: "1em", height: "1em", fill: "currentColor" },
963 ".cm-lp-callout-label-note": { color: "#4c8dff" },
964 ".cm-lp-callout-label-tip": { color: "#2fbf71" },
965 ".cm-lp-callout-label-important": { color: "#8b5cf6" },
966 ".cm-lp-callout-label-warning": { color: "#f5a623" },
967 ".cm-lp-callout-label-caution": { color: "#ef4444" },
968
969 ".cm-lp-bullet": { color: "var(--markdown-muted, var(--muted))" },
970 ".cm-lp-list-num": { color: "var(--markdown-muted, var(--muted))" },
971 ".cm-lp-list-nested": {
972 paddingLeft: "1.5rem",
973 borderLeft:
974 "2px solid color-mix(in srgb, var(--markdown-accent, var(--accent)) 35%, var(--border))",
975 },
976 ".cm-lp-task": {
977 appearance: "none",
978 width: "14px",
979 height: "14px",
980 border: "1px solid var(--border)",
981 borderRadius: "4px",
982 backgroundColor: "var(--markdown-panel, var(--panel-2))",
983 verticalAlign: "-0.16em",
984 transform: "translateY(1px)",
985 margin: "0 0.15em 0 0",
986 },
987 ".cm-lp-task:checked, .cm-lp-task:indeterminate": {
988 borderColor: "var(--markdown-accent, var(--accent))",
989 backgroundColor: "var(--markdown-accent, var(--accent))",
990 backgroundRepeat: "no-repeat",
991 },
992 ".cm-lp-task:checked": {
993 backgroundImage:
994 "linear-gradient(135deg, transparent 44%, #fff 44%, #fff 56%, transparent 56%), linear-gradient(45deg, transparent 47%, #fff 47%, #fff 59%, transparent 59%)",
995 backgroundSize: "7px 5px, 4px 7px",
996 backgroundPosition: "5px 2px, 2px 4px",
997 },
998 ".cm-lp-task:indeterminate": {
999 backgroundImage: "linear-gradient(#fff, #fff)",
1000 backgroundSize: "8px 2px",
1001 backgroundPosition: "2px 5px",
1002 },
1003 ".cm-lp-task:focus-visible": {
1004 outline: "2px solid var(--markdown-accent, var(--accent))",
1005 outlineOffset: "2px",
1006 },
1007 ".cm-lp-task-done": { color: "var(--markdown-muted, var(--muted))" },
1008
1009 ".cm-lp-link": { color: "var(--markdown-accent, var(--accent))", textDecoration: "underline" },
1010 ".cm-lp-url": { color: "var(--markdown-muted, var(--muted))" },
1011
1012 ".cm-lp-image img": {
1013 maxWidth: "100%",
1014 maxHeight: "360px",
1015 borderRadius: "6px",
1016 verticalAlign: "text-bottom",
1017 },
1018 ".cm-lp-video": {
1019 width: "auto",
1020 height: "auto",
1021 maxWidth: "100%",
1022 maxHeight: "360px",
1023 borderRadius: "6px",
1024 verticalAlign: "text-bottom",
1025 },
1026 ".cm-lp-video:focus-visible": {
1027 outline: "2px solid var(--markdown-accent, var(--accent))",
1028 outlineOffset: "2px",
1029 },
1030
1031 ".cm-lp-hr": {
1032 display: "inline-block",
1033 width: "100%",
1034 borderTop: "2px solid var(--border)",
1035 verticalAlign: "middle",
1036 },
1037
1038 ".cm-lp-table-src": { fontFamily: MONO, fontSize: "13px" },
1039
1040 ".cm-lp-html-src": {
1041 fontFamily: MONO,
1042 fontSize: "13px",
1043 color: "var(--markdown-muted, var(--muted))",
1044 },
1045 ".cm-lp-html-inline": {
1046 fontFamily: MONO,
1047 fontSize: "0.85em",
1048 color: "var(--markdown-muted, var(--muted))",
1049 },
1050 ".cm-html-tok-bracket": { color: "var(--markdown-muted, var(--muted))" },
1051 ".cm-html-tok-tag": { color: "var(--markdown-accent, var(--accent))" },
1052 ".cm-html-tok-attr": {
1053 color:
1054 "color-mix(in srgb, var(--markdown-text, var(--text)) 75%, var(--markdown-muted, var(--muted)))",
1055 },
1056 ".cm-html-tok-value": {
1057 color:
1058 "color-mix(in srgb, var(--markdown-accent, var(--accent)) 55%, var(--markdown-text, var(--text)))",
1059 },
1060 ".cm-html-tok-comment": { color: "var(--markdown-muted, var(--muted))", fontStyle: "italic" },
1061 ".cm-lp-comment": {
1062 fontFamily: MONO,
1063 fontSize: "0.82em",
1064 color: "var(--markdown-muted, var(--muted))",
1065 opacity: "0.8",
1066 },
1067 ".cm-lp-center": { textAlign: "center" },
1068 ".cm-lp-html": { lineHeight: "1.5" },
1069 ".cm-lp-html.cm-lp-centered": { textAlign: "center" },
1070 ".cm-lp-table.cm-lp-centered": { marginLeft: "auto", marginRight: "auto" },
1071 ".cm-lp-html img": { maxWidth: "100%", maxHeight: "360px", verticalAlign: "middle" },
1072 ".cm-lp-html a": { color: "var(--markdown-accent, var(--accent))" },
1073 ".cm-lp-html h1, .cm-lp-html h2, .cm-lp-html h3": {
1074 margin: "0.2em 0",
1075 lineHeight: "1.3",
1076 },
1077 ".cm-lp-html p": { margin: "0.3em 0" },
1078 ".cm-lp-html details": {
1079 border: "1px solid var(--border)",
1080 borderRadius: "6px",
1081 padding: "0.3rem 0.6rem",
1082 },
1083 ".cm-lp-html summary": { cursor: "pointer", fontWeight: "600", marginBottom: "-0.15em" },
1084 ".cm-lp-html details > summary + *": { marginTop: "0.1em" },
1085 ".cm-lp-html details > :last-child": { marginBottom: "0" },
1086 ".cm-lp-html table": { borderCollapse: "collapse" },
1087 ".cm-lp-html th, .cm-lp-html td": {
1088 border: "1px solid var(--border)",
1089 padding: "3px 10px",
1090 },
1091 ".cm-html-kbd": {
1092 fontFamily: MONO,
1093 fontSize: "0.82em",
1094 border: "1px solid var(--border)",
1095 borderBottomWidth: "2px",
1096 borderRadius: "4px",
1097 padding: "0.05em 0.35em",
1098 backgroundColor: "var(--markdown-panel, var(--panel-2))",
1099 },
1100 ".cm-html-sub": { fontSize: "0.75em", verticalAlign: "sub" },
1101 ".cm-html-sup": { fontSize: "0.75em", verticalAlign: "super" },
1102 ".cm-html-ins": { textDecoration: "underline" },
1103 ".cm-html-mark": {
1104 backgroundColor: "color-mix(in srgb, var(--markdown-accent, var(--accent)) 30%, transparent)",
1105 borderRadius: "3px",
1106 padding: "0 0.15em",
1107 },
1108 ".cm-html-small": { fontSize: "0.85em" },
1109 ".cm-lp-table": {
1110 borderCollapse: "collapse",
1111 margin: "0.2rem 0",
1112 fontSize: "0.95em",
1113 },
1114 ".cm-lp-table th, .cm-lp-table td": {
1115 border: "1px solid var(--border)",
1116 padding: "3px 10px",
1117 textAlign: "left",
1118 },
1119 ".cm-lp-table th": {
1120 fontWeight: "600",
1121 backgroundColor: "var(--markdown-panel, var(--panel-2))",
1122 },
1123});
1124
1125/**
1126 * Vertical cursor movement that lands *inside* a replaced block (rendered table/html, absorbed
1127 * wrapper) instead of skipping it: when the default move would jump more than one document line,
1128 * the cursor is placed on the first hidden line, which reveals it.
1129 */
1130function moveVerticallyIntoBlocks(forward: boolean) {
1131 return (view: EditorView): boolean => {
1132 const { state } = view;
1133 const selection = state.selection.main;
1134 if (!selection.empty) return false;
1135 const target = view.moveVertically(selection, forward);
1136 const fromLine = state.doc.lineAt(selection.head).number;
1137 const targetLine = state.doc.lineAt(target.head).number;
1138 if (forward ? targetLine <= fromLine + 1 : targetLine >= fromLine - 1) return false;
1139 const landing = forward ? state.doc.line(fromLine + 1).from : state.doc.line(fromLine - 1).to;
1140 view.dispatch({
1141 selection: EditorSelection.cursor(landing),
1142 scrollIntoView: true,
1143 userEvent: "select",
1144 });
1145 return true;
1146 };
1147}
1148
1149/**
1150 * Below this document size the inline pass covers the whole document (stable decorations, simpler
1151 * tests); above it, only the visible ranges decorate and the pass re-runs as the viewport moves.
1152 */
1153const INLINE_WHOLE_DOC_LIMIT = 32_000;
1154
1155/** The live-preview layer: block field + viewport-scoped inline plugin plus class styles. */
1156export function livePreview(options: LivePreviewOptions = {}): Extension {
1157 // Block replacements (tables, HTML blocks) decide which lines exist, so they live in a state
1158 // field covering the whole document; CodeMirror also requires block decorations to come from a
1159 // field, not a plugin.
1160 const field = StateField.define<DecorationSet>({
1161 create: (state) => Decoration.set(blockDecorations(state, options), true),
1162 // The tree-identity check matters: the incremental parser finishes after the initial build in
1163 // transactions with no doc or selection change, and decorations built from the partial tree
1164 // would otherwise stay stale until the next caret move.
1165 update: (previous, transaction) =>
1166 transaction.docChanged ||
1167 transaction.selection ||
1168 transaction.effects.some((effect) => effect.is(setPreviewFocus)) ||
1169 syntaxTree(transaction.state) !== syntaxTree(transaction.startState)
1170 ? Decoration.set(blockDecorations(transaction.state, options), true)
1171 : previous,
1172 provide: (self) => EditorView.decorations.from(self),
1173 });
1174
1175 // Marks, line classes, and single-line widgets rebuild only over the visible ranges once the
1176 // document outgrows the whole-doc limit — the fix for whole-document decoration cost.
1177 const buildInline = (view: EditorView): DecorationSet => {
1178 const ranges: Range<Decoration>[] = [];
1179 const tree = syntaxTree(view.state);
1180 const spans =
1181 view.state.doc.length <= INLINE_WHOLE_DOC_LIMIT
1182 ? [{ from: 0, to: view.state.doc.length }]
1183 : view.visibleRanges;
1184 for (const span of spans)
1185 collectInlineFormatting(view.state, tree, ranges, options, span.from, span.to);
1186 return Decoration.set(ranges, true);
1187 };
1188 const inlinePlugin = ViewPlugin.fromClass(
1189 class {
1190 decorations: DecorationSet;
1191
1192 constructor(view: EditorView) {
1193 this.decorations = buildInline(view);
1194 }
1195
1196 update(update: ViewUpdate) {
1197 if (
1198 update.docChanged ||
1199 update.selectionSet ||
1200 update.viewportChanged ||
1201 update.focusChanged ||
1202 update.transactions.some((tr) => tr.effects.some((e) => e.is(setPreviewFocus))) ||
1203 syntaxTree(update.state) !== syntaxTree(update.startState)
1204 )
1205 this.decorations = buildInline(update.view);
1206 }
1207 },
1208 { decorations: (value) => value.decorations },
1209 );
1210 // DOM focus/blur dispatch synchronously (they run outside the update cycle), keeping the
1211 // reveal gate current without a scheduling dance.
1212 const focusWatcher = EditorView.domEventHandlers({
1213 focus: (_event, view) => {
1214 view.dispatch({ effects: setPreviewFocus.of(true) });
1215 return false;
1216 },
1217 blur: (_event, view) => {
1218 view.dispatch({ effects: setPreviewFocus.of(false) });
1219 return false;
1220 },
1221 });
1222 return [
1223 syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
1224 previewFocus,
1225 focusWatcher,
1226 field,
1227 inlinePlugin,
1228 livePreviewTheme,
1229 // High precedence so these beat the base cursor commands; a picker's Prec.highest keymap
1230 // still claims the arrows first in composer-style hosts.
1231 ...(options.blockWidgets
1232 ? [
1233 Prec.high(
1234 keymap.of([
1235 { key: "ArrowDown", run: moveVerticallyIntoBlocks(true) },
1236 { key: "ArrowUp", run: moveVerticallyIntoBlocks(false) },
1237 ]),
1238 ),
1239 ]
1240 : []),
1241 ];
1242}