diff --git a/src/App.tsx b/src/App.tsx index 8847e5e..180b584 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import type { DiagramDocument } from './types'; const SAMPLE_DOCUMENT: DiagramDocument = { nodes: [ - { id: 'start', type: 'event', position: { x: 0, y: 80 }, data: { label: 'Заявка получена' } }, + { id: 'start', type: 'startEvent', position: { x: 0, y: 80 }, data: { label: 'Заявка получена' } }, { id: 'review', type: 'task', @@ -17,7 +17,9 @@ const SAMPLE_DOCUMENT: DiagramDocument = { duration: '1-2 дня', }, }, - { id: 'decision', type: 'gateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } }, + { id: 'decision', type: 'exclusiveGateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } }, + { id: 'end-approved', type: 'endEvent', position: { x: 640, y: 40 }, data: { label: 'Заявка одобрена' } }, + { id: 'end-rejected', type: 'endEvent', position: { x: 640, y: 140 }, data: { label: 'Заявка отклонена' } }, { id: 'review-check-docs', type: 'task', @@ -34,9 +36,29 @@ const SAMPLE_DOCUMENT: DiagramDocument = { }, ], edges: [ - { id: 'start-review', source: 'start', target: 'review' }, - { id: 'review-decision', source: 'review', target: 'decision' }, - { id: 'review-check-docs-assign', source: 'review-check-docs', target: 'review-assign' }, + { id: 'start-review', source: 'start', target: 'review', sourceHandle: 'right', targetHandle: 'left' }, + { id: 'review-decision', source: 'review', target: 'decision', sourceHandle: 'right', targetHandle: 'left' }, + { + id: 'decision-approved', + source: 'decision', + target: 'end-approved', + sourceHandle: 'right', + targetHandle: 'left', + }, + { + id: 'decision-rejected', + source: 'decision', + target: 'end-rejected', + sourceHandle: 'right', + targetHandle: 'left', + }, + { + id: 'review-check-docs-assign', + source: 'review-check-docs', + target: 'review-assign', + sourceHandle: 'right', + targetHandle: 'left', + }, ], }; diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index 689df0c..336822c 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -1,11 +1,16 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ReactFlow, ReactFlowProvider, Background, Controls, + ControlButton, MiniMap, Panel, + ConnectionMode, + MarkerType, + ViewportPortal, + useReactFlow, addEdge, applyNodeChanges, applyEdgeChanges, @@ -15,10 +20,12 @@ import { type NodeChange, type EdgeChange, type NodeMouseHandler, + type EdgeMouseHandler, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { nodeTypes, NodesWithChildrenContext } from './nodes'; +import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes'; import { NodeInspector } from './NodeInspector'; +import { EdgeInspector } from './EdgeInspector'; import { debounce, serializeDocument } from './serialize'; import type { DiagramDocument, DiagramNodeData, NodeKind } from './types'; import './diagram.css'; @@ -28,20 +35,106 @@ interface DiagramEditorProps { onChange: (doc: DiagramDocument) => void; } +const DEFAULT_EDGE_TYPE = 'smoothstep'; +const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed }; +const MAX_HISTORY = 50; +const TYPING_HISTORY_THROTTLE_MS = 1000; + const NODE_KINDS: { kind: NodeKind; label: string }[] = [ { kind: 'task', label: '+ Задача' }, - { kind: 'gateway', label: '+ Шлюз' }, - { kind: 'event', label: '+ Событие' }, + { kind: 'startEvent', label: '+ Начало' }, + { kind: 'intermediateEvent', label: '+ Промеж. событие' }, + { kind: 'endEvent', label: '+ Конец' }, + { kind: 'exclusiveGateway', label: '+ Искл. шлюз' }, + { kind: 'parallelGateway', label: '+ Паралл. шлюз' }, + { kind: 'inclusiveGateway', label: '+ Вкл. шлюз' }, ]; +const DEFAULT_NODE_SIZE: Record = { + task: { width: 140, height: 50 }, + startEvent: { width: 48, height: 48 }, + intermediateEvent: { width: 48, height: 48 }, + endEvent: { width: 48, height: 48 }, + exclusiveGateway: { width: 64, height: 64 }, + parallelGateway: { width: 64, height: 64 }, + inclusiveGateway: { width: 64, height: 64 }, +}; + +interface AlignmentGuide { + axis: 'x' | 'y'; + position: number; +} + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +const SNAP_THRESHOLD = 6; + +/** + * Compares the dragged box's edges/center against every other box on the same + * level and, for the first close-enough match on each axis, snaps to it and + * records a guide line to draw across the canvas. + */ +function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } { + const guides: AlignmentGuide[] = []; + let snappedX = dragged.x; + let snappedY = dragged.y; + let matchedX = false; + let matchedY = false; + + for (const other of others) { + if (!matchedX) { + const xCandidates: [number, number][] = [ + [dragged.x, other.x], + [dragged.x + dragged.width, other.x + other.width], + [dragged.x + dragged.width / 2, other.x + other.width / 2], + ]; + for (const [dValue, oValue] of xCandidates) { + if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) { + snappedX = dragged.x + (oValue - dValue); + guides.push({ axis: 'x', position: oValue }); + matchedX = true; + break; + } + } + } + if (!matchedY) { + const yCandidates: [number, number][] = [ + [dragged.y, other.y], + [dragged.y + dragged.height, other.y + other.height], + [dragged.y + dragged.height / 2, other.y + other.height / 2], + ]; + for (const [dValue, oValue] of yCandidates) { + if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) { + snappedY = dragged.y + (oValue - dValue); + guides.push({ axis: 'y', position: oValue }); + matchedY = true; + break; + } + } + } + } + + return { x: snappedX, y: snappedY, guides }; +} + function toRfNodes(doc: DiagramDocument | null): Node[] { - return (doc?.nodes ?? []).map((node) => ({ - id: node.id, - type: node.type, - position: node.position, - data: node.data, - ...(node.parentId ? { parentId: node.parentId } : {}), - })); + return (doc?.nodes ?? []).map((node) => { + const defaults = DEFAULT_NODE_SIZE[node.type]; + return { + id: node.id, + type: node.type, + position: node.position, + data: node.data, + width: node.width ?? defaults.width, + height: node.height ?? defaults.height, + ...(node.parentId ? { parentId: node.parentId } : {}), + }; + }); } function toRfEdges(doc: DiagramDocument | null): Edge[] { @@ -49,24 +142,80 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] { id: edge.id, source: edge.source, target: edge.target, - type: edge.type, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type ?? DEFAULT_EDGE_TYPE, + markerEnd: DEFAULT_MARKER_END, + label: edge.label, data: edge.data, })); } +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; +} + function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const [nodes, setNodes] = useState(() => toRfNodes(initialValue)); const [edges, setEdges] = useState(() => toRfEdges(initialValue)); const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [focusNodeId, setFocusNodeId] = useState(null); const [mode, setMode] = useState<'edit' | 'view'>('edit'); + const [alignmentGuides, setAlignmentGuides] = useState([]); const nodeCounter = useRef(0); + const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); + const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); + const lastTypingHistoryPushRef = useRef(0); + const clipboardRef = useRef(null); + const canvasWrapperRef = useRef(null); + const { screenToFlowPosition } = useReactFlow(); const emitChange = useMemo( () => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400), [onChange], ); + const pushHistory = useCallback((prevNodes: Node[], prevEdges: Edge[]) => { + historyRef.current.push({ nodes: prevNodes, edges: prevEdges }); + if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift(); + redoRef.current = []; + }, []); + + const pushHistoryThrottled = useCallback( + (prevNodes: Node[], prevEdges: Edge[]) => { + const now = Date.now(); + if (now - lastTypingHistoryPushRef.current > TYPING_HISTORY_THROTTLE_MS) { + pushHistory(prevNodes, prevEdges); + } + lastTypingHistoryPushRef.current = now; + }, + [pushHistory], + ); + + const undo = useCallback(() => { + const previous = historyRef.current.pop(); + if (!previous) return; + redoRef.current.push({ nodes, edges }); + setNodes(previous.nodes); + setEdges(previous.edges); + emitChange(previous.nodes, previous.edges); + setSelectedNodeId(null); + setSelectedEdgeId(null); + }, [nodes, edges, emitChange]); + + const redo = useCallback(() => { + const next = redoRef.current.pop(); + if (!next) return; + historyRef.current.push({ nodes, edges }); + setNodes(next.nodes); + setEdges(next.edges); + emitChange(next.nodes, next.edges); + setSelectedNodeId(null); + setSelectedEdgeId(null); + }, [nodes, edges, emitChange]); + const nodesWithChildren = useMemo(() => { const set = new Set(); for (const node of nodes) { @@ -102,112 +251,248 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const enterSubProcess = useCallback((nodeId: string) => { setFocusNodeId(nodeId); setSelectedNodeId(null); + setSelectedEdgeId(null); }, []); const goToLevel = useCallback((nodeId: string | null) => { setFocusNodeId(nodeId); setSelectedNodeId(null); + setSelectedEdgeId(null); }, []); const onNodesChange = useCallback( (changes: NodeChange[]) => { - setNodes((current) => { - const withChildren = new Set(current.filter((n) => n.parentId).map((n) => n.parentId!)); - const filteredChanges = changes.filter((change) => { - if (change.type !== 'remove' || !withChildren.has(change.id)) return true; - window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); - return false; - }); - const next = applyNodeChanges(filteredChanges, current); - emitChange(next, edges); - return next; + const withChildren = new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!)); + const filteredChanges = changes.filter((change) => { + if (change.type !== 'remove' || !withChildren.has(change.id)) return true; + window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); + return false; }); + + const positionChanges = filteredChanges.filter((c) => c.type === 'position' && c.position); + let adjustedChanges = filteredChanges; + if (positionChanges.length === 1) { + const change = positionChanges[0]; + const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined; + if (change.type === 'position' && change.position && draggedNode) { + const level = draggedNode.parentId ?? null; + const others: Box[] = nodes + .filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level) + .map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 })); + const dragged: Box = { + x: change.position.x, + y: change.position.y, + width: draggedNode.width ?? 0, + height: draggedNode.height ?? 0, + }; + const snapped = computeSnapAndGuides(dragged, others); + setAlignmentGuides(change.dragging === false ? [] : snapped.guides); + adjustedChanges = filteredChanges.map((c) => + c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c, + ); + } + } else if (alignmentGuides.length > 0) { + setAlignmentGuides([]); + } + + const isHistoryWorthy = filteredChanges.some( + (change) => + change.type === 'remove' || + (change.type === 'position' && change.dragging === false) || + (change.type === 'dimensions' && change.resizing === false), + ); + if (isHistoryWorthy) pushHistory(nodes, edges); + const next = applyNodeChanges(adjustedChanges, nodes); + setNodes(next); + emitChange(next, edges); }, - [edges, emitChange], + [nodes, edges, emitChange, pushHistory, alignmentGuides], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => { - setEdges((current) => { - const next = applyEdgeChanges(changes, current); - emitChange(nodes, next); - return next; - }); + const isHistoryWorthy = changes.some((change) => change.type === 'remove'); + if (isHistoryWorthy) pushHistory(nodes, edges); + const next = applyEdgeChanges(changes, edges); + setEdges(next); + emitChange(nodes, next); }, - [nodes, emitChange], + [nodes, edges, emitChange, pushHistory], ); const onConnect: OnConnect = useCallback( (connection) => { - setEdges((current) => { - const next = addEdge(connection, current); - emitChange(nodes, next); - return next; - }); + pushHistory(nodes, edges); + const next = addEdge({ ...connection, type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, edges); + setEdges(next); + emitChange(nodes, next); }, - [nodes, emitChange], + [nodes, edges, emitChange, pushHistory], ); const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { setSelectedNodeId(node.id); + setSelectedEdgeId(null); }, []); - const onNodeDoubleClick: NodeMouseHandler = useCallback( - (_event, node) => { - if (mode === 'view' && !nodesWithChildren.has(node.id)) return; - enterSubProcess(node.id); + const onEdgeClick: EdgeMouseHandler = useCallback( + (_event, edge) => { + if (mode !== 'edit') return; + setSelectedEdgeId(edge.id); + setSelectedNodeId(null); }, - [enterSubProcess, mode, nodesWithChildren], + [mode], ); const onPaneClick = useCallback(() => { setSelectedNodeId(null); + setSelectedEdgeId(null); }, []); const updateSelectedNodeData = useCallback( (patch: Partial) => { if (!selectedNodeId) return; - setNodes((current) => { - const next = current.map((node) => - node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node, - ); - emitChange(next, edges); - return next; - }); + pushHistoryThrottled(nodes, edges); + const next = nodes.map((node) => + node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node, + ); + setNodes(next); + emitChange(next, edges); }, - [selectedNodeId, edges, emitChange], + [selectedNodeId, nodes, edges, emitChange, pushHistoryThrottled], ); + const updateSelectedEdgeLabel = useCallback( + (label: string) => { + if (!selectedEdgeId) return; + pushHistoryThrottled(nodes, edges); + const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge)); + setEdges(next); + emitChange(nodes, next); + }, + [selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled], + ); + + const getViewportCenter = useCallback(() => { + const rect = canvasWrapperRef.current?.getBoundingClientRect(); + if (!rect) return { x: 0, y: 0 }; + return screenToFlowPosition({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + }, [screenToFlowPosition]); + const addNode = useCallback( (kind: NodeKind) => { nodeCounter.current += 1; const id = `${kind}-${nodeCounter.current}`; - const offset = nodeCounter.current; + const center = getViewportCenter(); + const fanOut = nodeCounter.current; + const size = DEFAULT_NODE_SIZE[kind]; const newNode: Node = { id, type: kind, - position: { x: 40 + ((offset * 40) % 400), y: 320 + ((offset * 70) % 200) }, + position: { + x: center.x + ((fanOut * 24) % 120) - 60, + y: center.y + ((fanOut * 18) % 90) - 45, + }, + width: size.width, + height: size.height, data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData, ...(focusNodeId ? { parentId: focusNodeId } : {}), }; - setNodes((current) => { - const next = [...current, newNode]; - emitChange(next, edges); - return next; - }); + pushHistory(nodes, edges); + const next = [...nodes, newNode]; + setNodes(next); + emitChange(next, edges); }, - [edges, emitChange, focusNodeId], + [nodes, edges, emitChange, focusNodeId, pushHistory, getViewportCenter], ); + const copySelectedNode = useCallback(() => { + const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined; + if (!node) return; + clipboardRef.current = node; + }, [selectedNodeId, nodes]); + + const pasteClipboardNode = useCallback(() => { + const source = clipboardRef.current; + if (!source) return; + nodeCounter.current += 1; + const newId = `${source.type}-${nodeCounter.current}`; + const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source; + const newNode: Node = { + ...restSource, + id: newId, + position: { x: source.position.x + 40, y: source.position.y + 40 }, + data: { ...(source.data as DiagramNodeData) }, + ...(focusNodeId ? { parentId: focusNodeId } : {}), + }; + pushHistory(nodes, edges); + const next = [...nodes, newNode]; + setNodes(next); + emitChange(next, edges); + setSelectedNodeId(newId); + setSelectedEdgeId(null); + }, [nodes, edges, emitChange, focusNodeId, pushHistory]); + + const duplicateSelectedNode = useCallback(() => { + copySelectedNode(); + pasteClipboardNode(); + }, [copySelectedNode, pasteClipboardNode]); + + const deleteSelectedNode = useCallback(() => { + if (!selectedNodeId) return; + if (nodes.some((n) => n.parentId === selectedNodeId)) { + window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); + return; + } + pushHistory(nodes, edges); + const nextNodes = nodes.filter((n) => n.id !== selectedNodeId); + const nextEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId); + setNodes(nextNodes); + setEdges(nextEdges); + emitChange(nextNodes, nextEdges); + setSelectedNodeId(null); + }, [selectedNodeId, nodes, edges, emitChange, pushHistory]); + + const deleteSelectedEdge = useCallback(() => { + if (!selectedEdgeId) return; + pushHistory(nodes, edges); + const nextEdges = edges.filter((e) => e.id !== selectedEdgeId); + setEdges(nextEdges); + emitChange(nodes, nextEdges); + setSelectedEdgeId(null); + }, [selectedEdgeId, nodes, edges, emitChange, pushHistory]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (mode !== 'edit' || isEditableTarget(event.target)) return; + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === 'z') { + event.preventDefault(); + undo(); + } else if (key === 'c') { + event.preventDefault(); + copySelectedNode(); + } else if (key === 'v') { + event.preventDefault(); + pasteClipboardNode(); + } + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [mode, undo, copySelectedNode, pasteClipboardNode]); + const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined; const selectedNodeChildCount = selectedNode ? nodes.filter((node) => node.parentId === selectedNode.id).length : 0; + const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined; return (
-
+
+ - + + {alignmentGuides.map((guide, index) => ( +
+ ))} + + + {mode === 'edit' && ( + <> + + ↶ + + + ↷ + + + )} +
@@ -275,11 +587,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { mode={mode} onChange={updateSelectedNodeData} onEnterSubProcess={enterSubProcess} + onDuplicate={duplicateSelectedNode} + onDelete={deleteSelectedNode} + /> + ) : selectedEdge ? ( + ) : (

{mode === 'edit' - ? 'Выберите узел на схеме, чтобы отредактировать его данные. Двойной клик по узлу — зайти внутрь как в под-процесс.' + ? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.' : 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}

)} diff --git a/src/EdgeInspector.tsx b/src/EdgeInspector.tsx new file mode 100644 index 0000000..0f81ca4 --- /dev/null +++ b/src/EdgeInspector.tsx @@ -0,0 +1,28 @@ +interface EdgeInspectorProps { + edgeId: string; + label: string; + onChange: (label: string) => void; + onDelete: () => void; +} + +export function EdgeInspector({ edgeId, label, onChange, onDelete }: EdgeInspectorProps) { + return ( +
+
+ Связь + {edgeId} +
+ +
+ +
+ + +
+ ); +} diff --git a/src/NodeInspector.tsx b/src/NodeInspector.tsx index 5c56187..6a1341d 100644 --- a/src/NodeInspector.tsx +++ b/src/NodeInspector.tsx @@ -1,9 +1,14 @@ +import { DEFAULT_NODE_COLOR } from './nodes'; import type { DiagramNodeData, NodeKind } from './types'; const TYPE_LABELS: Record = { task: 'Задача', - gateway: 'Шлюз', - event: 'Событие', + startEvent: 'Начальное событие', + intermediateEvent: 'Промежуточное событие', + endEvent: 'Конечное событие', + exclusiveGateway: 'Исключающий шлюз', + parallelGateway: 'Параллельный шлюз', + inclusiveGateway: 'Включающий шлюз', }; interface NodeInspectorProps { @@ -14,6 +19,8 @@ interface NodeInspectorProps { mode: 'edit' | 'view'; onChange: (patch: Partial) => void; onEnterSubProcess: (nodeId: string) => void; + onDuplicate: () => void; + onDelete: () => void; } export function NodeInspector({ @@ -24,6 +31,8 @@ export function NodeInspector({ mode, onChange, onEnterSubProcess, + onDuplicate, + onDelete, }: NodeInspectorProps) { if (mode === 'view') { const hasMeta = Boolean(data.responsible || data.duration); @@ -70,10 +79,28 @@ export function NodeInspector({ {nodeId}
+
+ + +
+ + +
diff --git a/src/diagram.css b/src/diagram.css index 2d874ec..be786b1 100644 --- a/src/diagram.css +++ b/src/diagram.css @@ -11,7 +11,9 @@ .diagram-toolbar { display: flex; + flex-wrap: wrap; gap: 8px; + max-width: 280px; } .diagram-toolbar button { @@ -28,6 +30,26 @@ background: color-mix(in srgb, canvastext 8%, canvas); } +.diagram-flow--view-mode .react-flow__handle { + display: none; +} + +/* + * React Flow's built-in disabled styling only dims children (for the + * zoom/fit/lock icon buttons) — our undo/redo controls use plain text glyphs, + * so they need their own dimming or they'd look clickable while inert. + */ +.react-flow__controls-button:disabled { + opacity: 0.4; +} + +.diagram-alignment-guide { + position: absolute; + background: #ff0072; + pointer-events: none; + z-index: 1000; +} + .diagram-mode-toggle { display: flex; border: 1px solid #999; @@ -80,14 +102,24 @@ .diagram-node { position: relative; - min-width: 120px; - padding: 10px 16px; + width: 100%; + height: 100%; + padding: 6px 10px; border: 1px solid #555; border-radius: 6px; background: canvas; color: canvastext; font-size: 12px; text-align: center; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +.diagram-node__label { + white-space: pre-wrap; + overflow-wrap: break-word; } .diagram-node__badge { @@ -104,35 +136,74 @@ display: flex; align-items: center; justify-content: center; + z-index: 1; } -.diagram-node--gateway { - width: 64px; - height: 64px; - min-width: 0; +.diagram-node__caption { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 4px; + max-width: 120px; + font-size: 11px; + text-align: center; + color: canvastext; + white-space: pre-wrap; +} + +.diagram-node--circle { padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transform: rotate(45deg); + border-radius: 50%; } -.diagram-node--gateway span { +.diagram-node--startEvent { + border-width: 1px; +} + +.diagram-node--endEvent { + border-width: 3px; +} + +.diagram-node--intermediateEvent { + border-width: 1px; +} + +.diagram-node--intermediateEvent::after { + content: ''; + position: absolute; + inset: 3px; + border: 1px solid #555; + border-radius: 50%; +} + +.diagram-node--gateway-outer { + position: relative; + width: 100%; + height: 100%; +} + +/* + * Inset to 1/sqrt(2) of the outer box and rotated 45deg: the diamond's tips + * exactly reach the outer box's edges without the corners clipping outside it. + */ +.diagram-node--diamond { + position: absolute; + top: 50%; + left: 50%; + width: 70.71%; + height: 70.71%; + padding: 0; + border-radius: 4px; + transform: translate(-50%, -50%) rotate(45deg); +} + +.diagram-node__icon { display: inline-block; transform: rotate(-45deg); - font-size: 11px; -} - -.diagram-node--event { - width: 64px; - height: 64px; - min-width: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; + font-size: 18px; + font-weight: 700; + line-height: 1; } .diagram-aside { @@ -177,6 +248,25 @@ justify-content: space-between; } +.node-inspector__toolbar { + display: flex; + gap: 8px; +} + +.node-inspector__toolbar button { + font: inherit; + padding: 6px 10px; + border: 1px solid #999; + border-radius: 4px; + background: canvas; + color: canvastext; + cursor: pointer; +} + +.node-inspector__toolbar button:hover { + background: color-mix(in srgb, canvastext 8%, canvas); +} + .node-inspector__type { font-weight: 600; } @@ -204,6 +294,19 @@ color: canvastext; } +.node-inspector__field--color { + flex-direction: row; + align-items: center; + gap: 8px; +} + +.node-inspector__field--color input[type='color'] { + width: 36px; + height: 28px; + padding: 2px; + cursor: pointer; +} + .node-inspector__action { font: inherit; padding: 8px 10px; @@ -227,6 +330,7 @@ .node-inspector__label { margin: 0; font-size: 15px; + white-space: pre-wrap; } .node-inspector__description { diff --git a/src/nodes.tsx b/src/nodes.tsx index 7aaee5a..7145b5c 100644 --- a/src/nodes.tsx +++ b/src/nodes.tsx @@ -1,5 +1,5 @@ -import { createContext, useContext } from 'react'; -import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; +import { createContext, useContext, type CSSProperties } from 'react'; +import { Handle, NodeResizer, Position, type Node, type NodeProps } from '@xyflow/react'; import type { DiagramNodeData, NodeKind } from './types'; type ShapeNode = Node; @@ -7,11 +7,64 @@ type ShapeNode = Node; /** Ids of nodes that currently have children (i.e. render as a drill-into-able sub-process). */ export const NodesWithChildrenContext = createContext>(new Set()); +/** Current editor mode — resize handles and connection points only show in 'edit'. */ +export const ModeContext = createContext<'edit' | 'view'>('edit'); + +export const DEFAULT_NODE_COLOR: Record = { + task: '#eef2ff', + startEvent: '#f0fdf4', + intermediateEvent: '#fefce8', + endEvent: '#fef2f2', + exclusiveGateway: '#fff7ed', + parallelGateway: '#faf5ff', + inclusiveGateway: '#f0fdfa', +}; + +const HANDLE_POSITIONS: { id: string; position: Position }[] = [ + { id: 'top', position: Position.Top }, + { id: 'right', position: Position.Right }, + { id: 'bottom', position: Position.Bottom }, + { id: 'left', position: Position.Left }, +]; + +/** + * A single handle per side acts as both connection start and end + * (React Flow handles are connectable both ways by default) — paired with + * connectionMode="loose" on so any point can link to any point. + */ function ShapeHandles() { return ( <> - - + {HANDLE_POSITIONS.map(({ id, position }) => ( + + ))} + + ); +} + +/** + * The diamond div is a rotated square (`transform: rotate(45deg)`). Handles + * placed at the square's own corners land on the diamond's tips once rotated + * — corners, not `Position.Top/Right/Bottom/Left` (those are edge-midpoints, + * which rotate onto the diamond's flat sides instead). + */ +const DIAMOND_HANDLES: { id: string; position: Position; style: CSSProperties }[] = [ + { id: 'top', position: Position.Top, style: { top: 0, left: 0, transform: 'translate(-50%, -50%)' } }, + { id: 'right', position: Position.Right, style: { top: 0, left: '100%', transform: 'translate(-50%, -50%)' } }, + { + id: 'bottom', + position: Position.Bottom, + style: { top: '100%', left: '100%', transform: 'translate(-50%, -50%)' }, + }, + { id: 'left', position: Position.Left, style: { top: '100%', left: 0, transform: 'translate(-50%, -50%)' } }, +]; + +function DiamondHandles() { + return ( + <> + {DIAMOND_HANDLES.map(({ id, position, style }) => ( + + ))} ); } @@ -26,38 +79,96 @@ function SubProcessBadge({ id }: { id: string }) { ); } -function TaskNode({ id, data }: NodeProps) { +function ShapeResizer({ + selected, + minWidth, + minHeight, + keepAspectRatio = true, +}: { + selected: boolean; + minWidth: number; + minHeight: number; + keepAspectRatio?: boolean; +}) { + const mode = useContext(ModeContext); return ( -
+ + ); +} + +function TaskNode({ id, data, selected }: NodeProps) { + return ( +
+ - {data.label} + {data.label}
); } -function GatewayNode({ id, data }: NodeProps) { +const EVENT_BORDER_CLASS: Record<'startEvent' | 'intermediateEvent' | 'endEvent', string> = { + startEvent: 'diagram-node--startEvent', + intermediateEvent: 'diagram-node--intermediateEvent', + endEvent: 'diagram-node--endEvent', +}; + +function EventNode({ id, data, type, selected }: NodeProps) { + const kind = (type as 'startEvent' | 'intermediateEvent' | 'endEvent') ?? 'startEvent'; return ( -
+
+ - {data.label} + {data.label}
); } -function EventNode({ id, data }: NodeProps) { +const GATEWAY_ICON: Record<'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway', string> = { + exclusiveGateway: '✕', + parallelGateway: '+', + inclusiveGateway: '○', +}; + +function GatewayNode({ id, data, type, selected }: NodeProps) { + const kind = (type as 'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway') ?? 'exclusiveGateway'; return ( -
- +
+ + {/* + The diamond is an inner square inset to 1/sqrt(2) of the outer box and + rotated 45deg, so its tips exactly reach the outer box's edges without + clipping. The caption sits on the (unrotated) outer box instead of the + diamond itself, so it renders upright below the shape. + */} +
+ + {GATEWAY_ICON[kind]} +
- {data.label} + {data.label}
); } export const nodeTypes = { task: TaskNode, - gateway: GatewayNode, - event: EventNode, + startEvent: EventNode, + intermediateEvent: EventNode, + endEvent: EventNode, + exclusiveGateway: GatewayNode, + parallelGateway: GatewayNode, + inclusiveGateway: GatewayNode, }; diff --git a/src/serialize.ts b/src/serialize.ts index 0b6cbb3..9d232ef 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -14,6 +14,8 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument position: { x: node.position.x, y: node.position.y }, data: node.data as DiagramNode['data'], ...(node.parentId ? { parentId: node.parentId } : {}), + ...(typeof node.width === 'number' ? { width: node.width } : {}), + ...(typeof node.height === 'number' ? { height: node.height } : {}), }), ), edges: edges.map( @@ -21,7 +23,10 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument id: edge.id, source: edge.source, target: edge.target, + ...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}), + ...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}), type: edge.type, + ...(typeof edge.label === 'string' && edge.label ? { label: edge.label } : {}), data: edge.data as DiagramEdge['data'], }), ), diff --git a/src/types.ts b/src/types.ts index 9412cf2..5a162f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,18 @@ -export type NodeKind = 'task' | 'gateway' | 'event'; +export type NodeKind = + | 'task' + | 'startEvent' + | 'intermediateEvent' + | 'endEvent' + | 'exclusiveGateway' + | 'parallelGateway' + | 'inclusiveGateway'; export interface DiagramNodeData extends Record { label: string; description?: string; responsible?: string; duration?: string; + color?: string; } export interface DiagramNode { @@ -14,17 +22,21 @@ export interface DiagramNode { data: DiagramNodeData; /** Node this one is nested under (BPMN sub-process style drill-down), same document. */ parentId?: string; + width?: number; + height?: number; } -export interface DiagramEdgeData extends Record { - label?: string; -} +export interface DiagramEdgeData extends Record {} export interface DiagramEdge { id: string; source: string; target: string; + /** Which of the 4 connection points on each shape this edge is anchored to. */ + sourceHandle?: string; + targetHandle?: string; type?: string; + label?: string; data?: DiagramEdgeData; }