Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/backend/contracts/websocket-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Transport model:
- Client sends JSON messages with a `type` discriminator.
- Ephemeral events are relayed directly in-memory.
- Durable structural events are committed through `canvas-service` first, then broadcast with committed metadata.
- The sender also receives the committed structural event back as the client-side ack path for optimistic queue reconciliation.
- `crdt_op` and `sync_request` are special-cased for replay/sync.
Comment on lines 9 to 13
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc now mentions the sender receiving the committed structural event as an ack, but the contract examples below still list structural outbound events without the required clientOperationId field (and imply the old relay-style payloads). Please update the “Event Types (Client -> Server)” section (and any examples) to reflect the new structural message shape with clientOperationId metadata.

Copilot uses AI. Check for mistakes.

## Join Flow
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"codemirror": "^6.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2"
"react-router-dom": "^7.13.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@babel/core": "^7.29.0",
Expand Down
26 changes: 26 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 14 additions & 15 deletions frontend/src/canvas/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { useCanvasShortcutContainer } from "./shortcuts/useCanvasShortcutContain
import type { NodeDragVisual } from "./dragVisuals";
import { useCanvasDocument } from "./model/useCanvasDocument";
import { createCursorPresenceStore } from "./presence/cursorPresenceStore";
import { createCanvasDocumentStore } from "./document/canvasDocumentStore";
import { createCanvasOperationQueueStore } from "./ops/canvasOperationQueueStore";

interface CanvasProps {
canvasId: string;
Expand All @@ -41,6 +43,10 @@ export function Canvas({ canvasId, userId, displayName }: CanvasProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const sendRef = useRef<((event: CanvasOutboundEvent) => void) | null>(null);
const cursorStore = useMemo(() => createCursorPresenceStore(), []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const documentStore = useMemo(() => createCanvasDocumentStore(), [canvasId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const operationQueueStore = useMemo(() => createCanvasOperationQueueStore(), [canvasId]);
Comment on lines +46 to +49
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The react-hooks/exhaustive-deps disables look unnecessary here (the memo callbacks only reference imported factory functions). Keeping the disables can hide real dependency issues later; consider removing them and letting the linter enforce correctness.

Copilot uses AI. Check for mistakes.
const [remoteSelections, setRemoteSelections] = useState<
Map<string, Set<string>>
>(new Map());
Expand Down Expand Up @@ -76,6 +82,8 @@ export function Canvas({ canvasId, userId, displayName }: CanvasProps) {
viewportRef,
transformRef,
sendRef,
documentStore,
operationQueueStore,
});

const [tool, setTool] = useState<CanvasTool>("select");
Expand All @@ -85,15 +93,8 @@ export function Canvas({ canvasId, userId, displayName }: CanvasProps) {

const collabHandlers = useMemo(
() => ({
onNodeCreate: remote.applyNodeCreate,
onNodeMove: (
remoteUserId: string,
nodeId: string,
x: number,
y: number,
) => {
onNodeMove: (remoteUserId: string, nodeId: string, x: number, y: number) => {
const previousNode = getNodeById(nodeId);
remote.applyNodeMove(remoteUserId, nodeId, x, y);
if (!previousNode) return;

setRemoteDragStates((prev) => {
Expand Down Expand Up @@ -130,10 +131,6 @@ export function Canvas({ canvasId, userId, displayName }: CanvasProps) {
return next;
});
},
onNodeUpdate: remote.applyNodeUpdate,
onNodeDelete: remote.applyNodeDelete,
onEdgeCreate: remote.applyEdgeCreate,
onEdgeDelete: remote.applyEdgeDelete,
onNodeSelect: (remoteUserId: string, nodeIds: string[]) => {
setRemoteSelections((prev) => {
const next = new Map(prev);
Expand Down Expand Up @@ -168,15 +165,17 @@ export function Canvas({ canvasId, userId, displayName }: CanvasProps) {
remoteStrokes,
send,
onPointerMove: collabPointerMove,
} = useCanvasCollab(
} = useCanvasCollab({
canvasId,
userId,
displayName,
viewportRef,
transformRef,
cursorStore,
collabHandlers,
);
documentStore,
operationQueueStore,
handlers: collabHandlers,
});

useEffect(() => {
sendRef.current = send;
Expand Down
20 changes: 14 additions & 6 deletions frontend/src/canvas/CursorOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import resizeNwseCursorImage from "../assets/Cursor/Resize/North West South East
import grabCursorImage from "../assets/Cursor/Grab/Grab.png";
import grabbingCursorImage from "../assets/Cursor/Grab/Grabbing.png";
import {
useRemoteCursors,
useInterpolatedRemoteCursors,
type CursorPresenceStore,
} from "./presence/cursorPresenceStore";
import type { CollabUser } from "./presence/types";
Expand Down Expand Up @@ -60,8 +60,12 @@ function CursorGlyph({
<div
className={`pointer-events-none absolute flex items-start ${zClassName}`}
style={{
left: x - CURSOR_TIP_OFFSET_X,
top: y - CURSOR_TIP_OFFSET_Y,
left: 0,
top: 0,
transform: `translate3d(${x - CURSOR_TIP_OFFSET_X}px, ${
y - CURSOR_TIP_OFFSET_Y
}px, 0)`,
willChange: "transform",
}}
>
<svg
Expand Down Expand Up @@ -100,7 +104,7 @@ export function CursorOverlay({
localCursorMode,
}: Props) {
const [localCursor, setLocalCursor] = useState<LocalCursor | null>(null);
const cursors = useRemoteCursors(cursorStore);
const cursors = useInterpolatedRemoteCursors(cursorStore);

useEffect(() => {
const viewport = viewportRef.current;
Expand Down Expand Up @@ -169,8 +173,12 @@ export function CursorOverlay({
aria-hidden="true"
draggable={false}
style={{
left: localCursor.x - CURSOR_TIP_OFFSET_X,
top: localCursor.y - CURSOR_TIP_OFFSET_Y,
left: 0,
top: 0,
transform: `translate3d(${localCursor.x - CURSOR_TIP_OFFSET_X}px, ${
localCursor.y - CURSOR_TIP_OFFSET_Y
}px, 0)`,
willChange: "transform",
}}
/>
))}
Expand Down
Loading
Loading