diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 3f7156d1..413d9acb 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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(""); + }); }); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 7f1e7b31..ecae6c1a 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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 = { - 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(); 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()?.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(); + 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(null); const graphRef = useRef(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(documentTheme); const titleRef = useRef(title); const themeRef = useRef(documentTheme); + const appearanceRef = useRef(resolvedTheme); const savedSnapshotRef = useRef(document ? diagramEditorSnapshot(memo.title ?? "", document) : ""); const [selectedNodeId, setSelectedNodeId] = useState(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(null); + const [flowQuickCreate, setFlowQuickCreate] = useState(null); + const flowQuickCreateRef = useRef(null); const nodeEditorRef = useRef(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(); + 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(); - 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 }} /> {saveError ?? (dirty ? t("diagram.unsaved") : t("diagram.saved"))} + {!readOnly && ( {t("diagram.addDecision")} {t("diagram.addTerminator")} + {t("diagram.connectHint")} ) )} @@ -915,9 +1100,9 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on - {t("diagram.themeBrand")} - {t("diagram.themeOcean")} - {t("diagram.themeInk")} + {t("diagram.themeBrand")} + {t("diagram.themeOcean")} + {t("diagram.themeInk")} @@ -937,7 +1122,37 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on )}
-
+
+ {flowQuickCreate ? ( +
{ + if (event.key !== "Escape") return; + event.preventDefault(); + dismissFlowQuickCreate(); + containerRef.current?.focus({ preventScroll: true }); + }} + > +
{t("diagram.quickCreateTitle")}
+
+ + + +
+
+ ) : null} {nodeEditor ? ( { + 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); + } + }); +}); diff --git a/apps/web/src/lib/diagram-theme.ts b/apps/web/src/lib/diagram-theme.ts new file mode 100644 index 00000000..3e9b3e02 --- /dev/null +++ b/apps/web/src/lib/diagram-theme.ts @@ -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> = { + 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]; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 22036c33..e459c26b 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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 diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 3862e1e8..a0ab9c59 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index b23eff6a..568d8336 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -252,8 +252,12 @@ export const zhCN = { addStep: "添加步骤", addDecision: "添加判断节点", addTerminator: "添加起止节点", + connectHint: "拖动端点连线;松开在空白处可创建后续节点", newTopic: "新主题", newStep: "新步骤", + newDecision: "判断条件", + newTerminator: "开始/结束", + quickCreateTitle: "选择后续节点类型", nodeText: "节点文字", editNode: "编辑节点文字", deleteSelection: "删除所选内容",