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