223 lines
8.0 KiB
TypeScript
223 lines
8.0 KiB
TypeScript
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
|
import type { DocsTableBorder } from "@/lib/drive/docs-table-types"
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
}
|
|
|
|
function parseHtmlBorder(style: string, side: "top" | "right" | "bottom" | "left"): DocsTableBorder | null {
|
|
const match = style.match(new RegExp(`border-${side}:\\s*([^;]+)`, "i"))
|
|
if (!match) return null
|
|
const value = match[1]?.trim()
|
|
if (!value || value === "none" || value === "0") return null
|
|
const parts = value.split(/\s+/)
|
|
if (parts.length < 2) return null
|
|
const size = parseFloat(parts[0] ?? "")
|
|
const styleName = parts[1] ?? "solid"
|
|
const color = parts.slice(2).join(" ") || "#000000"
|
|
return {
|
|
size: Number.isFinite(size) ? Math.round(size * 8) : 6,
|
|
style: styleName === "solid" ? "single" : styleName,
|
|
color,
|
|
}
|
|
}
|
|
|
|
function parseHtmlCellAttrs(cell: HTMLTableCellElement): Record<string, unknown> {
|
|
const attrs: Record<string, unknown> = {}
|
|
|
|
const colspan = Number(cell.getAttribute("colspan") || "1")
|
|
const rowspan = Number(cell.getAttribute("rowspan") || "1")
|
|
if (colspan > 1) attrs.colspan = colspan
|
|
if (rowspan > 1) attrs.rowspan = rowspan
|
|
|
|
const width = cell.getAttribute("width") || cell.style.width
|
|
if (width) {
|
|
const px = width.endsWith("px") ? parseFloat(width) : parseFloat(width)
|
|
if (Number.isFinite(px) && px > 0) attrs.colwidth = [Math.round(px)]
|
|
}
|
|
|
|
const bg =
|
|
cell.getAttribute("bgcolor") ||
|
|
cell.getAttribute("data-background-color") ||
|
|
cell.style.backgroundColor
|
|
if (bg) attrs.backgroundColor = bg
|
|
|
|
const align = cell.getAttribute("align") || cell.style.textAlign
|
|
if (align === "left" || align === "center" || align === "right" || align === "justify") {
|
|
attrs.align = align
|
|
}
|
|
|
|
const valign = cell.getAttribute("valign") || cell.style.verticalAlign
|
|
if (valign === "top" || valign === "middle" || valign === "bottom") {
|
|
attrs.verticalAlign = valign
|
|
}
|
|
|
|
const inlineStyle = cell.getAttribute("style") ?? ""
|
|
const borderTop = parseHtmlBorder(inlineStyle, "top")
|
|
const borderRight = parseHtmlBorder(inlineStyle, "right")
|
|
const borderBottom = parseHtmlBorder(inlineStyle, "bottom")
|
|
const borderLeft = parseHtmlBorder(inlineStyle, "left")
|
|
if (borderTop) attrs.borderTop = borderTop
|
|
if (borderRight) attrs.borderRight = borderRight
|
|
if (borderBottom) attrs.borderBottom = borderBottom
|
|
if (borderLeft) attrs.borderLeft = borderLeft
|
|
|
|
return attrs
|
|
}
|
|
|
|
function inlineTextFromElement(el: HTMLElement): TipTapJSON[] {
|
|
const content: TipTapJSON[] = []
|
|
for (const node of el.childNodes) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent ?? ""
|
|
if (text) content.push({ type: "text", text })
|
|
continue
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
|
const child = node as HTMLElement
|
|
const tag = child.tagName.toLowerCase()
|
|
const marks: TipTapJSON[] = []
|
|
if (tag === "strong" || tag === "b") marks.push({ type: "bold" })
|
|
if (tag === "em" || tag === "i") marks.push({ type: "italic" })
|
|
if (tag === "u") marks.push({ type: "underline" })
|
|
const text = child.textContent ?? ""
|
|
if (text) content.push({ type: "text", text, ...(marks.length ? { marks } : {}) })
|
|
}
|
|
return content
|
|
}
|
|
|
|
export function htmlTableToTipTap(table: HTMLTableElement): TipTapJSON {
|
|
const rows = Array.from(table.querySelectorAll(":scope > tbody > tr, :scope > tr"))
|
|
const tableAttrs: Record<string, unknown> = {}
|
|
const align = table.getAttribute("align") || table.style.margin
|
|
if (table.getAttribute("align") === "center") tableAttrs.alignment = "center"
|
|
else if (table.getAttribute("align") === "right") tableAttrs.alignment = "right"
|
|
|
|
const width = table.getAttribute("width") || table.style.width
|
|
if (width && width !== "auto") tableAttrs.layout = "fixed"
|
|
|
|
const rowNodes = rows.map((row, rowIndex) => {
|
|
const htmlRow = row as HTMLTableRowElement
|
|
const cells = Array.from(htmlRow.querySelectorAll(":scope > th, :scope > td"))
|
|
const rowAttrs: Record<string, unknown> = {}
|
|
const rowHeight = htmlRow.style.height || htmlRow.getAttribute("height")
|
|
if (rowHeight) rowAttrs.rowHeight = rowHeight.endsWith("px") ? rowHeight : `${rowHeight}px`
|
|
if (rowIndex === 0 && cells.every((cell) => cell.tagName.toLowerCase() === "th")) {
|
|
rowAttrs.header = true
|
|
}
|
|
|
|
return {
|
|
type: "tableRow",
|
|
...(Object.keys(rowAttrs).length ? { attrs: rowAttrs } : {}),
|
|
content: cells.map((cell) => {
|
|
const htmlCell = cell as HTMLTableCellElement
|
|
const isHeader = htmlCell.tagName.toLowerCase() === "th"
|
|
const attrs = parseHtmlCellAttrs(htmlCell)
|
|
const paragraphContent = inlineTextFromElement(htmlCell)
|
|
return {
|
|
type: isHeader ? "tableHeader" : "tableCell",
|
|
...(Object.keys(attrs).length ? { attrs } : {}),
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: paragraphContent.length ? paragraphContent : [],
|
|
},
|
|
],
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
|
|
return {
|
|
type: "table",
|
|
...(Object.keys(tableAttrs).length ? { attrs: tableAttrs } : {}),
|
|
content: rowNodes.length ? rowNodes : [{ type: "tableRow", content: [{ type: "tableCell", content: [{ type: "paragraph" }] }] }],
|
|
}
|
|
}
|
|
|
|
function normalizeCellNode(node: TipTapJSON): TipTapJSON | null {
|
|
if (!isRecord(node)) return null
|
|
if (node.type !== "tableCell" && node.type !== "tableHeader") return node
|
|
|
|
const attrs = isRecord(node.attrs) ? { ...node.attrs } : {}
|
|
if (attrs.rowspan === 0) return null
|
|
|
|
if (typeof attrs.colspan !== "number" || attrs.colspan < 1) attrs.colspan = 1
|
|
if (typeof attrs.rowspan !== "number" || attrs.rowspan < 1) attrs.rowspan = 1
|
|
|
|
if (Array.isArray(attrs.colwidth)) {
|
|
const colwidth = (attrs.colwidth as unknown[])
|
|
.map((value) => (typeof value === "number" && Number.isFinite(value) ? value : null))
|
|
.filter((value): value is number => value != null)
|
|
if (colwidth.length) attrs.colwidth = colwidth
|
|
else delete attrs.colwidth
|
|
}
|
|
|
|
const content = Array.isArray(node.content)
|
|
? node.content.map((child) => normalizeTableTree(child)).filter(Boolean)
|
|
: [{ type: "paragraph" }]
|
|
|
|
return {
|
|
...node,
|
|
attrs,
|
|
content: content.length ? content : [{ type: "paragraph" }],
|
|
}
|
|
}
|
|
|
|
function normalizeRowNode(node: TipTapJSON): TipTapJSON | null {
|
|
if (!isRecord(node) || node.type !== "tableRow") return node
|
|
const content = Array.isArray(node.content)
|
|
? node.content
|
|
.map((child) => normalizeCellNode(child))
|
|
.filter((child): child is TipTapJSON => Boolean(child))
|
|
: []
|
|
|
|
if (!content.length) return null
|
|
return { ...node, content }
|
|
}
|
|
|
|
function normalizeTableNode(node: TipTapJSON): TipTapJSON | null {
|
|
if (!isRecord(node) || node.type !== "table") return node
|
|
const content = Array.isArray(node.content)
|
|
? node.content
|
|
.map((child) => normalizeRowNode(child))
|
|
.filter((child): child is TipTapJSON => Boolean(child))
|
|
: []
|
|
|
|
if (!content.length) {
|
|
return {
|
|
type: "table",
|
|
content: [
|
|
{
|
|
type: "tableRow",
|
|
content: [{ type: "tableCell", content: [{ type: "paragraph" }] }],
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
return { ...node, content }
|
|
}
|
|
|
|
function normalizeTableTree(node: unknown): TipTapJSON | null {
|
|
if (!isRecord(node)) return null
|
|
if (node.type === "table") return normalizeTableNode(node)
|
|
if (node.type === "tableRow") return normalizeRowNode(node)
|
|
if (node.type === "tableCell" || node.type === "tableHeader") return normalizeCellNode(node)
|
|
|
|
if (Array.isArray(node.content)) {
|
|
return {
|
|
...node,
|
|
content: node.content.map((child) => normalizeTableTree(child)).filter(Boolean),
|
|
}
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
/** Preserve DOCX/HTML table attrs and drop invalid merged-cell placeholders. */
|
|
export function normalizeImportedTables(content: TipTapJSON): TipTapJSON {
|
|
const normalized = normalizeTableTree(content)
|
|
return (normalized ?? content) as TipTapJSON
|
|
}
|