import type { PlaitBoard } from "@plait/core"; import { MindElement, MindNodeComponent, PlaitMind, PlaitMindComponent, getBranchColorByMindElement, getBranchWidthByMindElement, type MindNode, } from "@plait/mind"; import { arrangeSpecialMind, isSpecialMindLayout, isSummaryNode, specialNodeRect, specialSubtreeBounds, } from "@/lib/flow-board/mind-special-layout"; import { measureXmindTopics } from "@/lib/flow-board/xmind-typography"; const ns = "http://www.w3.org/2000/svg"; type TableCell = { x: number; y: number; width: number; height: number; depth: number; branch: number; }; type TableGrid = { kind: "spreadsheet"; x: number; y: number; width: number; height: number; columns: string[]; headerHeight: number; columnHeaderHeight: number; rowHeaderWidth: number; columnWidth: number; rows: { y: number; height: number; branch: number }[]; }; const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]); const tableTopicState = new WeakMap< SVGGElement, { foreignObject: SVGForeignObjectElement; x: string | null; y: string | null; width: string | null; height: string | null; textStyle: string | null; contentStyle: string | null; shape: SVGElement | null; shapeVisibility: string; } >(); function ensureTreeTableStyles() { const id = "xmind-tree-table-topic-styles"; if (document.getElementById(id)) return; const style = document.createElement("style"); style.id = id; style.textContent = ` [data-special-table-topic] .plait-text-container { align-items: center !important; display: flex !important; height: 100% !important; justify-content: center !important; overflow-wrap: normal !important; text-align: center !important; white-space: nowrap !important; width: 100% !important; } [data-special-table-topic] .plait-text-container > [data-slate-node="element"] { text-align: center !important; white-space: nowrap !important; width: 100% !important; } [data-special-table-topic="0"] .plait-text-container { color: #fff !important; } `; document.head.append(style); } function frameTreeTableTopic( component: MindNodeComponent, node: MindNode, enabled: boolean ) { const host = component.getElementG(); const saved = tableTopicState.get(host); if (!enabled) { delete host.dataset.specialTableTopic; if (!saved) return; for (const [name, value] of [ ["x", saved.x], ["y", saved.y], ["width", saved.width], ["height", saved.height], ] as const) { if (value === null) saved.foreignObject.removeAttribute(name); else saved.foreignObject.setAttribute(name, value); } const text = saved.foreignObject.firstElementChild as HTMLElement | null; if (text) { if (saved.textStyle === null) text.removeAttribute("style"); else text.setAttribute("style", saved.textStyle); const content = text.firstElementChild as HTMLElement | null; if (content) { if (saved.contentStyle === null) content.removeAttribute("style"); else content.setAttribute("style", saved.contentStyle); } } if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility; tableTopicState.delete(host); return; } const cell = (node as MindNode & { tableCell?: TableCell }).tableCell; const foreignObject = host.querySelector( ":scope > .text foreignObject" ); if (!cell || !foreignObject) return; ensureTreeTableStyles(); host.dataset.specialTableTopic = String(cell.depth); const currentShape = ( component.nodeShapeGenerator as unknown as { g?: SVGGElement } ).g; if (!saved) { tableTopicState.set(host, { foreignObject, x: foreignObject.getAttribute("x"), y: foreignObject.getAttribute("y"), width: foreignObject.getAttribute("width"), height: foreignObject.getAttribute("height"), textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null, contentStyle: foreignObject.firstElementChild?.firstElementChild?.getAttribute( "style" ) ?? null, shape: currentShape || null, shapeVisibility: currentShape?.style.visibility || "", }); } else if (currentShape && currentShape !== saved.shape) { saved.shape = currentShape; saved.shapeVisibility = currentShape.style.visibility || ""; } if (currentShape) currentShape.style.visibility = "hidden"; foreignObject.setAttribute("x", String(cell.x + 14)); foreignObject.setAttribute("y", String(cell.y + 8)); foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28))); foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16))); const text = foreignObject.firstElementChild as HTMLElement | null; if (text) { text.style.alignItems = "center"; text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a"; text.style.display = "flex"; text.style.height = "100%"; text.style.justifyContent = "center"; text.style.overflowWrap = "normal"; text.style.textAlign = "center"; text.style.whiteSpace = "nowrap"; text.style.width = "100%"; const content = text.firstElementChild as HTMLElement | null; if (content) { content.style.alignItems = "center"; content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a"; content.style.display = "flex"; content.style.height = "100%"; content.style.justifyContent = "center"; content.style.textAlign = "center"; content.style.whiteSpace = "nowrap"; content.style.width = "100%"; } } } function specialLink(component: MindNodeComponent) { const node = component.node; let root: MindNode = node; while (root.parent) root = root.parent; const kind = root.origin.specialLayout; const tableLayout = tableLayouts.has(String(kind)); frameTreeTableTopic(component, node, tableLayout); if (tableLayout) requestAnimationFrame(() => { if (component.getElementG().isConnected) frameTreeTableTopic(component, node, true); }); if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false; if ( kind !== "brace" && kind !== "verticalTimeline" && !tableLayout && node !== root && node.parent !== root ) return false; component.linkLineG?.remove(); const group = document.createElementNS(ns, "g"); group.dataset.specialStructure = kind; const color = getBranchColorByMindElement(component.board, component.element) || "#64748b"; const width = getBranchWidthByMindElement(component.board, component.element) || 2; const path = (d: string) => { const shape = document.createElementNS(ns, "path"); shape.setAttribute("d", d); shape.setAttribute("fill", "none"); shape.setAttribute("stroke", color); shape.setAttribute("stroke-width", String(width)); group.append(shape); }; const blendWithWhite = (value: string, amount: number) => { const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); if (!match) return value; const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16)); return `#${channels .map((channel) => Math.round(channel + (255 - channel) * amount) .toString(16) .padStart(2, "0") ) .join("")}`; }; const solidColor = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback; const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i); if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`; if (/^#[\da-f]{6}$/i.test(value)) return value; const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); if (!rgba) return fallback; const alpha = Number.parseInt(rgba[4], 16) / 255; return `#${rgba .slice(1, 4) .map((channel) => Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha)) .toString(16) .padStart(2, "0") ) .join("")}`; }; const rootRect = specialNodeRect(root); const axisY = rootRect.y + rootRect.height / 2; const branches = root.children.filter((child) => !isSummaryNode(child)); if (kind === "treeTable" || kind === "treeTableSide") { if (node === root) { group.setAttribute("pointer-events", "none"); const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"]; const tableNodes: MindNode[] = []; const collect = (item: MindNode) => { if (!isSummaryNode(item)) tableNodes.push(item); item.children.forEach(collect); }; collect(root); const rootFill = solidColor(root.origin.fill, "#006d77"); tableNodes.forEach((item) => { const cell = (item as typeof item & { tableCell?: TableCell }).tableCell; if (!cell) return; const rectangle = document.createElementNS(ns, "rect"); rectangle.dataset.specialTableCell = item.origin.id || "topic"; rectangle.dataset.depth = String(cell.depth); rectangle.setAttribute("x", String(cell.x)); rectangle.setAttribute("y", String(cell.y)); rectangle.setAttribute("width", String(cell.width)); rectangle.setAttribute("height", String(cell.height)); const branchColor = palette[cell.branch % palette.length] || palette[0]; const explicit = solidColor(item.origin.fill, ""); rectangle.setAttribute( "fill", cell.depth === 0 ? rootFill : explicit || blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72) ); rectangle.setAttribute("stroke", rootFill); rectangle.setAttribute("stroke-width", "2"); rectangle.setAttribute("vector-effect", "non-scaling-stroke"); group.append(rectangle); }); group.dataset.specialTable = kind; } else { group.dataset.specialConnector = kind; } } else if (kind === "spreadsheet") { if (node === root) { group.setAttribute("pointer-events", "none"); const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid; if (grid) { const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"]; const rootFill = solidColor(root.origin.fill, "#1f2766"); const rectangle = ( x: number, y: number, width: number, height: number, fill: string, dataset?: Record ) => { const rect = document.createElementNS(ns, "rect"); rect.setAttribute("x", String(x)); rect.setAttribute("y", String(y)); rect.setAttribute("width", String(width)); rect.setAttribute("height", String(height)); rect.setAttribute("fill", fill); rect.setAttribute("stroke", rootFill); rect.setAttribute("stroke-width", "2"); rect.setAttribute("vector-effect", "non-scaling-stroke"); Object.assign(rect.dataset, dataset); group.append(rect); }; rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, { specialTableCell: root.origin.id || "topic", depth: "0", }); rectangle( grid.x, grid.y + grid.headerHeight, grid.rowHeaderWidth, grid.columnHeaderHeight, rootFill ); grid.columns.forEach((column, index) => { const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth; const y = grid.y + grid.headerHeight; rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, { specialMatrixColumn: column, }); const label = document.createElementNS(ns, "text"); label.dataset.specialMatrixColumnLabel = column; label.setAttribute("x", String(x + grid.columnWidth / 2)); label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5)); label.setAttribute("fill", "#ffffff"); label.setAttribute("font-size", "14"); label.setAttribute("font-weight", "600"); label.setAttribute("text-anchor", "middle"); label.textContent = column; group.append(label); }); grid.rows.forEach((row, rowIndex) => { const rowNode = branches[rowIndex]; const rowColor = solidColor( rowNode?.origin.fill, palette[rowIndex % palette.length] ); rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, { specialTableCell: rowNode?.origin.id || `row-${rowIndex}`, depth: "1", }); grid.columns.forEach((column, columnIndex) => rectangle( grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth, row.y, grid.columnWidth, row.height, "#ffffff", { specialMatrixCell: `${rowIndex}:${columnIndex}` } ) ); rowNode?.children .filter((child) => !isSummaryNode(child)) .forEach((entry) => { const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell; if (!cell) return; rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), { specialMatrixEntry: entry.origin.id || "topic", }); }); }); group.dataset.specialTable = kind; } } else group.dataset.specialConnector = kind; } else if (kind === "verticalTimeline") { const axisX = rootRect.x + rootRect.width / 2; if (node === root) { if (branches.length) { const end = Math.max( ...branches.map((child) => { const rect = specialNodeRect(child); return rect.y + rect.height / 2; }) ); path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`); group.dataset.specialAxis = kind; } } else { const rect = specialNodeRect(node); const right = rect.x > axisX; const endX = right ? rect.x : rect.x + rect.width; const endY = rect.y + rect.height / 2; if (node.parent === root) { path(`M${axisX} ${endY}H${endX}`); } else if (node.parent) { const parent = specialNodeRect(node.parent); const startX = right ? parent.x + parent.width : parent.x; const startY = parent.y + parent.height / 2; const midX = (startX + endX) / 2; path( `M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}` ); } group.dataset.specialConnector = kind; } } else if (kind === "brace") { const parent = node.parent; const siblings = parent?.children.filter((child) => !isSummaryNode(child)); if (parent && siblings?.[0] === node) { const owner = specialNodeRect(parent); const rects = siblings.map(specialNodeRect); const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2)); const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2)); const middle = (top + bottom) / 2; const right = Math.min(...rects.map((rect) => rect.x)) - 10; const x = right - 18; const left = Math.max(owner.x + owner.width + 6, x - 18); const radius = Math.min(18, (bottom - top) / 4); path( top === bottom ? `M${left} ${top}H${right}` : `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}` ); } } else if (node === root) { if (branches.length) { const end = Math.max( ...branches.map((child) => { const rect = specialSubtreeBounds(child); return rect.x + rect.width; }) ) + 32; path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`); group.dataset.specialAxis = kind; } } else { const rect = specialNodeRect(node); const above = rect.y + rect.height / 2 < axisY; const endX = rect.x + rect.width / 2; const startX = endX - (kind === "fishbone" ? 48 : 0); path( `M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}` ); group.dataset.specialConnector = kind; if (kind === "timeline") { const circle = document.createElementNS(ns, "circle"); circle.setAttribute("cx", String(startX)); circle.setAttribute("cy", String(axisY)); circle.setAttribute("r", "4"); circle.setAttribute("fill", color); group.append(circle); } } component.linkLineG = group; const host = component.getElementG(); if (tableLayout && node === root && host.parentElement) host.parentElement.insertBefore(group, host.parentElement.firstChild); else host.append(group); return true; } class SpecialMindRoot extends PlaitMindComponent { override updateMindLayout(element = this.element) { measureXmindTopics(this.board, element); super.updateMindLayout(element); if (isSpecialMindLayout(element.specialLayout)) arrangeSpecialMind(this.root, element.specialLayout); } override drawLink() { if (!specialLink(this)) { this.linkLineG?.remove(); this.linkLineG = undefined; super.drawLink(); } } } class SpecialMindNode extends MindNodeComponent { private lastSpecialLayout: unknown; private rootLayout() { let root = this.node; while (root.parent) root = root.parent; return root.origin.specialLayout; } override onContextChanged( ...args: Parameters ) { super.onContextChanged(...args); // A structure switch can leave descendant positions and parent identity unchanged. if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink(); } override drawLink() { if (!specialLink(this)) super.drawLink(); this.lastSpecialLayout = this.rootLayout(); } } const installed = new WeakSet(); export function withSpecialMindLayouts(board: PlaitBoard) { if (installed.has(board)) return board; installed.add(board); const drawElement = board.drawElement; board.drawElement = (context) => { if (PlaitMind.isMind(context.element)) return SpecialMindRoot; if (MindElement.isMindElement(board, context.element)) return SpecialMindNode; return drawElement(context); }; return board; }