1import { Extension, generateJSON, getHTMLFromFragment } from "@tiptap/core";
2import {
3 Table,
4 TableCell,
5 TableHeader,
6 TableRow,
7} from "@tiptap/extension-table";
8import TextAlign from "@tiptap/extension-text-align";
9import { TextStyle } from "@tiptap/extension-text-style";
10import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
11import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
12import StarterKit from "@tiptap/starter-kit";
13import { SuggestionExtension } from "frappe-ui/editor";
14import FieldAutocompleteList from "./FieldAutocompleteList.vue";
15
16// Tiptap drops any attribute its schema does not declare.
17export const ComponentUtils: Extension = Extension.create({
18 name: "ComponentUtils",
19 addGlobalAttributes() {
20 return [
21 {
22 types: ["video"],
23 attributes: {
24 controls: {
25 default: true,
26 parseHTML: (element) => element.getAttribute("controls"),
27 renderHTML: () => {
28 return { controls: true };
29 },
30 },
31 },
32 },
33 {
34 types: ["image"],
35 attributes: {
36 height: {
37 default: null,
38 parseHTML: (element) => element.getAttribute("height"),
39 renderHTML: (attributes) => {
40 if (!attributes.height) return {};
41 return { height: attributes.height };
42 },
43 },
44 width: {
45 default: null,
46 parseHTML: (element) => element.getAttribute("width"),
47 renderHTML: (attributes) => {
48 if (!attributes.width) return {};
49 return { width: attributes.width };
50 },
51 },
52 },
53 },
54 {
55 types: ["heading"],
56 attributes: {
57 id: {
58 default: null,
59 parseHTML: (element) => element.getAttribute("id"),
60 renderHTML: (attributes) => {
61 if (!attributes.id) {
62 return {};
63 }
64 return { id: attributes.id };
65 },
66 },
67 },
68 },
69 {
70 types: ["paragraph"],
71 attributes: {
72 class: {
73 default: null,
74 parseHTML: (element) => element.getAttribute("class"),
75 renderHTML: (attributes) => {
76 if (!attributes.class) return {};
77 return { class: attributes.class };
78 },
79 },
80 },
81 },
82 {
83 types: ["table"],
84 attributes: {
85 style: {
86 default: null,
87 parseHTML: (el) => el.getAttribute("style"),
88 renderHTML: () => ({
89 style:
90 "border-collapse: collapse; width: 100%; border: 1px solid #d1d5db;",
91 }),
92 },
93 },
94 },
95
96 {
97 types: ["tableRow"],
98 attributes: {
99 style: {
100 default: null,
101 parseHTML: (el) => el.getAttribute("style"),
102 renderHTML: () => ({
103 style: "border: 1px solid #d1d5db;",
104 }),
105 },
106 },
107 },
108
109 {
110 types: ["tableCell", "tableHeader"],
111 attributes: {
112 style: {
113 default: null,
114 parseHTML: (el) => el.getAttribute("style"),
115 renderHTML: () => ({
116 style:
117 "border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; text-align: left;",
118 }),
119 },
120 },
121 },
122 ];
123 },
124});
125
126// frappe-ui rounds article images; this adds a hairline border.
127export const ARTICLE_IMAGE_CLASSES = "[&_img]:border [&_img]:border-outline-gray-2";
128
129// The extensions that match frappe-ui's TextEditor schema, used for generateJSON
130const excelPasteExtensions = [
131 StarterKit,
132 Table,
133 TableRow,
134 TableHeader,
135 TableCell,
136 TextStyle,
137 TextAlign.configure({ types: ["heading", "paragraph"] }),
138];
139
140// Parsed per-element style info extracted from iframe computed styles
141interface ElementStyles {
142 fontWeight: string;
143 fontStyle: string;
144 textDecoration: string;
145 color: string;
146 backgroundColor: string;
147 textAlign: string;
148 fontSize: string;
149 verticalAlign: string;
150 whiteSpace: string;
151}
152
153interface TableCellMeta {
154 colspan: number;
155 rowspan: number;
156 colwidth?: number[];
157}
158
159interface ResolvedExcelPaste {
160 html: string;
161 stylesByCell: Map<string, ElementStyles>;
162 cellMetaByCell: Map<string, TableCellMeta>;
163}
164
165const DEFAULT_TEXT_COLORS = new Set([
166 "rgb(0, 0, 0)",
167 "rgba(0, 0, 0, 0)",
168 "canvastext",
169]);
170
171const DEFAULT_BACKGROUND_COLORS = new Set([
172 "rgba(0, 0, 0, 0)",
173 "rgb(255, 255, 255)",
174 "transparent",
175]);
176
177function convertExcelWidthToPx(width: string | null): number | null {
178 if (!width) return null;
179
180 const normalizedWidth = width.trim().toLowerCase();
181 const match = normalizedWidth.match(/^([\d.]+)\s*(pt|px)?$/);
182 if (!match) return null;
183
184 const value = parseFloat(match[1]);
185 if (!Number.isFinite(value) || value <= 0) return null;
186
187 const unit = match[2] || "px";
188 if (unit === "pt") {
189 return Math.round(value * 1.333);
190 }
191
192 return Math.round(value);
193}
194
195function normalizeCellKey(rowIdx: number, colIdx: number) {
196 return `r${rowIdx}:c${colIdx}`;
197}
198
199function isDefaultTextColor(color: string) {
200 return DEFAULT_TEXT_COLORS.has(color.trim().toLowerCase());
201}
202
203function isDefaultBackgroundColor(color: string) {
204 return DEFAULT_BACKGROUND_COLORS.has(color.trim().toLowerCase());
205}
206
207function sanitizeCssColor(color: string) {
208 return color.trim().toLowerCase();
209}
210
211function applyInlineTextStyles(
212 doc: Document,
213 iframeWindow: Window,
214 root: HTMLElement
215) {
216 const elements = Array.from(root.querySelectorAll("*")) as HTMLElement[];
217
218 elements.forEach((element) => {
219 if (
220 ["TABLE", "TBODY", "THEAD", "TR", "TD", "TH"].includes(element.tagName)
221 ) {
222 return;
223 }
224
225 const computed = iframeWindow.getComputedStyle(element);
226 const styleParts: string[] = [];
227
228 const color = sanitizeCssColor(computed.color);
229 if (!isDefaultTextColor(color)) {
230 styleParts.push(`color: ${computed.color}`);
231 }
232
233 const backgroundColor = sanitizeCssColor(computed.backgroundColor);
234 if (!isDefaultBackgroundColor(backgroundColor)) {
235 styleParts.push(`background-color: ${computed.backgroundColor}`);
236 }
237
238 if (
239 computed.fontSize &&
240 computed.fontSize !== "14.6667px" &&
241 computed.fontSize !== "16px"
242 ) {
243 styleParts.push(`font-size: ${computed.fontSize}`);
244 }
245
246 if (styleParts.length) {
247 const existingStyle = element.getAttribute("style");
248 element.setAttribute(
249 "style",
250 [existingStyle, ...styleParts].filter(Boolean).join("; ")
251 );
252 }
253 });
254}
255
256function preserveWhitespaceAndBreaks(doc: Document, el: HTMLElement) {
257 const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT);
258 const textNodes: Text[] = [];
259
260 while (walker.nextNode()) {
261 textNodes.push(walker.currentNode as Text);
262 }
263
264 textNodes.forEach((textNode) => {
265 const value = textNode.nodeValue;
266 if (!value || !/[\n\t]| {2,}/.test(value)) return;
267
268 const fragment = doc.createDocumentFragment();
269 const parts = value.split(/(\n|\t| {2,})/g).filter(Boolean);
270
271 parts.forEach((part) => {
272 if (part === "\n") {
273 fragment.appendChild(doc.createElement("br"));
274 return;
275 }
276
277 if (part === "\t") {
278 fragment.appendChild(doc.createTextNode(" "));
279 return;
280 }
281
282 if (/^ {2,}$/.test(part)) {
283 fragment.appendChild(doc.createTextNode(" "));
284 for (let i = 1; i < part.length; i++) {
285 fragment.appendChild(doc.createTextNode("\u00a0"));
286 }
287 return;
288 }
289
290 fragment.appendChild(doc.createTextNode(part));
291 });
292
293 textNode.parentNode?.replaceChild(fragment, textNode);
294 });
295}
296
297function addMarkIfMissing(
298 node: any,
299 type: string,
300 attrs?: Record<string, any>
301) {
302 const marks = [...(node.marks || [])];
303 const existingMark = marks.find((mark) => mark.type === type);
304
305 if (existingMark) {
306 if (attrs) {
307 existingMark.attrs = { ...(existingMark.attrs || {}), ...attrs };
308 }
309 return marks;
310 }
311
312 marks.push(attrs ? { type, attrs } : { type });
313 return marks;
314}
315
316// Use the browser's CSSOM to fully resolve all computed styles on Excel HTML elements.
317// Excel uses stylesheet classes (.xl65 etc.), not inline styles, so DOMParser alone
318// cannot resolve them — we need a live document with the <style> block applied.
319// Styles are keyed by "r{rowIdx}:c{colIdx}" for table cells so they survive DOM mutations.
320async function resolveExcelStyles(html: string): Promise<ResolvedExcelPaste> {
321 return new Promise((resolve) => {
322 const iframe = document.createElement("iframe");
323 iframe.style.cssText =
324 "position:fixed;top:-9999px;left:-9999px;width:2000px;height:2000px;visibility:hidden;";
325 document.body.appendChild(iframe);
326
327 const iframeDoc = iframe.contentDocument!;
328 iframeDoc.open();
329 iframeDoc.write(html);
330 iframeDoc.close();
331
332 requestAnimationFrame(() => {
333 const body = iframeDoc.body;
334 // Key: "r{rowIdx}:c{colIdx}", value: computed styles for that cell
335 const stylesByCell = new Map<string, ElementStyles>();
336 const cellMetaByCell = new Map<string, TableCellMeta>();
337
338 // Capture styles keyed by stable table coordinates
339 iframeDoc.querySelectorAll("tr").forEach((tr, rowIdx) => {
340 (tr as HTMLElement)
341 .querySelectorAll("td, th")
342 .forEach((cell, colIdx) => {
343 const el = cell as HTMLElement;
344 const computed = iframe.contentWindow!.getComputedStyle(el);
345 const cellKey = normalizeCellKey(rowIdx, colIdx);
346 stylesByCell.set(cellKey, {
347 fontWeight: computed.fontWeight,
348 fontStyle: computed.fontStyle,
349 textDecoration:
350 computed.textDecorationLine || computed.textDecoration,
351 color: computed.color,
352 backgroundColor: computed.backgroundColor,
353 textAlign: computed.textAlign,
354 fontSize: computed.fontSize,
355 verticalAlign: computed.verticalAlign,
356 whiteSpace: computed.whiteSpace,
357 });
358
359 const colspan =
360 parseInt(el.getAttribute("colspan") || "1", 10) || 1;
361 const rowspan =
362 parseInt(el.getAttribute("rowspan") || "1", 10) || 1;
363 cellMetaByCell.set(cellKey, {
364 colspan,
365 rowspan,
366 });
367 });
368 });
369
370 // Wrap cell contents with semantic tags based on cell-level computed styles
371 iframeDoc.querySelectorAll("tr").forEach((tr, rowIdx) => {
372 (tr as HTMLElement)
373 .querySelectorAll("td, th")
374 .forEach((cell, colIdx) => {
375 const el = cell as HTMLElement;
376 const s = stylesByCell.get(normalizeCellKey(rowIdx, colIdx));
377 if (!s) return;
378 if (parseInt(s.fontWeight) >= 700)
379 wrapChildren(iframeDoc, el, "strong");
380 if (s.fontStyle === "italic") wrapChildren(iframeDoc, el, "em");
381 if (s.textDecoration?.includes("underline"))
382 wrapChildren(iframeDoc, el, "u");
383 if (s.textDecoration?.includes("line-through"))
384 wrapChildren(iframeDoc, el, "s");
385
386 applyInlineTextStyles(iframeDoc, iframe.contentWindow!, el);
387 preserveWhitespaceAndBreaks(iframeDoc, el);
388 });
389 });
390
391 // Remove CSS classes and <style> block so generateJSON sees clean HTML
392 body.querySelectorAll("*").forEach((el) => el.removeAttribute("class"));
393 iframeDoc.querySelectorAll("style").forEach((s) => s.remove());
394
395 const resultHTML = body.innerHTML;
396 document.body.removeChild(iframe);
397 resolve({ html: resultHTML, stylesByCell, cellMetaByCell });
398 });
399 });
400}
401
402function wrapChildren(doc: Document, el: HTMLElement, tag: string) {
403 if (
404 el.children.length === 1 &&
405 el.children[0].tagName.toLowerCase() === tag
406 ) {
407 return;
408 }
409 const wrapper = doc.createElement(tag);
410 while (el.firstChild) {
411 wrapper.appendChild(el.firstChild);
412 }
413 el.appendChild(wrapper);
414}
415
416// Post-process Tiptap JSON to inject textStyle marks (color, fontSize, backgroundColor)
417// and paragraph attrs (textAlign) that generateJSON cannot infer from semantic HTML alone.
418// stylesByCell is keyed "r{rowIdx}:c{colIdx}" matching table structure.
419function injectStylesIntoJSON(
420 json: any,
421 stylesByCell: Map<string, ElementStyles>,
422 cellMetaByCell: Map<string, TableCellMeta>
423): any {
424 function injectIntoNode(node: any, elStyle: ElementStyles | null): any {
425 if (!elStyle) return node;
426
427 if (node.type === "text") {
428 const marks = [...(node.marks || [])];
429 const textStyleAttrs: Record<string, string> = {};
430
431 // Only apply non-default colors
432 if (
433 elStyle.color &&
434 !isDefaultTextColor(sanitizeCssColor(elStyle.color))
435 ) {
436 textStyleAttrs.color = elStyle.color;
437 }
438 if (
439 elStyle.backgroundColor &&
440 !isDefaultBackgroundColor(sanitizeCssColor(elStyle.backgroundColor))
441 ) {
442 textStyleAttrs.backgroundColor = elStyle.backgroundColor;
443 }
444 if (
445 elStyle.fontSize &&
446 elStyle.fontSize !== "14.6667px" &&
447 elStyle.fontSize !== "16px"
448 ) {
449 textStyleAttrs.fontSize = elStyle.fontSize;
450 }
451
452 if (Object.keys(textStyleAttrs).length > 0) {
453 const existingTextStyle = marks.find((m) => m.type === "textStyle");
454 if (existingTextStyle) {
455 existingTextStyle.attrs = {
456 ...existingTextStyle.attrs,
457 ...textStyleAttrs,
458 };
459 } else {
460 marks.push({ type: "textStyle", attrs: textStyleAttrs });
461 }
462 }
463
464 if (elStyle.textDecoration?.includes("line-through")) {
465 const strikeMarks = addMarkIfMissing({ marks }, "strike");
466 return strikeMarks.length ? { ...node, marks: strikeMarks } : node;
467 }
468
469 return marks.length ? { ...node, marks } : node;
470 }
471
472 if (node.type === "paragraph") {
473 const align = elStyle.textAlign;
474 if (align === "right" || align === "center" || align === "justify") {
475 node = { ...node, attrs: { ...node.attrs, textAlign: align } };
476 }
477 }
478
479 if (node.content) {
480 return {
481 ...node,
482 content: node.content.map((child: any) =>
483 injectIntoNode(child, elStyle)
484 ),
485 };
486 }
487
488 return node;
489 }
490
491 // Walk table cells and inject styles per cell using row/col coordinates
492 function walkNode(node: any): any {
493 if (node.type === "table") {
494 return {
495 ...node,
496 content: (node.content || []).map((row: any, rowIdx: number) => {
497 if (row.type !== "tableRow") return row;
498 return {
499 ...row,
500 content: (row.content || []).map((cell: any, colIdx: number) => {
501 const cellKey = normalizeCellKey(rowIdx, colIdx);
502 const elStyle = stylesByCell.get(cellKey) ?? null;
503 const cellMeta = cellMetaByCell.get(cellKey);
504 return {
505 ...cell,
506 attrs: {
507 ...cell.attrs,
508 ...(cellMeta?.colspan && cellMeta.colspan > 1
509 ? { colspan: cellMeta.colspan }
510 : {}),
511 ...(cellMeta?.rowspan && cellMeta.rowspan > 1
512 ? { rowspan: cellMeta.rowspan }
513 : {}),
514 ...(cellMeta?.colwidth?.length
515 ? { colwidth: cellMeta.colwidth }
516 : {}),
517 },
518 content: (cell.content || []).map((child: any) =>
519 injectIntoNode(child, elStyle)
520 ),
521 };
522 }),
523 };
524 }),
525 };
526 }
527
528 if (node.content) {
529 return { ...node, content: node.content.map(walkNode) };
530 }
531 return node;
532 }
533
534 return { ...json, content: (json.content || []).map(walkNode) };
535}
536
537// Handle pasting from excel properly
538export const HandleExcelPaste = Extension.create({
539 name: "handleExcelPaste",
540
541 addProseMirrorPlugins() {
542 return [
543 new Plugin({
544 key: new PluginKey("handleExcelPaste"),
545 props: {
546 handlePaste(view, event) {
547 const clipboardData = event.clipboardData;
548 if (!clipboardData) return false;
549
550 const types = Array.from(clipboardData.types);
551 const hasFile = types.includes("Files");
552 const hasHtml = types.includes("text/html");
553 const hasText = types.includes("text/plain");
554 const hasRtf = types.includes("text/rtf");
555
556 if (hasFile && hasHtml && hasText && hasRtf) {
557 event.preventDefault();
558 const html = clipboardData.getData("text/html");
559 const text = clipboardData.getData("text/plain");
560
561 if (!html) {
562 view.pasteText(text);
563 return true;
564 }
565
566 // Async: use iframe + getComputedStyle to fully resolve Excel CSS classes,
567 // then insert the enriched content into the editor
568 resolveExcelStyles(html).then(
569 ({ html: normalizedHTML, stylesByCell, cellMetaByCell }) => {
570 // Extract column widths from <col> elements (pt → px)
571 const tempDoc = new DOMParser().parseFromString(
572 html,
573 "text/html"
574 );
575 const colWidths: number[] = [];
576 tempDoc.querySelectorAll("col").forEach((col) => {
577 const span = parseInt(col.getAttribute("span") || "1", 10);
578 const styleWidth =
579 (col.getAttribute("style") || "").match(
580 /width:\s*([\d.]+(?:pt|px)?)/i
581 )?.[1] ?? null;
582 const widthPx =
583 convertExcelWidthToPx(styleWidth) ??
584 convertExcelWidthToPx(col.getAttribute("width"));
585 for (let i = 0; i < span; i++) {
586 colWidths.push(widthPx || 100);
587 }
588 });
589
590 let json = generateJSON(normalizedHTML, excelPasteExtensions);
591
592 // Inject colors, fontSize, textAlign from computed styles
593 json = injectStylesIntoJSON(
594 json,
595 stylesByCell,
596 cellMetaByCell
597 );
598
599 // Inject colwidth into each tableCell based on its column index
600 if (colWidths.length) {
601 (json.content || []).forEach((node: any) => {
602 if (node.type !== "table") return;
603 (node.content || []).forEach((row: any) => {
604 if (row.type !== "tableRow") return;
605 (row.content || []).forEach(
606 (cell: any, colIdx: number) => {
607 if (
608 cell.type !== "tableCell" &&
609 cell.type !== "tableHeader"
610 )
611 return;
612 const span = cell.attrs?.colspan || 1;
613 const widths = colWidths.slice(
614 colIdx,
615 colIdx + span
616 );
617 const widthValues = widths.filter(Boolean);
618 if (widthValues.length) {
619 cell.attrs = {
620 ...cell.attrs,
621 colwidth: widthValues,
622 };
623 }
624 }
625 );
626 });
627 });
628 }
629
630 const { state, dispatch } = view;
631 const { tr, selection, schema } = state;
632 const nodes = (json.content || [])
633 .map((n: any) => {
634 try {
635 return schema.nodeFromJSON(n);
636 } catch {
637 return null;
638 }
639 })
640 .filter(Boolean);
641
642 if (!nodes.length) return;
643 let totalInsertedSize = 0;
644 nodes.forEach((n: any) => {
645 totalInsertedSize += n.nodeSize;
646 });
647
648 const insertTr = tr.replaceWith(
649 selection.from,
650 selection.to,
651 nodes
652 );
653 dispatch(insertTr);
654
655 // After the table is inserted, ensure there is a paragraph after it
656 // and move the cursor there. We do this in a separate transaction so
657 // we can walk the already-updated document and find the exact node
658 // position after the last inserted top-level node.
659 requestAnimationFrame(() => {
660 const currentState = view.state;
661 const followUpTr = currentState.tr;
662
663 // Walk top-level nodes to find the end of the inserted block.
664 let tableEnd: number | null = null;
665 let offset = 0;
666 currentState.doc.forEach((node, nodeOffset) => {
667 if (
668 nodeOffset >= selection.from &&
669 nodeOffset < selection.from + totalInsertedSize
670 ) {
671 tableEnd = nodeOffset + node.nodeSize;
672 }
673 offset = nodeOffset;
674 });
675
676 if (tableEnd === null) return;
677
678 // If nothing exists after the table, insert an empty paragraph.
679 const needsParagraph =
680 tableEnd >= currentState.doc.content.size;
681 if (needsParagraph) {
682 followUpTr.insert(
683 currentState.doc.content.size,
684 currentState.schema.nodes.paragraph.create()
685 );
686 }
687
688 // Resolve the position just inside the paragraph after the table.
689 const targetPos = Math.min(
690 tableEnd + 1,
691 followUpTr.doc.content.size - 1
692 );
693 const $target = followUpTr.doc.resolve(targetPos);
694 followUpTr.setSelection(TextSelection.near($target, 1));
695 followUpTr.scrollIntoView();
696 view.dispatch(followUpTr);
697 });
698 }
699 );
700
701 return true;
702 }
703
704 return false;
705 },
706 },
707 }),
708 ];
709 },
710});
711
712// Handle formatting cleanup
713type StyleValidator = (value: string) => boolean;
714type StyleNormalizer = (value: string) => string | null;
715
716export interface CleanStylesOptions {
717 validators?: Record<string, StyleValidator>;
718 normalizers?: Record<string, StyleNormalizer>;
719 allowProperty?: (property: string, value: string) => boolean;
720}
721
722export const CleanStyles = Extension.create<CleanStylesOptions>({
723 name: "cleanStyles",
724
725 addOptions() {
726 return {
727 validators: {},
728 normalizers: {},
729 allowProperty: () => true,
730 };
731 },
732
733 addCommands() {
734 return {
735 cleanStyles:
736 () =>
737 ({ state, tr, dispatch }) => {
738 const { doc, schema } = state;
739 const textStyleType = schema.marks.textStyle;
740
741 if (!textStyleType) return true;
742
743 const {
744 validators = {},
745 normalizers = {},
746 allowProperty = () => true,
747 } = this.options;
748
749 doc.descendants((node, pos) => {
750 if (!node.isText) return;
751
752 const styleMark = node.marks.find((m) => m.type === textStyleType);
753 if (!styleMark) return;
754
755 const oldAttrs: Record<string, any> = styleMark.attrs ?? {};
756 const newAttrs: Record<string, any> = {};
757
758 for (const [key, rawValue] of Object.entries(oldAttrs)) {
759 if (rawValue == null) continue;
760 const value = String(rawValue);
761
762 if (!allowProperty(key, value)) continue;
763
764 const validator = validators[key];
765 if (validator && !validator(value)) continue;
766
767 const normalizer = normalizers[key];
768 if (normalizer) {
769 const normalized = normalizer(value);
770 if (normalized === null) continue;
771 newAttrs[key] = normalized;
772 } else {
773 newAttrs[key] = rawValue;
774 }
775 }
776
777 const from = pos;
778 const to = pos + node.nodeSize;
779
780 tr.removeMark(from, to, textStyleType);
781
782 if (Object.keys(newAttrs).length > 0) {
783 tr.addMark(from, to, textStyleType.create(newAttrs));
784 }
785 });
786
787 if (tr.steps.length && dispatch) {
788 dispatch(tr);
789 }
790
791 return true;
792 },
793 } as any;
794 },
795});
796
797// getHTML() emits bare `<p></p>` and plain spaces, which HTML collapses;
798// encode them with ` ` so the timeline and mail show what was typed.
799export const PreserveWhitespace = Extension.create({
800 name: "preserveWhitespace",
801
802 onBeforeCreate() {
803 const editor = this.editor;
804 const getHTML = editor.getHTML.bind(editor);
805 editor.getHTML = () =>
806 editor.isEmpty
807 ? getHTML()
808 : getHTMLFromFragment(
809 withVisibleWhitespace(editor.state.doc).content,
810 editor.schema
811 );
812 },
813});
814
815function withVisibleWhitespace(node: ProseMirrorNode): ProseMirrorNode {
816 if (node.type.spec.code) return node;
817 if (node.isTextblock) return withVisibleSpaces(node);
818 if (node.isLeaf) return node;
819 const children: ProseMirrorNode[] = [];
820 node.forEach((child, _offset, index) => {
821 // TrailingNode appends an empty paragraph after a final list or code block
822 const isTrailing =
823 node.type === node.type.schema.topNodeType &&
824 index === node.childCount - 1 &&
825 !child.childCount &&
826 node.maybeChild(index - 1)?.type !== child.type;
827 children.push(isTrailing ? child : withVisibleWhitespace(child));
828 });
829 return node.copy(Fragment.fromArray(children));
830}
831
832function withVisibleSpaces(block: ProseMirrorNode): ProseMirrorNode {
833 if (!block.childCount) {
834 return block.copy(Fragment.from(block.type.schema.text("\u00a0")));
835 }
836 // a space at a line start or after another space collapses in HTML
837 let previous = "\n";
838 const children: ProseMirrorNode[] = [];
839 block.forEach((child) => {
840 if (!child.isText) {
841 previous = child.type.name === "hardBreak" ? "\n" : "";
842 children.push(child);
843 return;
844 }
845 const text = child.text!.replace(/ /g, (space, index, whole) =>
846 /[ \n]/.test(index ? whole[index - 1] : previous) ? "\u00a0" : space
847 );
848 previous = child.text!.slice(-1);
849 children.push(block.type.schema.text(text, child.marks));
850 });
851 return block.copy(Fragment.fromArray(children));
852}
853
854// frappe-ui's suggestion popups (`/`, `@`, `{{`) don't close on an outside click.
855export const DismissSuggestionsOnOutsideClick = Extension.create({
856 name: "dismissSuggestionsOnOutsideClick",
857
858 addProseMirrorPlugins() {
859 return [
860 new Plugin({
861 key: new PluginKey("dismissSuggestionsOnOutsideClick"),
862 view(view) {
863 const onPointerDown = (event: PointerEvent) => {
864 const target = event.target as Element;
865 if (
866 view.dom.contains(target) ||
867 target.closest?.(".editor-popover, [data-suggestion-popup]")
868 )
869 return;
870 for (const plugin of view.state.plugins) {
871 const state = plugin.getState(view.state);
872 if (state?.active && "decorationId" in state)
873 view.dispatch(
874 view.state.tr.setMeta(plugin.spec.key!, { exit: true })
875 );
876 }
877 };
878 document.addEventListener("pointerdown", onPointerDown, true);
879 return {
880 destroy: () =>
881 document.removeEventListener("pointerdown", onPointerDown, true),
882 };
883 },
884 }),
885 ];
886 },
887});
888
889export interface FieldItem {
890 label: string;
891 value: string;
892}
893
894/** The `{{ field }}` picker. The caller supplies the fields it offers. */
895export function createFieldAutocomplete(getFields: () => FieldItem[]) {
896 return SuggestionExtension.configure<FieldItem>({
897 name: "fieldAutocomplete",
898 trigger: "{{",
899 allowSpaces: true,
900 items: (query: string) => {
901 // Return empty list to force the dropdown to close.
902 if (query.includes("}}")) {
903 return [];
904 }
905
906 // Clean up query when editing inside existing {{ }} brackets
907 // Remove any trailing `}}` and trim whitespace
908 const cleanedQuery = query.replace(/\s*}}.*$/, "").trim().toLowerCase();
909
910 return getFields().filter(
911 (field) =>
912 field.label.toLowerCase().includes(cleanedQuery) ||
913 field.value.toLowerCase().includes(cleanedQuery)
914 );
915 },
916 command: ({ editor, range, item }) => {
917 const { state } = editor;
918 const textAfterCursor = state.doc.textBetween(
919 range.to,
920 Math.min(range.to + 10, state.doc.content.size),
921 ""
922 );
923
924 let extendedTo = range.to;
925 const closingMatch = textAfterCursor.match(/^\s*}}/);
926 if (closingMatch) {
927 extendedTo = range.to + closingMatch[0].length;
928 }
929
930 editor
931 .chain()
932 .focus()
933 .deleteRange({ from: range.from, to: extendedTo })
934 .insertContent(`{{ ${item.value} }} `)
935 .run();
936 },
937 listComponent: FieldAutocompleteList,
938 });
939}
940