import { Extension, generateJSON, getHTMLFromFragment } from "@tiptap/core"; import { Table, TableCell, TableHeader, TableRow, } from "@tiptap/extension-table"; import TextAlign from "@tiptap/extension-text-align"; import { TextStyle } from "@tiptap/extension-text-style"; import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; import { SuggestionExtension } from "frappe-ui/editor"; import FieldAutocompleteList from "./FieldAutocompleteList.vue"; // Tiptap drops any attribute its schema does not declare. export const ComponentUtils: Extension = Extension.create({ name: "ComponentUtils", addGlobalAttributes() { return [ { types: ["video"], attributes: { controls: { default: true, parseHTML: (element) => element.getAttribute("controls"), renderHTML: () => { return { controls: true }; }, }, }, }, { types: ["image"], attributes: { height: { default: null, parseHTML: (element) => element.getAttribute("height"), renderHTML: (attributes) => { if (!attributes.height) return {}; return { height: attributes.height }; }, }, width: { default: null, parseHTML: (element) => element.getAttribute("width"), renderHTML: (attributes) => { if (!attributes.width) return {}; return { width: attributes.width }; }, }, }, }, { types: ["heading"], attributes: { id: { default: null, parseHTML: (element) => element.getAttribute("id"), renderHTML: (attributes) => { if (!attributes.id) { return {}; } return { id: attributes.id }; }, }, }, }, { types: ["paragraph"], attributes: { class: { default: null, parseHTML: (element) => element.getAttribute("class"), renderHTML: (attributes) => { if (!attributes.class) return {}; return { class: attributes.class }; }, }, }, }, { types: ["table"], attributes: { style: { default: null, parseHTML: (el) => el.getAttribute("style"), renderHTML: () => ({ style: "border-collapse: collapse; width: 100%; border: 1px solid #d1d5db;", }), }, }, }, { types: ["tableRow"], attributes: { style: { default: null, parseHTML: (el) => el.getAttribute("style"), renderHTML: () => ({ style: "border: 1px solid #d1d5db;", }), }, }, }, { types: ["tableCell", "tableHeader"], attributes: { style: { default: null, parseHTML: (el) => el.getAttribute("style"), renderHTML: () => ({ style: "border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; text-align: left;", }), }, }, }, ]; }, }); // frappe-ui rounds article images; this adds a hairline border. export const ARTICLE_IMAGE_CLASSES = "[&_img]:border [&_img]:border-outline-gray-2"; // The extensions that match frappe-ui's TextEditor schema, used for generateJSON const excelPasteExtensions = [ StarterKit, Table, TableRow, TableHeader, TableCell, TextStyle, TextAlign.configure({ types: ["heading", "paragraph"] }), ]; // Parsed per-element style info extracted from iframe computed styles interface ElementStyles { fontWeight: string; fontStyle: string; textDecoration: string; color: string; backgroundColor: string; textAlign: string; fontSize: string; verticalAlign: string; whiteSpace: string; } interface TableCellMeta { colspan: number; rowspan: number; colwidth?: number[]; } interface ResolvedExcelPaste { html: string; stylesByCell: Map; cellMetaByCell: Map; } const DEFAULT_TEXT_COLORS = new Set([ "rgb(0, 0, 0)", "rgba(0, 0, 0, 0)", "canvastext", ]); const DEFAULT_BACKGROUND_COLORS = new Set([ "rgba(0, 0, 0, 0)", "rgb(255, 255, 255)", "transparent", ]); function convertExcelWidthToPx(width: string | null): number | null { if (!width) return null; const normalizedWidth = width.trim().toLowerCase(); const match = normalizedWidth.match(/^([\d.]+)\s*(pt|px)?$/); if (!match) return null; const value = parseFloat(match[1]); if (!Number.isFinite(value) || value <= 0) return null; const unit = match[2] || "px"; if (unit === "pt") { return Math.round(value * 1.333); } return Math.round(value); } function normalizeCellKey(rowIdx: number, colIdx: number) { return `r${rowIdx}:c${colIdx}`; } function isDefaultTextColor(color: string) { return DEFAULT_TEXT_COLORS.has(color.trim().toLowerCase()); } function isDefaultBackgroundColor(color: string) { return DEFAULT_BACKGROUND_COLORS.has(color.trim().toLowerCase()); } function sanitizeCssColor(color: string) { return color.trim().toLowerCase(); } function applyInlineTextStyles( doc: Document, iframeWindow: Window, root: HTMLElement ) { const elements = Array.from(root.querySelectorAll("*")) as HTMLElement[]; elements.forEach((element) => { if ( ["TABLE", "TBODY", "THEAD", "TR", "TD", "TH"].includes(element.tagName) ) { return; } const computed = iframeWindow.getComputedStyle(element); const styleParts: string[] = []; const color = sanitizeCssColor(computed.color); if (!isDefaultTextColor(color)) { styleParts.push(`color: ${computed.color}`); } const backgroundColor = sanitizeCssColor(computed.backgroundColor); if (!isDefaultBackgroundColor(backgroundColor)) { styleParts.push(`background-color: ${computed.backgroundColor}`); } if ( computed.fontSize && computed.fontSize !== "14.6667px" && computed.fontSize !== "16px" ) { styleParts.push(`font-size: ${computed.fontSize}`); } if (styleParts.length) { const existingStyle = element.getAttribute("style"); element.setAttribute( "style", [existingStyle, ...styleParts].filter(Boolean).join("; ") ); } }); } function preserveWhitespaceAndBreaks(doc: Document, el: HTMLElement) { const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT); const textNodes: Text[] = []; while (walker.nextNode()) { textNodes.push(walker.currentNode as Text); } textNodes.forEach((textNode) => { const value = textNode.nodeValue; if (!value || !/[\n\t]| {2,}/.test(value)) return; const fragment = doc.createDocumentFragment(); const parts = value.split(/(\n|\t| {2,})/g).filter(Boolean); parts.forEach((part) => { if (part === "\n") { fragment.appendChild(doc.createElement("br")); return; } if (part === "\t") { fragment.appendChild(doc.createTextNode(" ")); return; } if (/^ {2,}$/.test(part)) { fragment.appendChild(doc.createTextNode(" ")); for (let i = 1; i < part.length; i++) { fragment.appendChild(doc.createTextNode("\u00a0")); } return; } fragment.appendChild(doc.createTextNode(part)); }); textNode.parentNode?.replaceChild(fragment, textNode); }); } function addMarkIfMissing( node: any, type: string, attrs?: Record ) { const marks = [...(node.marks || [])]; const existingMark = marks.find((mark) => mark.type === type); if (existingMark) { if (attrs) { existingMark.attrs = { ...(existingMark.attrs || {}), ...attrs }; } return marks; } marks.push(attrs ? { type, attrs } : { type }); return marks; } // Use the browser's CSSOM to fully resolve all computed styles on Excel HTML elements. // Excel uses stylesheet classes (.xl65 etc.), not inline styles, so DOMParser alone // cannot resolve them — we need a live document with the