feat(diagram): enhance flowchart interactive connection and canvas theme palettes
This commit is contained in:
1 parent
2124866a78
commit
63bbcae193
7 files changed
+575
-178
No files matched your search
@@ -21,8 +21,38 @@ describe("diagram editor keyboard workflow", () => {
|
||||
|
||||
describe("diagram editor canvas surface", () => {
|
||||
test("keeps alignment grids for flowcharts without adding visual noise to mind maps", () => {
|
||||
expect(source).toContain('grid: document.kind === "flowchart" ? diagramGrid(documentTheme) : false');
|
||||
expect(source).toContain('if (kind === "flowchart") graph.drawGrid(diagramGrid(theme));');
|
||||
expect(source).toContain('grid: document.kind === "flowchart" ? diagramGrid(documentTheme, appearance) : false');
|
||||
expect(source).toContain('if (kind === "flowchart") graph.drawGrid(diagramGrid(theme, appearance));');
|
||||
expect(source).toContain("else graph.clearGrid();");
|
||||
});
|
||||
|
||||
test("exposes flowchart-only connection handles with safe connection rules", () => {
|
||||
expect(source).toContain('kind === "flowchart" ? { ports:');
|
||||
expect(source).toContain('allowPort: document.kind === "flowchart"');
|
||||
expect(source).toContain('allowBlank: document.kind === "flowchart"');
|
||||
expect(source).toContain("allowNode: false");
|
||||
expect(source).toContain("allowLoop: false");
|
||||
expect(source).toContain("allowMulti: false");
|
||||
expect(source).toContain("sourceCell.id !== targetCell.id");
|
||||
expect(source).toContain("data-diagram-kind={document.kind}");
|
||||
});
|
||||
|
||||
test("turns a connection dropped on blank canvas into a connected-node picker", () => {
|
||||
expect(source).toContain('graph.on("edge:connected"');
|
||||
expect(source).toContain('role="dialog"');
|
||||
expect(source).toContain('t("diagram.quickCreateTitle")');
|
||||
expect(source).toContain('createConnectedFlowNode("process")');
|
||||
expect(source).toContain('createConnectedFlowNode("decision")');
|
||||
expect(source).toContain('createConnectedFlowNode("terminator")');
|
||||
expect(source).toContain("draftEdgeId: edge.id");
|
||||
expect(source).toContain("removeFlowDraftEdge");
|
||||
expect(source).toContain('graph.startBatch("quick-create")');
|
||||
});
|
||||
|
||||
test("repaints the graph when the application appearance changes", () => {
|
||||
expect(source).toContain("const { resolvedTheme } = useAppearanceTheme();");
|
||||
expect(source).toContain("applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme);");
|
||||
expect(source).toContain("data-diagram-appearance={resolvedTheme}");
|
||||
expect(source).toContain("<ThemeToggle />");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FileImage,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Maximize2,
|
||||
Redo2,
|
||||
Save,
|
||||
@@ -37,8 +38,11 @@ import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { useAppearanceTheme } from "@/components/ThemeProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import { compactMindMapNodeSize, computeDiagramLayout } from "@/lib/diagram-layout";
|
||||
import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme";
|
||||
import { isLocalMemoId } from "@/lib/local-mirror";
|
||||
import { isBrowserOffline } from "@/lib/network-status";
|
||||
import type { EdgeEverRepository } from "@/lib/repository";
|
||||
@@ -54,6 +58,17 @@ type DiagramEditorPaneProps = {
|
||||
|
||||
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string };
|
||||
type MindMapInsertRelation = "child" | "sibling";
|
||||
type FlowPort = "top" | "right" | "bottom" | "left";
|
||||
type FlowQuickCreateState = {
|
||||
draftEdgeId: string;
|
||||
restoreHistory: boolean;
|
||||
sourceNodeId: string;
|
||||
sourcePort?: FlowPort;
|
||||
x: number;
|
||||
y: number;
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
type NodeEditorState = {
|
||||
nodeId: string;
|
||||
originalValue: string;
|
||||
@@ -69,75 +84,44 @@ type NodeEditorState = {
|
||||
borderColor: string;
|
||||
};
|
||||
|
||||
const BRAND_GREEN = "#16A06E";
|
||||
const createId = (prefix: string) => `${prefix}-${crypto.randomUUID()}`;
|
||||
const oppositeFlowPort = (port?: FlowPort): FlowPort | undefined => port ? ({
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
} as const)[port] : undefined;
|
||||
|
||||
const DIAGRAM_THEMES: Record<DiagramTheme, {
|
||||
topicFill: string;
|
||||
topicText: string;
|
||||
nodeFill: string;
|
||||
nodeText: string;
|
||||
nodeStroke: string;
|
||||
topicStroke: string;
|
||||
mindMapEdge: string;
|
||||
flowEdge: string;
|
||||
canvas: string;
|
||||
grid: string;
|
||||
gridStrong: string;
|
||||
}> = {
|
||||
brand: {
|
||||
topicFill: BRAND_GREEN,
|
||||
topicText: "#FFFFFF",
|
||||
nodeFill: "#F0F8F4",
|
||||
nodeText: "#173B2E",
|
||||
nodeStroke: "#B8DFD0",
|
||||
topicStroke: "#12845B",
|
||||
mindMapEdge: "#55B891",
|
||||
flowEdge: "#408A6D",
|
||||
canvas: "#F8FAF9",
|
||||
grid: "#E6EEE9",
|
||||
gridStrong: "#CFDDD5",
|
||||
},
|
||||
ocean: {
|
||||
topicFill: "#DEF1E9",
|
||||
topicText: "#0F4432",
|
||||
nodeFill: "#FFFEFA",
|
||||
nodeText: "#26352E",
|
||||
nodeStroke: "#D5E3DB",
|
||||
topicStroke: BRAND_GREEN,
|
||||
mindMapEdge: "#8ACCB2",
|
||||
flowEdge: "#6B9281",
|
||||
canvas: "#FBFCFA",
|
||||
grid: "#EBF0EC",
|
||||
gridStrong: "#D9E3DC",
|
||||
},
|
||||
ink: {
|
||||
topicFill: "#16A06E",
|
||||
topicText: "#F5FBF8",
|
||||
nodeFill: "#19261F",
|
||||
nodeText: "#E5EEE9",
|
||||
nodeStroke: "#3C594A",
|
||||
topicStroke: "#68D6B0",
|
||||
mindMapEdge: "#58BA94",
|
||||
flowEdge: "#68A78D",
|
||||
canvas: "#101512",
|
||||
grid: "#202B25",
|
||||
gridStrong: "#304038",
|
||||
},
|
||||
const flowNodeSize = (shape: DiagramNodeShape) => (
|
||||
shape === "decision" ? { width: 132, height: 84 } : { width: 140, height: 52 }
|
||||
);
|
||||
|
||||
const removeFlowDraftEdge = (graph: Graph, edge: Edge, restoreHistory: boolean) => {
|
||||
if (graph.isHistoryEnabled()) graph.disableHistory();
|
||||
graph.removeCell(edge);
|
||||
if (restoreHistory) graph.enableHistory();
|
||||
};
|
||||
|
||||
const diagramGrid = (theme: DiagramTheme) => ({
|
||||
visible: true,
|
||||
type: "doubleMesh" as const,
|
||||
args: [
|
||||
{ color: DIAGRAM_THEMES[theme].grid, thickness: 1 },
|
||||
{ color: DIAGRAM_THEMES[theme].gridStrong, thickness: 1, factor: 5 },
|
||||
],
|
||||
});
|
||||
const diagramGrid = (theme: DiagramTheme, appearance: DiagramAppearance) => {
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
return {
|
||||
visible: true,
|
||||
type: "doubleMesh" as const,
|
||||
args: [
|
||||
{ color: palette.grid, thickness: 1 },
|
||||
{ color: palette.gridStrong, thickness: 1, factor: 5 },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const applyDiagramSurface = (graph: Graph, theme: DiagramTheme, kind: DiagramDocument["kind"]) => {
|
||||
graph.drawBackground({ color: DIAGRAM_THEMES[theme].canvas });
|
||||
if (kind === "flowchart") graph.drawGrid(diagramGrid(theme));
|
||||
const applyDiagramSurface = (
|
||||
graph: Graph,
|
||||
theme: DiagramTheme,
|
||||
kind: DiagramDocument["kind"],
|
||||
appearance: DiagramAppearance,
|
||||
) => {
|
||||
graph.drawBackground({ color: resolveDiagramPalette(theme, appearance).canvas });
|
||||
if (kind === "flowchart") graph.drawGrid(diagramGrid(theme, appearance));
|
||||
else graph.clearGrid();
|
||||
};
|
||||
|
||||
@@ -163,13 +147,18 @@ const createLocalEditSession = (memo: MemoDetail): MemoEditSession => ({
|
||||
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
});
|
||||
|
||||
const nodeEditorState = (graph: Graph, node: Node, theme: DiagramTheme): NodeEditorState => {
|
||||
const nodeEditorState = (
|
||||
graph: Graph,
|
||||
node: Node,
|
||||
theme: DiagramTheme,
|
||||
appearance: DiagramAppearance,
|
||||
): NodeEditorState => {
|
||||
const data = node.getData<NodeData>();
|
||||
const bbox = node.getBBox();
|
||||
const topLeft = graph.localToGraph({ x: bbox.x, y: bbox.y });
|
||||
const bottomRight = graph.localToGraph({ x: bbox.x + bbox.width, y: bbox.y + bbox.height });
|
||||
const isRootTopic = data?.shape === "topic" && !data.parentId;
|
||||
const attrs = nodeAttrs(data?.shape ?? "process", theme, isRootTopic);
|
||||
const attrs = nodeAttrs(data?.shape ?? "process", theme, appearance, isRootTopic);
|
||||
return {
|
||||
nodeId: node.id,
|
||||
originalValue: data?.label ?? "",
|
||||
@@ -186,8 +175,13 @@ const nodeEditorState = (graph: Graph, node: Node, theme: DiagramTheme): NodeEdi
|
||||
};
|
||||
};
|
||||
|
||||
const nodeAttrs = (shape: DiagramNodeShape, theme: DiagramTheme, isRootTopic = false) => {
|
||||
const palette = DIAGRAM_THEMES[theme];
|
||||
const nodeAttrs = (
|
||||
shape: DiagramNodeShape,
|
||||
theme: DiagramTheme,
|
||||
appearance: DiagramAppearance,
|
||||
isRootTopic = false,
|
||||
) => {
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
const isTerminator = shape === "terminator";
|
||||
const isAccent = isRootTopic || isTerminator;
|
||||
return {
|
||||
@@ -203,11 +197,16 @@ const nodeAttrs = (shape: DiagramNodeShape, theme: DiagramTheme, isRootTopic = f
|
||||
};
|
||||
};
|
||||
|
||||
const nodeMetadata = (node: DiagramDocument["nodes"][number], theme: DiagramTheme) => {
|
||||
const nodeMetadata = (
|
||||
node: DiagramDocument["nodes"][number],
|
||||
theme: DiagramTheme,
|
||||
kind: DiagramDocument["kind"],
|
||||
appearance: DiagramAppearance,
|
||||
) => {
|
||||
const isDecision = node.shape === "decision";
|
||||
const isRootTopic = node.shape === "topic" && !node.parentId;
|
||||
const visualAttrs = nodeAttrs(node.shape, theme, isRootTopic);
|
||||
const palette = DIAGRAM_THEMES[theme];
|
||||
const visualAttrs = nodeAttrs(node.shape, theme, appearance, isRootTopic);
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
const size = node.shape === "topic"
|
||||
? compactMindMapNodeSize(node.label, isRootTopic)
|
||||
: { width: node.width, height: node.height };
|
||||
@@ -223,36 +222,44 @@ const nodeMetadata = (node: DiagramDocument["nodes"][number], theme: DiagramThem
|
||||
body: visualAttrs.body,
|
||||
label: { ...visualAttrs.label, text: node.label },
|
||||
},
|
||||
ports: {
|
||||
...(kind === "flowchart" ? { ports: {
|
||||
groups: {
|
||||
top: { position: "top", attrs: { circle: { r: 4, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 1.5 } } },
|
||||
right: { position: "right", attrs: { circle: { r: 4, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 1.5 } } },
|
||||
bottom: { position: "bottom", attrs: { circle: { r: 4, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 1.5 } } },
|
||||
left: { position: "left", attrs: { circle: { r: 4, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 1.5 } } },
|
||||
top: { position: "top", attrs: { circle: { r: 5, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 2 } } },
|
||||
right: { position: "right", attrs: { circle: { r: 5, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 2 } } },
|
||||
bottom: { position: "bottom", attrs: { circle: { r: 5, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 2 } } },
|
||||
left: { position: "left", attrs: { circle: { r: 5, magnet: true, stroke: palette.topicStroke, fill: palette.canvas, strokeWidth: 2 } } },
|
||||
},
|
||||
items: ["top", "right", "bottom", "left"].map((group) => ({ id: group, group })),
|
||||
},
|
||||
} } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const edgeMetadata = (edge: DiagramDocument["edges"][number], kind: DiagramDocument["kind"], theme: DiagramTheme) => ({
|
||||
id: edge.id,
|
||||
source: { cell: edge.source },
|
||||
target: { cell: edge.target },
|
||||
router: kind === "flowchart" ? { name: "manhattan", args: { padding: 20 } } : undefined,
|
||||
connector: { name: kind === "mind-map" ? "smooth" : "rounded", args: { radius: 10 } },
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: kind === "mind-map" ? DIAGRAM_THEMES[theme].mindMapEdge : DIAGRAM_THEMES[theme].flowEdge,
|
||||
strokeWidth: kind === "mind-map" ? 2 : 1.5,
|
||||
targetMarker: kind === "mind-map" ? null : { name: "block", width: 8, height: 6 },
|
||||
const edgeMetadata = (
|
||||
edge: DiagramDocument["edges"][number],
|
||||
kind: DiagramDocument["kind"],
|
||||
theme: DiagramTheme,
|
||||
appearance: DiagramAppearance,
|
||||
) => {
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
return {
|
||||
id: edge.id,
|
||||
source: { cell: edge.source },
|
||||
target: { cell: edge.target },
|
||||
router: kind === "flowchart" ? { name: "manhattan", args: { padding: 20 } } : undefined,
|
||||
connector: { name: kind === "mind-map" ? "smooth" : "rounded", args: { radius: 10 } },
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge,
|
||||
strokeWidth: kind === "mind-map" ? 2 : 1.5,
|
||||
targetMarker: kind === "mind-map" ? null : { name: "block", width: 8, height: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: edge.label ? [{ attrs: {
|
||||
label: { text: edge.label, fill: DIAGRAM_THEMES[theme].nodeText, fontSize: 12 },
|
||||
body: { fill: DIAGRAM_THEMES[theme].canvas, stroke: DIAGRAM_THEMES[theme].nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
|
||||
} }] : undefined,
|
||||
});
|
||||
labels: edge.label ? [{ attrs: {
|
||||
label: { text: edge.label, fill: palette.nodeText, fontSize: 12 },
|
||||
body: { fill: palette.canvas, stroke: palette.nodeStroke, strokeWidth: 1, rx: 5, ry: 5 },
|
||||
} }] : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: DiagramTheme): DiagramDocument => ({
|
||||
schemaVersion: DIAGRAM_SCHEMA_VERSION,
|
||||
@@ -316,19 +323,47 @@ const fitDiagramContent = (graph: Graph, document: DiagramDocument, container: H
|
||||
graph.translate(translation.tx + desiredLeft - rootLeft, translation.ty);
|
||||
};
|
||||
|
||||
const detectGraphTheme = (graph: Graph): DiagramTheme | null => {
|
||||
const node = graph.getNodes()[0];
|
||||
if (!node) return null;
|
||||
const shape = node.getData<NodeData>()?.shape ?? "process";
|
||||
const stroke = node.attr("body/stroke");
|
||||
return (Object.keys(DIAGRAM_THEMES) as DiagramTheme[]).find((candidate) => {
|
||||
const palette = DIAGRAM_THEMES[candidate];
|
||||
return stroke === (shape === "topic" ? palette.topicStroke : palette.nodeStroke);
|
||||
}) ?? null;
|
||||
const applyGraphPalette = (
|
||||
graph: Graph,
|
||||
theme: DiagramTheme,
|
||||
kind: DiagramDocument["kind"],
|
||||
appearance: DiagramAppearance,
|
||||
) => {
|
||||
const palette = resolveDiagramPalette(theme, appearance);
|
||||
const historyEnabled = graph.isHistoryEnabled();
|
||||
if (historyEnabled) graph.disableHistory();
|
||||
try {
|
||||
for (const node of graph.getNodes()) {
|
||||
const data = node.getData<NodeData>();
|
||||
const shape = data?.shape ?? "process";
|
||||
const attrs = nodeAttrs(shape, theme, appearance, shape === "topic" && !data?.parentId);
|
||||
node.attr("body", attrs.body);
|
||||
node.attr("label", { ...attrs.label, text: data?.label ?? "" });
|
||||
for (const port of node.getPorts()) {
|
||||
if (!port.id) continue;
|
||||
node.portProp(port.id, "attrs/circle", {
|
||||
stroke: palette.topicStroke,
|
||||
fill: palette.canvas,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const edge of graph.getEdges()) {
|
||||
edge.attr("line/stroke", kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge);
|
||||
if (edge.getLabels().length > 0) {
|
||||
edge.attr("label/fill", palette.nodeText);
|
||||
edge.attr("body/fill", palette.canvas);
|
||||
edge.attr("body/stroke", palette.nodeStroke);
|
||||
}
|
||||
}
|
||||
applyDiagramSurface(graph, theme, kind, appearance);
|
||||
} finally {
|
||||
if (historyEnabled) graph.enableHistory();
|
||||
}
|
||||
};
|
||||
|
||||
export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, onSaved }: DiagramEditorPaneProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { resolvedTheme } = useAppearanceTheme();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const insertNodeRef = useRef<(relation: MindMapInsertRelation, baseNodeId?: string) => void>(() => undefined);
|
||||
@@ -341,6 +376,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
const [theme, setTheme] = useState<DiagramTheme>(documentTheme);
|
||||
const titleRef = useRef(title);
|
||||
const themeRef = useRef<DiagramTheme>(documentTheme);
|
||||
const appearanceRef = useRef<DiagramAppearance>(resolvedTheme);
|
||||
const savedSnapshotRef = useRef(document ? diagramEditorSnapshot(memo.title ?? "", document) : "");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedNodeLabel, setSelectedNodeLabel] = useState("");
|
||||
@@ -352,12 +388,14 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
||||
const [historyState, setHistoryState] = useState({ undo: false, redo: false });
|
||||
const [nodeEditor, setNodeEditor] = useState<NodeEditorState | null>(null);
|
||||
const [flowQuickCreate, setFlowQuickCreate] = useState<FlowQuickCreateState | null>(null);
|
||||
const flowQuickCreateRef = useRef<FlowQuickCreateState | null>(null);
|
||||
const nodeEditorRef = useRef<NodeEditorState | null>(null);
|
||||
|
||||
const beginNodeEdit = useCallback((node: Node) => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || readOnly) return;
|
||||
const nextEditor = nodeEditorState(graph, node, themeRef.current);
|
||||
const nextEditor = nodeEditorState(graph, node, themeRef.current, appearanceRef.current);
|
||||
nodeEditorRef.current = nextEditor;
|
||||
setNodeEditor(nextEditor);
|
||||
}, [readOnly]);
|
||||
@@ -384,10 +422,23 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
return cell;
|
||||
}, []);
|
||||
|
||||
const dismissFlowQuickCreate = useCallback(() => {
|
||||
const pending = flowQuickCreateRef.current;
|
||||
flowQuickCreateRef.current = null;
|
||||
setFlowQuickCreate(null);
|
||||
if (!pending) return;
|
||||
const graph = graphRef.current;
|
||||
const draftEdge = graph?.getCellById(pending.draftEdgeId);
|
||||
if (graph && draftEdge?.isEdge()) removeFlowDraftEdge(graph, draftEdge, pending.restoreHistory);
|
||||
if (graph && pending.restoreHistory) graph.enableHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setHasSelection(false);
|
||||
flowQuickCreateRef.current = null;
|
||||
setFlowQuickCreate(null);
|
||||
nodeEditorRef.current = null;
|
||||
setNodeEditor(null);
|
||||
setTheme(documentTheme);
|
||||
@@ -420,27 +471,70 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
return () => { cancelled = true; };
|
||||
}, [memo.id, memo.contentHash, memo.revision, readOnly, t]);
|
||||
|
||||
useEffect(() => {
|
||||
appearanceRef.current = resolvedTheme;
|
||||
const graph = graphRef.current;
|
||||
if (!graph || !document) return;
|
||||
applyGraphPalette(graph, themeRef.current, document.kind, resolvedTheme);
|
||||
const currentEditor = nodeEditorRef.current;
|
||||
if (!currentEditor) return;
|
||||
const node = graph.getCellById(currentEditor.nodeId);
|
||||
if (!node?.isNode()) return;
|
||||
const visualState = nodeEditorState(graph, node, themeRef.current, resolvedTheme);
|
||||
const nextEditor = {
|
||||
...currentEditor,
|
||||
color: visualState.color,
|
||||
background: visualState.background,
|
||||
borderColor: visualState.borderColor,
|
||||
};
|
||||
nodeEditorRef.current = nextEditor;
|
||||
setNodeEditor(nextEditor);
|
||||
}, [resolvedTheme, document?.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !document) return;
|
||||
const appearance = appearanceRef.current;
|
||||
const palette = resolveDiagramPalette(documentTheme, appearance);
|
||||
let graph!: Graph;
|
||||
graph = new Graph({
|
||||
container: containerRef.current,
|
||||
autoResize: true,
|
||||
async: true,
|
||||
background: { color: DIAGRAM_THEMES[documentTheme].canvas },
|
||||
grid: document.kind === "flowchart" ? diagramGrid(documentTheme) : false,
|
||||
background: { color: palette.canvas },
|
||||
grid: document.kind === "flowchart" ? diagramGrid(documentTheme, appearance) : false,
|
||||
panning: { enabled: true, eventTypes: ["leftMouseDown", "mouseWheel"] },
|
||||
mousewheel: { enabled: true, modifiers: ["ctrl", "meta"], minScale: 0.3, maxScale: 2.5 },
|
||||
interacting: !readOnly,
|
||||
connecting: {
|
||||
allowBlank: false,
|
||||
allowBlank: document.kind === "flowchart",
|
||||
allowLoop: false,
|
||||
allowNode: true,
|
||||
allowNode: false,
|
||||
allowEdge: false,
|
||||
snap: true,
|
||||
allowPort: document.kind === "flowchart",
|
||||
allowMulti: false,
|
||||
highlight: document.kind === "flowchart",
|
||||
snap: { radius: 24 },
|
||||
router: document.kind === "flowchart" ? "manhattan" : "normal",
|
||||
connector: document.kind === "mind-map" ? "smooth" : "rounded",
|
||||
createEdge: (): Edge => graph.createEdge(edgeMetadata({ id: createId("edge"), source: "", target: "" }, document.kind, documentTheme)),
|
||||
validateConnection: ({ sourceCell, targetCell, sourcePort, targetPort }) => {
|
||||
if (document.kind !== "flowchart" || !sourceCell || !sourcePort) return false;
|
||||
if (!targetCell) return true;
|
||||
return Boolean(targetPort && sourceCell.id !== targetCell.id);
|
||||
},
|
||||
createEdge: (): Edge => {
|
||||
dismissFlowQuickCreate();
|
||||
const restoreHistory = graph.isHistoryEnabled();
|
||||
if (restoreHistory) graph.disableHistory();
|
||||
return graph.createEdge({
|
||||
...edgeMetadata(
|
||||
{ id: createId("edge"), source: "", target: "" },
|
||||
document.kind,
|
||||
themeRef.current,
|
||||
appearanceRef.current,
|
||||
),
|
||||
data: { quickConnectDraft: true, restoreHistory },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
graph.use(new History({ enabled: !readOnly }));
|
||||
@@ -454,8 +548,8 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
},
|
||||
}));
|
||||
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: !readOnly, showNodeSelectionBox: true, showEdgeSelectionBox: true }));
|
||||
graph.addNodes(document.nodes.map((node) => nodeMetadata(node, documentTheme)));
|
||||
graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme)));
|
||||
graph.addNodes(document.nodes.map((node) => nodeMetadata(node, documentTheme, document.kind, appearance)));
|
||||
graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance)));
|
||||
graph.cleanHistory();
|
||||
if (document.kind === "mind-map") fitDiagramContent(graph, document, containerRef.current);
|
||||
else graph.centerContent();
|
||||
@@ -469,17 +563,14 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
updateHistory();
|
||||
};
|
||||
const clearSelectionAfterHistory = () => {
|
||||
const nextTheme = detectGraphTheme(graph) ?? themeRef.current;
|
||||
themeRef.current = nextTheme;
|
||||
setTheme(nextTheme);
|
||||
applyDiagramSurface(graph, nextTheme, document.kind);
|
||||
applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current);
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setHasSelection(false);
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
graphToDocument(graph, document.kind, nextTheme),
|
||||
graphToDocument(graph, document.kind, themeRef.current),
|
||||
));
|
||||
};
|
||||
graph.on("model:updated", markDirty);
|
||||
@@ -488,6 +579,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
graph.on("history:redo", clearSelectionAfterHistory);
|
||||
graph.on("node:click", ({ node }: { node: Node }) => {
|
||||
const data = node.getData<NodeData>();
|
||||
dismissFlowQuickCreate();
|
||||
containerRef.current?.focus({ preventScroll: true });
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedNodeLabel(data?.label ?? "");
|
||||
@@ -495,15 +587,75 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
});
|
||||
graph.on("node:dblclick", ({ node }: { node: Node }) => beginNodeEdit(node));
|
||||
graph.on("edge:click", () => {
|
||||
dismissFlowQuickCreate();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setHasSelection(true);
|
||||
});
|
||||
graph.on("blank:click", () => {
|
||||
dismissFlowQuickCreate();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
setHasSelection(false);
|
||||
});
|
||||
graph.on("edge:removed", ({ edge }: { edge: Edge }) => {
|
||||
const draft = edge.getData<{ quickConnectDraft?: boolean; restoreHistory?: boolean }>();
|
||||
if (flowQuickCreateRef.current?.draftEdgeId === edge.id) {
|
||||
flowQuickCreateRef.current = null;
|
||||
setFlowQuickCreate(null);
|
||||
}
|
||||
if (draft?.quickConnectDraft && draft.restoreHistory) graph.enableHistory();
|
||||
});
|
||||
graph.on("edge:connected", ({ edge, isNew, type, currentCell, currentPort, currentPoint }) => {
|
||||
if (document.kind !== "flowchart" || !isNew || type !== "target") return;
|
||||
const sourceNodeId = edge.getSourceCellId();
|
||||
const source = edge.getSource();
|
||||
const draft = edge.getData<{ restoreHistory?: boolean }>();
|
||||
const removeDraft = () => {
|
||||
graph.removeCell(edge);
|
||||
if (draft?.restoreHistory) graph.enableHistory();
|
||||
};
|
||||
if (!sourceNodeId) {
|
||||
removeDraft();
|
||||
return;
|
||||
}
|
||||
if (currentCell?.isNode()) {
|
||||
removeDraft();
|
||||
graph.startBatch("connect");
|
||||
graph.addEdge({
|
||||
...edgeMetadata(
|
||||
{ id: edge.id, source: sourceNodeId, target: currentCell.id },
|
||||
document.kind,
|
||||
themeRef.current,
|
||||
appearanceRef.current,
|
||||
),
|
||||
source,
|
||||
target: { cell: currentCell.id, ...(currentPort ? { port: currentPort } : {}) },
|
||||
});
|
||||
graph.stopBatch("connect");
|
||||
return;
|
||||
}
|
||||
if (!currentPoint || !containerRef.current) {
|
||||
removeDraft();
|
||||
return;
|
||||
}
|
||||
const overlayPoint = graph.localToGraph(currentPoint);
|
||||
const popupWidth = 246;
|
||||
const popupHeight = 112;
|
||||
const nextQuickCreate: FlowQuickCreateState = {
|
||||
draftEdgeId: edge.id,
|
||||
restoreHistory: Boolean(draft?.restoreHistory),
|
||||
sourceNodeId,
|
||||
sourcePort: "port" in source && typeof source.port === "string" ? source.port as FlowPort : undefined,
|
||||
x: currentPoint.x,
|
||||
y: currentPoint.y,
|
||||
left: Math.max(12, Math.min(overlayPoint.x + 12, containerRef.current.clientWidth - popupWidth - 12)),
|
||||
top: Math.max(12, Math.min(overlayPoint.y + 12, containerRef.current.clientHeight - popupHeight - 12)),
|
||||
};
|
||||
flowQuickCreateRef.current = nextQuickCreate;
|
||||
setFlowQuickCreate(nextQuickCreate);
|
||||
if (nextQuickCreate.restoreHistory) graph.enableHistory();
|
||||
});
|
||||
graph.bindKey(["backspace", "delete"], (event) => {
|
||||
event.preventDefault();
|
||||
if (!removeGraphSelection(graph)) return;
|
||||
@@ -535,10 +687,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
graph.bindKey(["meta+z", "ctrl+z"], (event) => {
|
||||
event.preventDefault();
|
||||
graph.undo();
|
||||
const nextTheme = detectGraphTheme(graph) ?? themeRef.current;
|
||||
themeRef.current = nextTheme;
|
||||
setTheme(nextTheme);
|
||||
applyDiagramSurface(graph, nextTheme, document.kind);
|
||||
applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current);
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
@@ -546,16 +695,13 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
graphToDocument(graph, document.kind, nextTheme),
|
||||
graphToDocument(graph, document.kind, themeRef.current),
|
||||
));
|
||||
});
|
||||
graph.bindKey(["meta+shift+z", "ctrl+shift+z"], (event) => {
|
||||
event.preventDefault();
|
||||
graph.redo();
|
||||
const nextTheme = detectGraphTheme(graph) ?? themeRef.current;
|
||||
themeRef.current = nextTheme;
|
||||
setTheme(nextTheme);
|
||||
applyDiagramSurface(graph, nextTheme, document.kind);
|
||||
applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current);
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
@@ -563,7 +709,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
graphToDocument(graph, document.kind, nextTheme),
|
||||
graphToDocument(graph, document.kind, themeRef.current),
|
||||
));
|
||||
});
|
||||
graphRef.current = graph;
|
||||
@@ -572,7 +718,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
graphRef.current = null;
|
||||
graph.dispose();
|
||||
};
|
||||
}, [beginNodeEdit, memo.contentHash, memo.id, readOnly]);
|
||||
}, [beginNodeEdit, dismissFlowQuickCreate, memo.contentHash, memo.id, readOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
@@ -641,9 +787,14 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
height: shape === "decision" ? 84 : 52,
|
||||
shape: isMindMap ? "topic" : shape,
|
||||
...(isMindMap && parent?.isNode() ? { parentId: parent.id } : {}),
|
||||
}, themeRef.current));
|
||||
}, themeRef.current, document.kind, appearanceRef.current));
|
||||
if (isMindMap && parent?.isNode()) {
|
||||
graph.addEdge(edgeMetadata({ id: createId("branch"), source: parent.id, target: id }, document.kind, themeRef.current));
|
||||
graph.addEdge(edgeMetadata(
|
||||
{ id: createId("branch"), source: parent.id, target: id },
|
||||
document.kind,
|
||||
themeRef.current,
|
||||
appearanceRef.current,
|
||||
));
|
||||
}
|
||||
if (isMindMap) {
|
||||
const positions = computeDiagramLayout(
|
||||
@@ -682,6 +833,57 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
node.attr("label/text", label);
|
||||
};
|
||||
|
||||
const createConnectedFlowNode = (shape: DiagramNodeShape) => {
|
||||
const graph = graphRef.current;
|
||||
const pending = flowQuickCreate;
|
||||
if (!graph || !document || document.kind !== "flowchart" || !pending || readOnly) return;
|
||||
if (!graph.getCellById(pending.sourceNodeId)?.isNode()) {
|
||||
dismissFlowQuickCreate();
|
||||
return;
|
||||
}
|
||||
const size = flowNodeSize(shape);
|
||||
const id = createId("node");
|
||||
const label = shape === "decision"
|
||||
? t("diagram.newDecision")
|
||||
: shape === "terminator"
|
||||
? t("diagram.newTerminator")
|
||||
: t("diagram.newStep");
|
||||
const draftEdge = graph.getCellById(pending.draftEdgeId);
|
||||
if (draftEdge?.isEdge()) removeFlowDraftEdge(graph, draftEdge, pending.restoreHistory);
|
||||
if (pending.restoreHistory) graph.enableHistory();
|
||||
flowQuickCreateRef.current = null;
|
||||
setFlowQuickCreate(null);
|
||||
graph.startBatch("quick-create");
|
||||
const node = graph.addNode(nodeMetadata({
|
||||
id,
|
||||
label,
|
||||
x: Math.round(Math.max(0, pending.x - size.width / 2)),
|
||||
y: Math.round(Math.max(0, pending.y - size.height / 2)),
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
shape,
|
||||
}, themeRef.current, document.kind, appearanceRef.current));
|
||||
graph.addEdge({
|
||||
...edgeMetadata(
|
||||
{ id: createId("edge"), source: pending.sourceNodeId, target: id },
|
||||
document.kind,
|
||||
themeRef.current,
|
||||
appearanceRef.current,
|
||||
),
|
||||
source: { cell: pending.sourceNodeId, ...(pending.sourcePort ? { port: pending.sourcePort } : {}) },
|
||||
target: { cell: id, ...(oppositeFlowPort(pending.sourcePort) ? { port: oppositeFlowPort(pending.sourcePort) } : {}) },
|
||||
});
|
||||
graph.stopBatch("quick-create");
|
||||
graph.cleanSelection();
|
||||
graph.select(node);
|
||||
setSelectedNodeId(id);
|
||||
setSelectedNodeLabel(label);
|
||||
setHasSelection(true);
|
||||
setDirty(true);
|
||||
setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() });
|
||||
requestAnimationFrame(() => beginNodeEdit(node));
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || readOnly) return;
|
||||
@@ -703,10 +905,12 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
if (!graph || readOnly) return;
|
||||
if (action === "undo") graph.undo();
|
||||
else graph.redo();
|
||||
const nextTheme = detectGraphTheme(graph) ?? themeRef.current;
|
||||
themeRef.current = nextTheme;
|
||||
setTheme(nextTheme);
|
||||
applyDiagramSurface(graph, nextTheme, document?.kind ?? "flowchart");
|
||||
applyGraphPalette(
|
||||
graph,
|
||||
themeRef.current,
|
||||
document?.kind ?? "flowchart",
|
||||
appearanceRef.current,
|
||||
);
|
||||
graph.cleanSelection();
|
||||
setSelectedNodeId(null);
|
||||
setSelectedNodeLabel("");
|
||||
@@ -715,7 +919,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
if (document) {
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
graphToDocument(graph, document.kind, nextTheme),
|
||||
graphToDocument(graph, document.kind, themeRef.current),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -752,33 +956,12 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
themeRef.current = nextTheme;
|
||||
setTheme(nextTheme);
|
||||
if (!graph || readOnly) return;
|
||||
graph.startBatch("update");
|
||||
for (const node of graph.getNodes()) {
|
||||
const data = node.getData<NodeData>();
|
||||
const shape = data?.shape ?? "process";
|
||||
const attrs = nodeAttrs(shape, nextTheme, shape === "topic" && !data?.parentId);
|
||||
node.attr("body", attrs.body);
|
||||
node.attr("label", { ...attrs.label, text: data?.label ?? "" });
|
||||
for (const port of node.getPorts()) {
|
||||
if (!port.id) continue;
|
||||
node.portProp(port.id, "attrs/circle", {
|
||||
stroke: DIAGRAM_THEMES[nextTheme].topicStroke,
|
||||
fill: DIAGRAM_THEMES[nextTheme].canvas,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const edge of graph.getEdges()) {
|
||||
edge.attr("line/stroke", document?.kind === "mind-map"
|
||||
? DIAGRAM_THEMES[nextTheme].mindMapEdge
|
||||
: DIAGRAM_THEMES[nextTheme].flowEdge);
|
||||
if (edge.getLabels().length > 0) {
|
||||
edge.attr("label/fill", DIAGRAM_THEMES[nextTheme].nodeText);
|
||||
edge.attr("body/fill", DIAGRAM_THEMES[nextTheme].canvas);
|
||||
edge.attr("body/stroke", DIAGRAM_THEMES[nextTheme].nodeStroke);
|
||||
}
|
||||
}
|
||||
applyDiagramSurface(graph, nextTheme, document?.kind ?? "flowchart");
|
||||
graph.stopBatch("update");
|
||||
applyGraphPalette(
|
||||
graph,
|
||||
nextTheme,
|
||||
document?.kind ?? "flowchart",
|
||||
appearanceRef.current,
|
||||
);
|
||||
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
|
||||
titleRef.current,
|
||||
graphToDocument(graph, document?.kind ?? "flowchart", nextTheme),
|
||||
@@ -792,7 +975,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
const fileName = (title.trim() || fallbackName).replace(/[\\/:*?"<>|]/g, "-").slice(0, 80);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const palette = DIAGRAM_THEMES[themeRef.current];
|
||||
const palette = resolveDiagramPalette(themeRef.current, appearanceRef.current);
|
||||
const beforeSerialize = prepareExportSvg(palette.canvas);
|
||||
if (format === "png") {
|
||||
graph.exportPNG(fileName, { backgroundColor: palette.canvas, padding: 32, ratio: 2, copyStyles: false, beforeSerialize });
|
||||
@@ -875,6 +1058,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
}}
|
||||
/>
|
||||
<span className={cn("hidden text-xs sm:block", saveError ? "text-rose-600" : dirty ? "text-amber-600" : "text-slate-400")}>{saveError ?? (dirty ? t("diagram.unsaved") : t("diagram.saved"))}</span>
|
||||
<ThemeToggle />
|
||||
{!readOnly && (
|
||||
<Button variant="solid" size="sm" disabled={!dirty || saving || !editSessionReady} onClick={() => void save()}>
|
||||
<Save className="h-4 w-4" />
|
||||
@@ -898,6 +1082,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
<Button size="sm" variant="soft" onClick={() => addNode("process")}><Box className="h-4 w-4" />{t("diagram.addStep")}</Button>
|
||||
<Tooltip><TooltipTrigger asChild><Button size="sm" variant="ghost" aria-label={t("diagram.addDecision")} onClick={() => addNode("decision")}><Diamond className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.addDecision")}</TooltipContent></Tooltip>
|
||||
<Tooltip><TooltipTrigger asChild><Button size="sm" variant="ghost" aria-label={t("diagram.addTerminator")} onClick={() => addNode("terminator")}><Circle className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.addTerminator")}</TooltipContent></Tooltip>
|
||||
<span className="hidden items-center gap-1.5 px-2 text-xs text-slate-500 xl:flex"><Link2 className="h-3.5 w-3.5" />{t("diagram.connectHint")}</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
@@ -915,9 +1100,9 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brand" textValue={t("diagram.themeBrand")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: DIAGRAM_THEMES.brand.topicFill }} />{t("diagram.themeBrand")}</span></SelectItem>
|
||||
<SelectItem value="ocean" textValue={t("diagram.themeOcean")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: DIAGRAM_THEMES.ocean.topicFill }} />{t("diagram.themeOcean")}</span></SelectItem>
|
||||
<SelectItem value="ink" textValue={t("diagram.themeInk")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: DIAGRAM_THEMES.ink.nodeFill }} />{t("diagram.themeInk")}</span></SelectItem>
|
||||
<SelectItem value="brand" textValue={t("diagram.themeBrand")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("brand", resolvedTheme).topicFill }} />{t("diagram.themeBrand")}</span></SelectItem>
|
||||
<SelectItem value="ocean" textValue={t("diagram.themeOcean")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ocean", resolvedTheme).topicFill }} />{t("diagram.themeOcean")}</span></SelectItem>
|
||||
<SelectItem value="ink" textValue={t("diagram.themeInk")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ink", resolvedTheme).nodeFill }} />{t("diagram.themeInk")}</span></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
@@ -937,7 +1122,37 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={containerRef} className="edgeever-diagram-canvas absolute inset-0 touch-none outline-none" data-diagram-theme={theme} tabIndex={0} aria-label={t("diagram.canvas", { type: kindLabel })} />
|
||||
<div ref={containerRef} className="edgeever-diagram-canvas absolute inset-0 touch-none outline-none" data-diagram-appearance={resolvedTheme} data-diagram-kind={document.kind} data-diagram-theme={theme} tabIndex={0} aria-label={t("diagram.canvas", { type: kindLabel })} />
|
||||
{flowQuickCreate ? (
|
||||
<div
|
||||
className="absolute z-30 w-[246px] rounded-xl border border-slate-200 bg-white p-2 shadow-xl"
|
||||
style={{ left: flowQuickCreate.left, top: flowQuickCreate.top }}
|
||||
role="dialog"
|
||||
aria-label={t("diagram.quickCreateTitle")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
dismissFlowQuickCreate();
|
||||
containerRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<div className="px-1.5 pb-1.5 text-xs font-medium text-slate-500">{t("diagram.quickCreateTitle")}</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Button autoFocus className="h-16 flex-col gap-1 text-xs" variant="ghost" aria-label={t("diagram.addStep")} onClick={() => createConnectedFlowNode("process")}>
|
||||
<Box className="h-6 w-6" />
|
||||
{t("diagram.addStep")}
|
||||
</Button>
|
||||
<Button className="h-16 flex-col gap-1 text-xs" variant="ghost" aria-label={t("diagram.addDecision")} onClick={() => createConnectedFlowNode("decision")}>
|
||||
<Diamond className="h-6 w-6" />
|
||||
{t("diagram.addDecision")}
|
||||
</Button>
|
||||
<Button className="h-16 flex-col gap-1 text-xs" variant="ghost" aria-label={t("diagram.addTerminator")} onClick={() => createConnectedFlowNode("terminator")}>
|
||||
<Circle className="h-6 w-6" />
|
||||
{t("diagram.addTerminator")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{nodeEditor ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { DIAGRAM_THEME_PALETTES, resolveDiagramPalette } from "./diagram-theme.ts";
|
||||
|
||||
const channel = (value) => {
|
||||
const normalized = value / 255;
|
||||
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
const luminance = (hex) => {
|
||||
const channels = hex.match(/[a-f\d]{2}/gi).map((value) => channel(Number.parseInt(value, 16)));
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
|
||||
const contrast = (foreground, background) => {
|
||||
const values = [luminance(foreground), luminance(background)].sort((left, right) => right - left);
|
||||
return (values[0] + 0.05) / (values[1] + 0.05);
|
||||
};
|
||||
|
||||
describe("diagram appearance palettes", () => {
|
||||
test("provides distinct night-mode surfaces for every diagram theme", () => {
|
||||
for (const theme of Object.keys(DIAGRAM_THEME_PALETTES)) {
|
||||
const light = resolveDiagramPalette(theme, "light");
|
||||
const dark = resolveDiagramPalette(theme, "dark");
|
||||
expect(dark.canvas).not.toBe(light.canvas);
|
||||
expect(luminance(dark.canvas)).toBeLessThan(0.02);
|
||||
expect(contrast(dark.nodeText, dark.nodeFill)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(dark.topicText, dark.topicFill)).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { DiagramTheme } from "@edgeever/shared";
|
||||
|
||||
export type DiagramAppearance = "light" | "dark";
|
||||
|
||||
export type DiagramPalette = {
|
||||
topicFill: string;
|
||||
topicText: string;
|
||||
nodeFill: string;
|
||||
nodeText: string;
|
||||
nodeStroke: string;
|
||||
topicStroke: string;
|
||||
mindMapEdge: string;
|
||||
flowEdge: string;
|
||||
canvas: string;
|
||||
grid: string;
|
||||
gridStrong: string;
|
||||
};
|
||||
|
||||
const BRAND_GREEN = "#16A06E";
|
||||
|
||||
export const DIAGRAM_THEME_PALETTES: Record<DiagramTheme, Record<DiagramAppearance, DiagramPalette>> = {
|
||||
brand: {
|
||||
light: {
|
||||
topicFill: BRAND_GREEN,
|
||||
topicText: "#FFFFFF",
|
||||
nodeFill: "#F0F8F4",
|
||||
nodeText: "#173B2E",
|
||||
nodeStroke: "#B8DFD0",
|
||||
topicStroke: "#12845B",
|
||||
mindMapEdge: "#55B891",
|
||||
flowEdge: "#408A6D",
|
||||
canvas: "#F8FAF9",
|
||||
grid: "#E6EEE9",
|
||||
gridStrong: "#CFDDD5",
|
||||
},
|
||||
dark: {
|
||||
topicFill: BRAND_GREEN,
|
||||
topicText: "#F4FFF9",
|
||||
nodeFill: "#18211D",
|
||||
nodeText: "#E8F2ED",
|
||||
nodeStroke: "#3B5248",
|
||||
topicStroke: "#58CDA4",
|
||||
mindMapEdge: "#4DB58B",
|
||||
flowEdge: "#72B99B",
|
||||
canvas: "#101311",
|
||||
grid: "#1D2722",
|
||||
gridStrong: "#2B3A33",
|
||||
},
|
||||
},
|
||||
ocean: {
|
||||
light: {
|
||||
topicFill: "#DEF1E9",
|
||||
topicText: "#0F4432",
|
||||
nodeFill: "#FFFEFA",
|
||||
nodeText: "#26352E",
|
||||
nodeStroke: "#D5E3DB",
|
||||
topicStroke: BRAND_GREEN,
|
||||
mindMapEdge: "#8ACCB2",
|
||||
flowEdge: "#6B9281",
|
||||
canvas: "#FBFCFA",
|
||||
grid: "#EBF0EC",
|
||||
gridStrong: "#D9E3DC",
|
||||
},
|
||||
dark: {
|
||||
topicFill: "#244D3D",
|
||||
topicText: "#DFF7ED",
|
||||
nodeFill: "#17201C",
|
||||
nodeText: "#E6F0EB",
|
||||
nodeStroke: "#3B554A",
|
||||
topicStroke: "#65C9A4",
|
||||
mindMapEdge: "#55B992",
|
||||
flowEdge: "#77AD97",
|
||||
canvas: "#111713",
|
||||
grid: "#202A25",
|
||||
gridStrong: "#304039",
|
||||
},
|
||||
},
|
||||
ink: {
|
||||
light: {
|
||||
topicFill: BRAND_GREEN,
|
||||
topicText: "#F5FBF8",
|
||||
nodeFill: "#19261F",
|
||||
nodeText: "#E5EEE9",
|
||||
nodeStroke: "#3C594A",
|
||||
topicStroke: "#68D6B0",
|
||||
mindMapEdge: "#58BA94",
|
||||
flowEdge: "#68A78D",
|
||||
canvas: "#101512",
|
||||
grid: "#202B25",
|
||||
gridStrong: "#304038",
|
||||
},
|
||||
dark: {
|
||||
topicFill: BRAND_GREEN,
|
||||
topicText: "#F5FBF8",
|
||||
nodeFill: "#151D19",
|
||||
nodeText: "#E7F0EB",
|
||||
nodeStroke: "#3C594A",
|
||||
topicStroke: "#68D6B0",
|
||||
mindMapEdge: "#58BA94",
|
||||
flowEdge: "#72B296",
|
||||
canvas: "#0B0F0D",
|
||||
grid: "#19211D",
|
||||
gridStrong: "#29362F",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const resolveDiagramPalette = (theme: DiagramTheme, appearance: DiagramAppearance) =>
|
||||
DIAGRAM_THEME_PALETTES[theme][appearance];
|
||||
@@ -499,16 +499,21 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
box-shadow: 0 2px 10px rgb(104 214 176 / 0.22) !important;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.edgeever-diagram-canvas .x6-port-body {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
.edgeever-diagram-canvas .x6-port-body {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas .x6-node:hover .x6-port-body,
|
||||
.edgeever-diagram-canvas .x6-port-body:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-port-body {
|
||||
cursor: crosshair;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-node:hover .x6-port-body,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-port-body:hover,
|
||||
.edgeever-diagram-canvas[data-diagram-kind="flowchart"] .x6-available-magnet {
|
||||
filter: drop-shadow(0 1px 3px rgb(var(--brand-green-rgb) / 0.28));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Curated reading themes should feel like part of the note canvas in dark
|
||||
|
||||
@@ -252,8 +252,12 @@ export const enUS = {
|
||||
addStep: "Add step",
|
||||
addDecision: "Add decision",
|
||||
addTerminator: "Add start or end",
|
||||
connectHint: "Drag a handle to connect, or drop on canvas to create the next node",
|
||||
newTopic: "New topic",
|
||||
newStep: "New step",
|
||||
newDecision: "Decision",
|
||||
newTerminator: "Start / end",
|
||||
quickCreateTitle: "Choose the next node type",
|
||||
nodeText: "Node text",
|
||||
editNode: "Edit node text",
|
||||
deleteSelection: "Delete selection",
|
||||
|
||||
@@ -252,8 +252,12 @@ export const zhCN = {
|
||||
addStep: "添加步骤",
|
||||
addDecision: "添加判断节点",
|
||||
addTerminator: "添加起止节点",
|
||||
connectHint: "拖动端点连线;松开在空白处可创建后续节点",
|
||||
newTopic: "新主题",
|
||||
newStep: "新步骤",
|
||||
newDecision: "判断条件",
|
||||
newTerminator: "开始/结束",
|
||||
quickCreateTitle: "选择后续节点类型",
|
||||
nodeText: "节点文字",
|
||||
editNode: "编辑节点文字",
|
||||
deleteSelection: "删除所选内容",
|
||||
|
||||
Reference in new issue
Block a user