504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
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";
|
|
|
|
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<SVGForeignObjectElement>(
|
|
":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<string, string>
|
|
) => {
|
|
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) {
|
|
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<MindNodeComponent["onContextChanged"]>
|
|
) {
|
|
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<PlaitBoard>();
|
|
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;
|
|
}
|