Skip to content

Commit 5013fc6

Browse files
feat(library): undo/redo in shared (collaborative) diagrams (#766)
1 parent 82942cd commit 5013fc6

14 files changed

Lines changed: 975 additions & 111 deletions

.changeset/collab-undo-redo.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
Adds undo/redo to shared (collaborative) diagrams. Undo and redo now work during a collaboration session, scoped to each person's own edits — undo reverts only your own changes, never a teammate's, and never overwrites an element someone else is editing. A whole drag or resize counts as a single step, your selection comes back with the change you undo, and everyone keeps seeing each other's edits move in real time.

library/eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default [
3737
"lib/components/popovers/**/*EditPopover.tsx",
3838
"lib/edges/GenericEdge.tsx",
3939
"lib/hooks/useMessagePositioning.ts",
40+
"lib/hooks/useRemoteDraggingNodes.ts",
4041
"lib/hooks/useStraightPathEdge.ts",
4142
],
4243
rules: { "react-hooks/set-state-in-effect": "warn" },

library/lib/App.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import { diagramNodeTypes } from "./nodes"
3737
import { useDiagramModifiable } from "./hooks/useDiagramModifiable"
3838
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"
3939
import { usePaneClicked } from "./hooks/usePaneClicked"
40+
import {
41+
useRemoteDraggingNodes,
42+
applyDraggingOverlay,
43+
} from "./hooks/useRemoteDraggingNodes"
4044
import { ApollonMode } from "./typings"
4145
import {
4246
getConnectionLineType,
@@ -70,14 +74,15 @@ const isPointArray = (value: unknown): value is IPoint[] =>
7074
function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
7175
useKeyboardShortcuts()
7276

73-
const { nodes, onNodesChange, edges, onEdgesChange, diagramId } =
77+
const { nodes, onNodesChange, edges, onEdgesChange, diagramId, previewMode } =
7478
useDiagramStore(
7579
useShallow((state) => ({
7680
nodes: state.nodes,
7781
onNodesChange: state.onNodesChange,
7882
edges: state.edges,
7983
onEdgesChange: state.onEdgesChange,
8084
diagramId: state.diagramId,
85+
previewMode: state.previewMode,
8186
}))
8287
)
8388

@@ -105,6 +110,18 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
105110

106111
const isDiagramModifiable = useDiagramModifiable()
107112

113+
// Overlay the live positions/sizes of nodes peers are dragging (carried over
114+
// ephemeral awareness, never the document) onto what React Flow renders, so
115+
// remote drags stay live without per-frame CRDT writes. Suppressed during a
116+
// version preview (matching CollaborationLayer's other remote visuals), and a
117+
// no-op outside collaboration — `displayNodes` is then `nodes` by reference,
118+
// so React Flow re-renders nothing.
119+
const remoteDraggingNodes = useRemoteDraggingNodes(
120+
awareness,
121+
collaboration.enabled && !previewMode
122+
)
123+
const displayNodes = applyDraggingOverlay(nodes, remoteDraggingNodes)
124+
108125
const connectionLineType = getConnectionLineType(diagramType)
109126
const onNodeDragStop = useNodeDragStop()
110127
const onNodeDrag = useNodeDrag()
@@ -163,7 +180,7 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
163180
className="apollon-container"
164181
nodeTypes={diagramNodeTypes}
165182
edgeTypes={diagramEdgeTypes}
166-
nodes={nodes}
183+
nodes={displayNodes}
167184
edges={edges}
168185
onDragOver={onDragOver}
169186
onNodesChange={onNodesChange}

library/lib/apollon-editor.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,13 @@ export class ApollonEditor {
177177
this.metadataStore.getState().setScrollLock(options.scrollLock)
178178
}
179179

180-
if (
181-
this.metadataStore.getState().mode === Apollon.ApollonMode.Modelling &&
182-
!collaboration.enabled
183-
) {
180+
this.diagramStore.getState().setCollaborationEnabled(collaboration.enabled)
181+
182+
// Undo/redo runs in modelling, collaboration included. The UndoManager
183+
// tracks only locally-authored ("store") writes, so each peer undoes only
184+
// their own edits (local undo). Safe because transient drag/resize frames
185+
// are no longer persisted (see diagramStore.onNodesChange).
186+
if (this.metadataStore.getState().mode === Apollon.ApollonMode.Modelling) {
184187
this.diagramStore.getState().initializeUndoManager()
185188
}
186189

library/lib/hooks/useNodeDragStop.ts

Lines changed: 99 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import { useShallow } from "zustand/shallow"
1313

1414
export const useNodeDragStop = () => {
1515
const { screenToFlowPosition, getIntersectingNodes } = useReactFlow()
16-
const { nodes, setNodes } = useDiagramStore(
16+
const { nodes, setNodes, endTransientNodeBroadcast } = useDiagramStore(
1717
useShallow((state) => ({
1818
nodes: state.nodes,
1919
setNodes: state.setNodes,
20+
endTransientNodeBroadcast: state.endTransientNodeBroadcast,
2021
}))
2122
)
2223

@@ -31,97 +32,117 @@ export const useNodeDragStop = () => {
3132
// Clear alignment guides when drag stops
3233
clearGuides()
3334

34-
const draggedLastPoint = screenToFlowPosition({
35-
x:
36-
"changedTouches" in event
37-
? // event is handled as Mouse event in the library but also it is touch event for mobile users
38-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
39-
(event as any).changedTouches[0].clientX
40-
: event.clientX,
41-
y:
42-
"changedTouches" in event
43-
? // event is handled as Mouse event in the library but also it is touch event for mobile users
44-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45-
(event as any).changedTouches[0].clientY
46-
: event.clientY,
47-
})
35+
// Tear down the peers' live-drag overlay once this handler's settle
36+
// `setNodes` (below, every branch) has committed the final position to
37+
// the document — `finally` guarantees the order, so peers apply the
38+
// durable position before the overlay is removed (no snap-back). The
39+
// dragging:false `onNodesChange` frame usually carries the same position
40+
// as the last drag frame and short-circuits, so this is the drag path's
41+
// clear; resize (no drag-stop) clears from `onNodesChange` instead.
42+
try {
43+
const draggedLastPoint = screenToFlowPosition({
44+
x:
45+
"changedTouches" in event
46+
? // event is handled as Mouse event in the library but also it is touch event for mobile users
47+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
48+
(event as any).changedTouches[0].clientX
49+
: event.clientX,
50+
y:
51+
"changedTouches" in event
52+
? // event is handled as Mouse event in the library but also it is touch event for mobile users
53+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
54+
(event as any).changedTouches[0].clientY
55+
: event.clientY,
56+
})
4857

49-
const intersectionsWithDroppedLocation = getIntersectingNodes({
50-
x: draggedLastPoint.x,
51-
y: draggedLastPoint.y,
52-
width: CANVAS.MOUSE_UP_OFFSET_PX,
53-
height: CANVAS.MOUSE_UP_OFFSET_PX,
54-
}).filter((n) => {
55-
return (
56-
isParentNodeType(n.type) &&
57-
n.id !== draggedNode.id &&
58-
n.type &&
59-
draggedNode.type &&
60-
canDropIntoParent(draggedNode.type, n.type)
61-
)
62-
})
58+
const intersectionsWithDroppedLocation = getIntersectingNodes({
59+
x: draggedLastPoint.x,
60+
y: draggedLastPoint.y,
61+
width: CANVAS.MOUSE_UP_OFFSET_PX,
62+
height: CANVAS.MOUSE_UP_OFFSET_PX,
63+
}).filter((n) => {
64+
return (
65+
isParentNodeType(n.type) &&
66+
n.id !== draggedNode.id &&
67+
n.type &&
68+
draggedNode.type &&
69+
canDropIntoParent(draggedNode.type, n.type)
70+
)
71+
})
6372

64-
const parentNode = intersectionsWithDroppedLocation.length
65-
? intersectionsWithDroppedLocation[
66-
intersectionsWithDroppedLocation.length - 1
67-
]
68-
: null
73+
const parentNode = intersectionsWithDroppedLocation.length
74+
? intersectionsWithDroppedLocation[
75+
intersectionsWithDroppedLocation.length - 1
76+
]
77+
: null
6978

70-
if (!parentNode) {
71-
const updatedNode = nodes.map((n) =>
72-
n.id === draggedNode.id
73-
? {
74-
...draggedNode,
75-
position: getPositionOnCanvas(draggedNode, nodes),
76-
parentId: undefined,
77-
}
78-
: n
79-
)
80-
setNodes(updatedNode)
81-
return
82-
}
79+
if (!parentNode) {
80+
const updatedNode = nodes.map((n) =>
81+
n.id === draggedNode.id
82+
? {
83+
...draggedNode,
84+
position: getPositionOnCanvas(draggedNode, nodes),
85+
parentId: undefined,
86+
}
87+
: n
88+
)
89+
setNodes(updatedNode)
90+
return
91+
}
8392

84-
const isThisNewParent =
85-
parentNode && parentNode?.id !== draggedNode.parentId
93+
const isThisNewParent =
94+
parentNode && parentNode?.id !== draggedNode.parentId
8695

87-
if (isThisNewParent) {
88-
const updatedNode: Node = {
89-
...structuredClone(draggedNode),
90-
position: getPositionOnCanvas(draggedNode, nodes),
91-
parentId: undefined,
92-
}
93-
const parentsFlowPosition = getPositionOnCanvas(parentNode, nodes)
96+
if (isThisNewParent) {
97+
const updatedNode: Node = {
98+
...structuredClone(draggedNode),
99+
position: getPositionOnCanvas(draggedNode, nodes),
100+
parentId: undefined,
101+
}
102+
const parentsFlowPosition = getPositionOnCanvas(parentNode, nodes)
94103

95-
updatedNode.position.x -= parentsFlowPosition.x
96-
updatedNode.position.y -= parentsFlowPosition.y
97-
updatedNode.parentId = parentNode.id
104+
updatedNode.position.x -= parentsFlowPosition.x
105+
updatedNode.position.y -= parentsFlowPosition.y
106+
updatedNode.parentId = parentNode.id
98107

99-
const updatedNodes = structuredClone(nodes)
100-
const updatedNodesList = sortNodesTopologically(
101-
resizeAllParents(
102-
updatedNode,
103-
updatedNodes.map((n) => (n.id === updatedNode.id ? updatedNode : n))
108+
const updatedNodes = structuredClone(nodes)
109+
const updatedNodesList = sortNodesTopologically(
110+
resizeAllParents(
111+
updatedNode,
112+
updatedNodes.map((n) =>
113+
n.id === updatedNode.id ? updatedNode : n
114+
)
115+
)
104116
)
105-
)
106117

107-
setNodes(updatedNodesList)
108-
return
109-
}
118+
setNodes(updatedNodesList)
119+
return
120+
}
110121

111-
if (draggedNode.parentId) {
112-
const updatedNodes = structuredClone(nodes)
113-
const updatedNodesList = sortNodesTopologically(
114-
resizeAllParents(
115-
draggedNode,
116-
updatedNodes.map((n) =>
117-
n.id === draggedNode.id ? { ...draggedNode } : n
122+
if (draggedNode.parentId) {
123+
const updatedNodes = structuredClone(nodes)
124+
const updatedNodesList = sortNodesTopologically(
125+
resizeAllParents(
126+
draggedNode,
127+
updatedNodes.map((n) =>
128+
n.id === draggedNode.id ? { ...draggedNode } : n
129+
)
118130
)
119131
)
120-
)
121-
setNodes(updatedNodesList)
132+
setNodes(updatedNodesList)
133+
}
134+
} finally {
135+
endTransientNodeBroadcast()
122136
}
123137
},
124-
[screenToFlowPosition, nodes, getIntersectingNodes, setNodes, clearGuides]
138+
[
139+
screenToFlowPosition,
140+
nodes,
141+
getIntersectingNodes,
142+
setNodes,
143+
clearGuides,
144+
endTransientNodeBroadcast,
145+
]
125146
)
126147

127148
return onNodeDragStop
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useEffect, useState } from "react"
2+
import type { Node } from "@xyflow/react"
3+
import type { CollaborationAwarenessApi } from "@/components/collaboration/CollaborationLayer"
4+
import type { CollaborationState, DraggingNode } from "@/typings"
5+
6+
/**
7+
* Live positions/sizes of nodes that *remote* peers are currently dragging or
8+
* resizing, keyed by node id. These travel over the ephemeral awareness channel
9+
* (never the Yjs document — see `diagramStore.onNodesChange`), so this hook is
10+
* the read side of the live remote drag: it mirrors awareness into React state
11+
* that `applyDraggingOverlay` overlays onto the rendered nodes.
12+
*/
13+
export type RemoteDraggingOverlay = Map<string, DraggingNode>
14+
15+
const sameOverlay = (
16+
a: RemoteDraggingOverlay,
17+
b: RemoteDraggingOverlay
18+
): boolean => {
19+
if (a.size !== b.size) return false
20+
for (const [id, node] of a) {
21+
const other = b.get(id)
22+
if (
23+
!other ||
24+
other.position.x !== node.position.x ||
25+
other.position.y !== node.position.y ||
26+
(other.width ?? null) !== (node.width ?? null) ||
27+
(other.height ?? null) !== (node.height ?? null)
28+
) {
29+
return false
30+
}
31+
}
32+
return true
33+
}
34+
35+
const buildOverlay = (
36+
states: Map<number, CollaborationState>,
37+
localClientId: number
38+
): RemoteDraggingOverlay => {
39+
const overlay: RemoteDraggingOverlay = new Map()
40+
for (const [clientId, state] of states) {
41+
if (clientId === localClientId) continue
42+
const draggingNodes = state.draggingNodes
43+
if (!draggingNodes) continue
44+
// Last writer wins on the rare chance two peers drag the same node; the
45+
// node is one peer's at a time in practice, so collisions don't linger.
46+
for (const node of draggingNodes) overlay.set(node.id, node)
47+
}
48+
return overlay
49+
}
50+
51+
export const useRemoteDraggingNodes = (
52+
awareness: CollaborationAwarenessApi,
53+
active: boolean
54+
): RemoteDraggingOverlay => {
55+
const [overlay, setOverlay] = useState<RemoteDraggingOverlay>(() => new Map())
56+
57+
useEffect(() => {
58+
if (!active) {
59+
// Drop any lingering overlay when collaboration turns off so locally
60+
// owned positions take over immediately.
61+
setOverlay((prev) => (prev.size === 0 ? prev : new Map()))
62+
return
63+
}
64+
65+
const localClientId = awareness.getLocalAwarenessClientId()
66+
const rebuild = (states: Map<number, CollaborationState>) => {
67+
const next = buildOverlay(states, localClientId)
68+
// Bail out of the state update when nothing dragging changed, so an
69+
// unrelated awareness tick (a peer's cursor move) doesn't re-render the
70+
// whole canvas.
71+
setOverlay((prev) => (sameOverlay(prev, next) ? prev : next))
72+
}
73+
74+
rebuild(awareness.getAwarenessStates())
75+
return awareness.subscribeToAwarenessChanges(rebuild)
76+
}, [awareness, active])
77+
78+
return overlay
79+
}
80+
81+
/**
82+
* Overlay remote live-drag positions/sizes onto the rendered nodes. Returns the
83+
* same array reference when there is nothing to overlay so React Flow skips the
84+
* update. Behaviourally identical to the old per-frame document write — it
85+
* moves the node in the controlled `nodes` prop — so connected edges follow
86+
* exactly as before; the difference is only the (ephemeral) source.
87+
*/
88+
export const applyDraggingOverlay = (
89+
nodes: Node[],
90+
overlay: RemoteDraggingOverlay
91+
): Node[] => {
92+
if (overlay.size === 0) return nodes
93+
return nodes.map((node) => {
94+
const dragged = overlay.get(node.id)
95+
if (!dragged) return node
96+
return {
97+
...node,
98+
position: dragged.position,
99+
...(dragged.width != null ? { width: dragged.width } : {}),
100+
...(dragged.height != null ? { height: dragged.height } : {}),
101+
}
102+
})
103+
}

0 commit comments

Comments
 (0)