1// Widget classes for the live-preview layer. Each renders from data captured at decoration-build
2// time; the field rebuilds on every doc change, so stored offsets are valid whenever a widget
3// handles an event.
4import type { EditorView } from "@codemirror/view";
5import { WidgetType } from "@codemirror/view";
6
7import { type HtmlOptions, sanitizeHtml } from "./html.ts";
8
9// No TS parameter properties anywhere here: kiln imports this module through the live-preview
10// graph and its type-stripping cannot transform them.
11
12/** The rendered bullet for an unrevealed `-`/`*`/`+` list mark. */
13export class BulletWidget extends WidgetType {
14 override eq(): boolean {
15 return true;
16 }
17
18 override toDOM(): HTMLElement {
19 const dot = document.createElement("span");
20 dot.className = "cm-lp-bullet";
21 dot.textContent = "•";
22 return dot;
23 }
24}
25
26/** A clickable checkbox replacing a `[ ]`/`[x]` task marker; toggling edits the document. */
27export type TaskState = "unchecked" | "checked" | "indeterminate";
28
29export class TaskCheckboxWidget extends WidgetType {
30 readonly state: TaskState;
31 readonly from: number;
32 readonly to: number;
33 readonly readOnly: boolean;
34
35 constructor(state: TaskState, from: number, to: number, readOnly = false) {
36 super();
37 this.state = state;
38 this.from = from;
39 this.to = to;
40 this.readOnly = readOnly;
41 }
42
43 override eq(other: TaskCheckboxWidget): boolean {
44 return (
45 other.state === this.state &&
46 other.from === this.from &&
47 other.to === this.to &&
48 other.readOnly === this.readOnly
49 );
50 }
51
52 override toDOM(view: EditorView): HTMLElement {
53 const box = document.createElement("input");
54 box.type = "checkbox";
55 box.className = "cm-lp-task";
56 box.checked = this.state === "checked";
57 box.indeterminate = this.state === "indeterminate";
58 box.addEventListener("mousedown", (event) => event.preventDefault());
59 box.disabled = this.readOnly;
60 box.addEventListener("change", () => {
61 if (view.state.readOnly) return;
62 view.dispatch({
63 changes: { from: this.from, to: this.to, insert: this.state === "checked" ? "[ ]" : "[x]" },
64 userEvent: "input",
65 });
66 });
67 return box;
68 }
69
70 override ignoreEvent(event: Event): boolean {
71 return event.type !== "mousedown";
72 }
73}
74
75export const CALLOUT_TYPES: Record<string, string> = {
76 note: "Note",
77 tip: "Tip",
78 important: "Important",
79 warning: "Warning",
80 caution: "Caution",
81};
82
83/** The styled label replacing an unrevealed `[!type]` callout marker. */
84export class CalloutLabelWidget extends WidgetType {
85 readonly type: string;
86
87 constructor(type: string) {
88 super();
89 this.type = type;
90 }
91
92 override eq(other: CalloutLabelWidget): boolean {
93 return other.type === this.type;
94 }
95
96 override toDOM(): HTMLElement {
97 const label = document.createElement("span");
98 label.className = `cm-lp-callout-label cm-lp-callout-label-${this.type}`;
99 const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
100 icon.setAttribute("class", "cm-lp-callout-icon");
101 icon.setAttribute("viewBox", "0 0 16 16");
102 icon.setAttribute("aria-hidden", "true");
103 const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
104 path.setAttribute(
105 "d",
106 this.type === "tip"
107 ? "M8 1.5a5 5 0 0 0-3 9v1h6v-1a5 5 0 0 0-3-9Zm-2 12h4v1H6Z"
108 : this.type === "warning"
109 ? "M8.87 2.1a1 1 0 0 0-1.74 0L1.2 12.5A1 1 0 0 0 2.07 14h11.86a1 1 0 0 0 .87-1.5ZM7.25 6h1.5v4h-1.5Zm0 5.5h1.5V13h-1.5Z"
110 : this.type === "caution"
111 ? "M5.2 1h5.6L15 5.2v5.6L10.8 15H5.2L1 10.8V5.2Zm2.05 3v6h1.5V4Zm0 7.5V13h1.5v-1.5Z"
112 : this.type === "important"
113 ? "M8 1a5 5 0 0 0-3.5 8.57V12h7V9.57A5 5 0 0 0 8 1Zm-.75 3h1.5v4h-1.5Zm0 5.5h1.5V11h-1.5ZM5 13.5h6V15H5Z"
114 : "M8 1.25a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM7.25 4h1.5v1.5h-1.5Zm0 3h1.5v5h-1.5Z",
115 );
116 icon.append(path);
117 label.append(icon, document.createTextNode(CALLOUT_TYPES[this.type] ?? this.type));
118 return label;
119 }
120}
121
122/** An inline-rendered markdown image (`![alt](url)`), shown when the caret is elsewhere. */
123export class ImageWidget extends WidgetType {
124 readonly url: string;
125 readonly alt: string;
126
127 constructor(url: string, alt: string) {
128 super();
129 this.url = url;
130 this.alt = alt;
131 }
132
133 override eq(other: ImageWidget): boolean {
134 return other.url === this.url && other.alt === this.alt;
135 }
136
137 override toDOM(): HTMLElement {
138 const frame = document.createElement("span");
139 frame.className = "cm-lp-image";
140 const image = document.createElement("img");
141 image.src = this.url;
142 image.alt = this.alt;
143 if (this.alt) image.title = this.alt;
144 frame.append(image);
145 return frame;
146 }
147
148 // Let CodeMirror handle clicks so they place the caret at the image's source.
149 override ignoreEvent(event: Event): boolean {
150 return event.type !== "mousedown";
151 }
152}
153
154function replaceVideoSource(video: HTMLVideoElement, url: string): void {
155 const position = video.currentTime;
156 if (position > 0) {
157 video.addEventListener(
158 "loadedmetadata",
159 () => {
160 if (Number.isFinite(video.duration)) video.currentTime = Math.min(position, video.duration);
161 },
162 { once: true },
163 );
164 }
165 video.src = url;
166}
167
168export interface VideoPreviewInput {
169 readonly source: string;
170 readonly url: string;
171 readonly alt: string;
172}
173
174export type VideoPreviewRenderer = (
175 input: VideoPreviewInput,
176 view: EditorView,
177) => {
178 readonly dom: HTMLElement;
179 readonly update: (input: VideoPreviewInput) => void;
180 readonly destroy: () => void;
181};
182
183const hostedVideos = new WeakMap<
184 HTMLElement,
185 {
186 readonly source: string;
187 readonly render: VideoPreviewRenderer;
188 readonly preview: ReturnType<VideoPreviewRenderer>;
189 }
190>();
191
192/** The authored destination stays stable while signed URLs refresh. */
193export class VideoWidget extends WidgetType {
194 readonly source: string;
195 readonly url: string;
196 readonly alt: string;
197 readonly renderVideo: VideoPreviewRenderer | undefined;
198
199 constructor(source: string, url: string, alt: string, renderVideo?: VideoPreviewRenderer) {
200 super();
201 this.source = source;
202 this.url = url;
203 this.alt = alt;
204 this.renderVideo = renderVideo;
205 }
206
207 override eq(other: VideoWidget): boolean {
208 return (
209 other.source === this.source &&
210 other.url === this.url &&
211 other.alt === this.alt &&
212 other.renderVideo === this.renderVideo
213 );
214 }
215
216 override toDOM(view: EditorView): HTMLElement {
217 if (this.renderVideo) {
218 const preview = this.renderVideo({ source: this.source, url: this.url, alt: this.alt }, view);
219 hostedVideos.set(preview.dom, { source: this.source, render: this.renderVideo, preview });
220 preview.dom.addEventListener("loadedmetadata", () => view.requestMeasure(), true);
221 return preview.dom;
222 }
223 const video = document.createElement("video");
224 video.className = "cm-lp-video";
225 video.dataset["source"] = this.source;
226 video.src = this.url;
227 video.controls = true;
228 video.playsInline = true;
229 video.preload = "metadata";
230 video.tabIndex = 0;
231 video.setAttribute("aria-label", this.alt || "Video preview");
232 video.addEventListener("loadedmetadata", () => view.requestMeasure());
233 const refreshPendingSource = () => {
234 const pending = video.dataset["pendingSrc"];
235 if (pending === undefined) return;
236 delete video.dataset["pendingSrc"];
237 replaceVideoSource(video, pending);
238 };
239 video.addEventListener("pause", refreshPendingSource);
240 video.addEventListener("error", refreshPendingSource);
241 return video;
242 }
243
244 override updateDOM(dom: HTMLElement): boolean {
245 const hosted = hostedVideos.get(dom);
246 if (hosted) {
247 if (hosted.source !== this.source || hosted.render !== this.renderVideo) return false;
248 hosted.preview.update({ source: this.source, url: this.url, alt: this.alt });
249 return true;
250 }
251 if (this.renderVideo) return false;
252 if (!(dom instanceof HTMLVideoElement) || dom.dataset["source"] !== this.source) return false;
253 dom.setAttribute("aria-label", this.alt || "Video preview");
254 if (dom.getAttribute("src") === this.url) {
255 delete dom.dataset["pendingSrc"];
256 } else if (dom.paused) {
257 delete dom.dataset["pendingSrc"];
258 replaceVideoSource(dom, this.url);
259 } else {
260 dom.dataset["pendingSrc"] = this.url;
261 }
262 return true;
263 }
264
265 override destroy(dom: HTMLElement): void {
266 const hosted = hostedVideos.get(dom);
267 if (hosted) {
268 hostedVideos.delete(dom);
269 hosted.preview.destroy();
270 return;
271 }
272 if (!(dom instanceof HTMLVideoElement)) return;
273 delete dom.dataset["pendingSrc"];
274 dom.pause();
275 }
276
277 override ignoreEvent(): boolean {
278 return true;
279 }
280}
281
282/** A thematic break replacing an unrevealed `---` line. */
283export class HrWidget extends WidgetType {
284 override eq(): boolean {
285 return true;
286 }
287
288 override toDOM(): HTMLElement {
289 const rule = document.createElement("span");
290 rule.className = "cm-lp-hr";
291 return rule;
292 }
293
294 override ignoreEvent(event: Event): boolean {
295 return event.type !== "mousedown";
296 }
297}
298
299/** The collapsed placeholder for an unrevealed HTML comment; the caret entering expands it. */
300export class CommentWidget extends WidgetType {
301 override eq(): boolean {
302 return true;
303 }
304
305 override toDOM(): HTMLElement {
306 const marker = document.createElement("span");
307 marker.className = "cm-lp-comment";
308 marker.textContent = "<!-- ⋯ -->";
309 return marker;
310 }
311
312 override ignoreEvent(event: Event): boolean {
313 return event.type !== "mousedown";
314 }
315}
316
317/** A hard line break replacing an unrevealed inline `<br>`. */
318export class BrWidget extends WidgetType {
319 override eq(): boolean {
320 return true;
321 }
322
323 override toDOM(): HTMLElement {
324 const span = document.createElement("span");
325 span.className = "cm-lp-br";
326 span.appendChild(document.createElement("br"));
327 return span;
328 }
329}
330
331/**
332 * A sanitized-HTML rendering of a source slice — the only widget that renders document-authored
333 * markup, and only ever through `sanitizeHtml`. Used for balanced HTML blocks and for
334 * self-contained inline runs (`<img>`, `<picture>…</picture>`, `<a><img></a>`).
335 */
336export class HtmlWidget extends WidgetType {
337 readonly source: string;
338 readonly inline: boolean;
339 readonly centered: boolean;
340 readonly options: HtmlOptions;
341
342 constructor(source: string, inline: boolean, centered: boolean, options: HtmlOptions) {
343 super();
344 this.source = source;
345 this.inline = inline;
346 this.centered = centered;
347 this.options = options;
348 }
349
350 override eq(other: HtmlWidget): boolean {
351 return (
352 other.source === this.source &&
353 other.inline === this.inline &&
354 other.centered === this.centered
355 );
356 }
357
358 override toDOM(): HTMLElement {
359 const frame = document.createElement(this.inline ? "span" : "div");
360 frame.className = this.centered ? "cm-lp-html cm-lp-centered" : "cm-lp-html";
361 const clean = sanitizeHtml(this.source, this.options);
362 if (clean) frame.append(...clean.childNodes);
363 // Nothing survived sanitization (e.g. a script block): render empty; the source is still
364 // reachable — the caret entering the range reveals it.
365 else frame.classList.add("cm-lp-html-empty");
366 return frame;
367 }
368
369 // Interactive descendants (links, details/summary) keep their events; anywhere else a mousedown
370 // goes to CodeMirror so the caret lands in the source and reveals it.
371 override ignoreEvent(event: Event): boolean {
372 const target = event.target as Element | null;
373 if (target?.closest("a, summary, details")) return true;
374 return event.type !== "mousedown";
375 }
376}
377
378export interface TableData {
379 /** Per-column alignment from the delimiter row: "left" | "center" | "right" | "". */
380 align: string[];
381 header: string[];
382 rows: string[][];
383}
384
385/** A GFM table rendered as a real table while the caret is outside the source block. */
386export class TableWidget extends WidgetType {
387 readonly data: TableData;
388 readonly centered: boolean;
389 readonly key: string;
390
391 constructor(data: TableData, centered = false) {
392 super();
393 this.data = data;
394 this.centered = centered;
395 this.key = `${centered}|${JSON.stringify(data)}`;
396 }
397
398 override eq(other: TableWidget): boolean {
399 return other.key === this.key;
400 }
401
402 override toDOM(): HTMLElement {
403 const table = document.createElement("table");
404 table.className = this.centered ? "cm-lp-table cm-lp-centered" : "cm-lp-table";
405 const head = table.createTHead().insertRow();
406 this.data.header.forEach((cell, column) => {
407 const th = document.createElement("th");
408 th.textContent = cell;
409 const align = this.data.align[column];
410 if (align) th.style.textAlign = align;
411 head.append(th);
412 });
413 const body = table.createTBody();
414 for (const row of this.data.rows) {
415 const tr = body.insertRow();
416 this.data.header.forEach((_, column) => {
417 const td = tr.insertCell();
418 td.textContent = row[column] ?? "";
419 const align = this.data.align[column];
420 if (align) td.style.textAlign = align;
421 });
422 }
423 return table;
424 }
425
426 // Let CodeMirror handle clicks so clicking the rendered table opens its source for editing.
427 override ignoreEvent(event: Event): boolean {
428 return event.type !== "mousedown";
429 }
430}