Skip to content

Commit 2f6c7b0

Browse files
authored
feat: Implement live collaboration and Shareable link
* feat: Implement collaboration room functionality with cursor states and member management - Added support for collaboration rooms in the relay server, including room creation, member joining, and cursor color assignment. - Implemented room cleanup logic to notify remaining members when a room is emptied. - Introduced a share server for storing and retrieving encrypted scene snapshots via HTTP API. - Integrated scene encryption and sharing service using Web Crypto API for client-side encryption. - Enhanced main application to support sharing scenes and managing collaboration room states. - Updated styles for improved UI elements.
1 parent 7ebb971 commit 2f6c7b0

15 files changed

Lines changed: 1972 additions & 38 deletions

.github/workflows/e2e-tests.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
uses: oven-sh/setup-bun@v2
3131

3232
- name: Install Jac and dependencies
33-
run: pip install jaclang jac-client jac-scale pytest pytest-playwright pytest-timeout byllm
33+
run: pip install jaclang jac-client jac-scale[data] pytest pytest-playwright pytest-timeout byllm
3434

3535
- name: Install Playwright browsers
3636
run: python -m playwright install --with-deps chromium
@@ -41,6 +41,9 @@ jobs:
4141
- name: Install local MCP server into jac venv
4242
run: .jac/venv/bin/pip install ./mcp-server --force-reinstall --no-deps
4343

44+
- name: Install jac-scale data dependencies into jac venv
45+
run: .jac/venv/bin/pip install "jac-scale[data]"
46+
4447
- name: Run E2E tests
4548
run: pytest tests/e2e/ -v --timeout=300
4649

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
2020
&& rm -rf /var/lib/apt/lists/*
2121

2222
# Install Jac packages from PyPI
23-
RUN pip install --no-cache-dir jaclang jac-client jac-scale byllm
23+
RUN pip install --no-cache-dir jaclang jac-client jac-scale[all] byllm
2424

2525
# Install Bun (required for jac client-side dependencies)
2626
RUN curl -fsSL https://bun.sh/install | bash && \

components/Canvas.cl.jac

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import from "..constants.tools" { TOOL_SHORTCUTS }
1414
import from .canvas.CanvasRenderer { CanvasRenderer }
1515
import from .canvas.TextInput { TextInput }
1616
import from .canvas.ContextMenu { ContextMenu }
17+
import from .canvas.collaborator_cursors { CollaboratorCursors }
1718
import from .layout.ShortcutHelp { ShortcutHelp }
1819
import from .layout.ConfirmDialog { ConfirmDialog }
1920

@@ -29,14 +30,31 @@ def:pub Canvas(props: dict) -> JsxElement {
2930
currentLineStyle = props.currentLineStyle or "solid";
3031
currentCurveStyle = props.currentCurveStyle or "curved";
3132
currentFillColor = props.currentFillColor or "transparent";
33+
roomState = props.roomState or {};
34+
onSessionEnded = props.onSessionEnded;
35+
endSessionRef = props.endSessionRef;
36+
updateUsernameRef = props.updateUsernameRef;
37+
onParticipantsChange = props.onParticipantsChange;
3238

3339
canvasHook = useCanvas();
3440
drawingState = useDrawingState();
3541
elementsHook = useElements();
3642
selectionHook = useSelection();
3743
viewportHook = useViewport();
3844
textInputHook = useTextInput();
39-
useWebSocket(elementsHook, viewportHook, canvasHook.canvasRef, canvasHook);
45+
wsHook = useWebSocket(elementsHook, viewportHook, canvasHook.canvasRef, canvasHook, roomState, onSessionEnded);
46+
wsHookRef = useRef(wsHook);
47+
wsHookRef.current = wsHook;
48+
# Expose sendEndSession to parent via ref
49+
if endSessionRef and wsHook.sendEndSessionRef {
50+
endSessionRef.current = wsHook.sendEndSessionRef.current;
51+
}
52+
if updateUsernameRef and wsHook.sendUpdateUsernameRef {
53+
updateUsernameRef.current = wsHook.sendUpdateUsernameRef.current;
54+
}
55+
hasBroadcastMountedRef = useRef(False);
56+
lastCursorBroadcastRef = useRef(0);
57+
lastDrawBroadcastRef = useRef(0);
4058

4159
canvasRef = canvasHook.canvasRef;
4260
containerRef = canvasHook.containerRef;
@@ -74,6 +92,28 @@ def:pub Canvas(props: dict) -> JsxElement {
7492
editingShapeId = textInputHook.textInput.editingShapeId;
7593
}
7694

95+
can with [elementsHook.elements] entry {
96+
if not hasBroadcastMountedRef.current {
97+
hasBroadcastMountedRef.current = True;
98+
} elif wsHookRef.current and wsHookRef.current.isApplyingRemoteRef and wsHookRef.current.isApplyingRemoteRef.current {
99+
wsHookRef.current.isApplyingRemoteRef.current = False;
100+
} elif wsHookRef.current and wsHookRef.current.sendRoomBroadcastRef and wsHookRef.current.sendRoomBroadcastRef.current {
101+
wsHookRef.current.sendRoomBroadcastRef.current(elementsHook.elements);
102+
}
103+
}
104+
105+
can with [wsHook.participants, wsHook.hostSessionId] entry {
106+
if onParticipantsChange {
107+
# Merge hostSessionId as __hostSid meta key so parent can read it
108+
merged = Object.assign({}, wsHook.participants);
109+
if wsHook.hostSessionId {
110+
merged["__hostSid"] = wsHook.hostSessionId;
111+
}
112+
onParticipantsChange(merged);
113+
}
114+
return lambda -> None {};
115+
}
116+
77117
can with [elementsHook.elements, canvasHook.canvasSize, viewportHook.zoom, viewportHook.panOffset, selectionHook.selectedElement, selectionHook.selectedElements, selectionHook.isBoxSelecting, selectionHook.boxStart, selectionHook.boxEnd, drawingState.isDrawing, textInputHook.textInput] entry {
78118
canvas = canvasRef.current;
79119
if canvas {
@@ -409,6 +449,15 @@ def:pub Canvas(props: dict) -> JsxElement {
409449
});
410450
};
411451
}
452+
if props.elementsForShareRef {
453+
props.elementsForShareRef.current = elementsHook.elements;
454+
}
455+
if props.setElementsRef {
456+
props.setElementsRef.current = lambda elements: any -> None {
457+
elementsHook.setElements(elements);
458+
selectionHook.clearSelection();
459+
};
460+
}
412461
}
413462

414463
def handleCanvasClick(e: dict) -> None {
@@ -1159,6 +1208,17 @@ def:pub Canvas(props: dict) -> JsxElement {
11591208
def handleMouseMove(e: dict) -> None {
11601209
pos = getMousePosition(e, canvasRef.current, viewportHook.panOffset, viewportHook.zoom);
11611210

1211+
# Throttled cursor broadcast (50ms) — only when in a room
1212+
cur_now = Date.now();
1213+
if cur_now - lastCursorBroadcastRef.current > 50 {
1214+
lastCursorBroadcastRef.current = cur_now;
1215+
cur_ws_hook = wsHookRef.current;
1216+
if cur_ws_hook and cur_ws_hook.sendCursorBroadcastRef and cur_ws_hook.sendCursorBroadcastRef.current {
1217+
cur_stored_name = localStorage.getItem("jasketch_username") or "Anonymous";
1218+
cur_ws_hook.sendCursorBroadcastRef.current(pos.x, pos.y, cur_stored_name);
1219+
}
1220+
}
1221+
11621222
if viewportHook.isPanning {
11631223
viewportHook.handlePan(e.movementX, e.movementY);
11641224
return;
@@ -1642,6 +1702,38 @@ def:pub Canvas(props: dict) -> JsxElement {
16421702
drawPreview(previewCtx, currentTool, drawingState.startPoint, previewEnd, currentColor, currentStrokeWidth);
16431703
previewCtx.restore();
16441704
}
1705+
1706+
# Throttled live drawing broadcast (80ms) — sends in-progress element as __draft__
1707+
if drawingState.isDrawing {
1708+
draw_now = Date.now();
1709+
if draw_now - lastDrawBroadcastRef.current > 80 {
1710+
lastDrawBroadcastRef.current = draw_now;
1711+
preview_ws_hook = wsHookRef.current;
1712+
if preview_ws_hook and preview_ws_hook.sendRoomPreviewRef and preview_ws_hook.sendRoomPreviewRef.current {
1713+
live_draft_el = None;
1714+
if currentTool == "freehand" and drawingState.currentPath and drawingState.currentPath.length > 0 {
1715+
live_draft_el = {"type": "freehand", "id": "__draft__", "points": drawingState.currentPath, "color": currentColor, "strokeWidth": currentStrokeWidth, "lineStyle": "solid", "opacity": currentOpacity};
1716+
} elif currentTool == "rectangle" and drawingState.startPoint {
1717+
ldraft_rw = pos.x - drawingState.startPoint.x;
1718+
ldraft_rh = pos.y - drawingState.startPoint.y;
1719+
live_draft_el = {"type": "rectangle", "id": "__draft__", "x": drawingState.startPoint.x, "y": drawingState.startPoint.y, "width": ldraft_rw, "height": ldraft_rh, "color": currentColor, "strokeWidth": currentStrokeWidth, "fillColor": currentFillColor, "lineStyle": currentLineStyle, "opacity": currentOpacity};
1720+
} elif currentTool == "circle" and drawingState.startPoint {
1721+
ldraft_cw = pos.x - drawingState.startPoint.x;
1722+
ldraft_ch = pos.y - drawingState.startPoint.y;
1723+
live_draft_el = {"type": "circle", "id": "__draft__", "x": drawingState.startPoint.x, "y": drawingState.startPoint.y, "width": ldraft_cw, "height": ldraft_ch, "color": currentColor, "strokeWidth": currentStrokeWidth, "fillColor": currentFillColor, "lineStyle": currentLineStyle, "opacity": currentOpacity};
1724+
} elif currentTool == "diamond" and drawingState.startPoint {
1725+
ldraft_dw = pos.x - drawingState.startPoint.x;
1726+
ldraft_dh = pos.y - drawingState.startPoint.y;
1727+
live_draft_el = {"type": "diamond", "id": "__draft__", "x": drawingState.startPoint.x, "y": drawingState.startPoint.y, "width": ldraft_dw, "height": ldraft_dh, "color": currentColor, "strokeWidth": currentStrokeWidth, "fillColor": currentFillColor, "lineStyle": currentLineStyle, "opacity": currentOpacity};
1728+
} elif (currentTool == "line" or currentTool == "arrow") and drawingState.startPoint {
1729+
live_draft_el = {"type": currentTool, "id": "__draft__", "x1": drawingState.startPoint.x, "y1": drawingState.startPoint.y, "x2": pos.x, "y2": pos.y, "color": currentColor, "strokeWidth": currentStrokeWidth, "lineStyle": currentLineStyle, "curveStyle": currentCurveStyle, "opacity": currentOpacity};
1730+
}
1731+
if live_draft_el {
1732+
preview_ws_hook.sendRoomPreviewRef.current(elementsHook.elements.concat([live_draft_el]));
1733+
}
1734+
}
1735+
}
1736+
}
16451737
}
16461738

16471739
def handleMouseUp(e: dict) -> None {
@@ -1857,6 +1949,13 @@ def:pub Canvas(props: dict) -> JsxElement {
18571949
onMouseLeave={handleMouseLeave}
18581950
/>
18591951
1952+
<CollaboratorCursors
1953+
cursor_states={wsHook.cursorStates or {}}
1954+
zoom={viewportHook.zoom}
1955+
pan_x={viewportHook.panOffset.x}
1956+
pan_y={viewportHook.panOffset.y}
1957+
/>
1958+
18601959
<TextInput
18611960
textInput={textInputHook.textInput}
18621961
textInputRef={textInputRef}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""CollaboratorCursors — Render other collaborators' cursors and usernames on the canvas."""
2+
3+
def:pub CollaboratorCursors(cursor_states: dict, zoom: float, pan_x: float, pan_y: float) -> any {
4+
if not cursor_states {
5+
return None;
6+
}
7+
8+
cursor_sids: list = Object.keys(cursor_states);
9+
if not cursor_sids or cursor_sids.length == 0 {
10+
return None;
11+
}
12+
13+
return <div className="absolute inset-0 pointer-events-none overflow-hidden z-30">
14+
{[<div
15+
key={csid}
16+
className="absolute flex flex-col items-start"
17+
style={{
18+
"left": (cursor_states[csid].x * zoom + pan_x) + "px",
19+
"top": (cursor_states[csid].y * zoom + pan_y) + "px",
20+
"transform": "translate(-4px, -4px)"
21+
}}
22+
>
23+
<svg width="14" height="14" viewBox="0 0 14 14">
24+
<path
25+
d="M0 0 L0 14 L4 10 L7 13 L9 12 L6 9 L10 9 Z"
26+
fill={cursor_states[csid].color or "#666"}
27+
stroke="white"
28+
strokeWidth="0.8"
29+
/>
30+
</svg>
31+
<div
32+
className="text-xs font-medium px-1.5 py-0.5 rounded-sm whitespace-nowrap mt-0.5"
33+
style={{"backgroundColor": cursor_states[csid].color or "#666", "color": "white", "fontSize": "11px"}}
34+
>
35+
{cursor_states[csid].username or "Anonymous"}
36+
</div>
37+
</div> for csid in Object.keys(cursor_states)]}
38+
</div>;
39+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""JoinNameOverlay — name prompt shown when opening a collaboration link."""
2+
3+
def:pub JoinNameOverlay(on_join: any) -> any {
4+
has name_value: str = localStorage.getItem("jasketch_username") or "";
5+
6+
def handle_join() -> None {
7+
name = name_value.strip() or "Anonymous";
8+
localStorage.setItem("jasketch_username", name);
9+
on_join(name);
10+
}
11+
12+
return <div className="fixed inset-0 z-50 flex items-center justify-center">
13+
<div className="bg-white rounded-lg shadow-lg w-full max-w-sm mx-4">
14+
<div className="border-b px-6 py-4">
15+
<h2 className="text-lg font-semibold">{"Join Collaboration Session"}</h2>
16+
</div>
17+
<div className="p-6 space-y-4">
18+
<p className="text-sm text-gray-600">
19+
{"You've been invited to collaborate. Set your display name before joining."}
20+
</p>
21+
<div>
22+
<label className="block text-sm font-medium text-gray-700 mb-2">
23+
{"Display Name"}
24+
</label>
25+
<input
26+
type="text"
27+
value={name_value}
28+
onChange={lambda e: any -> None { name_value = e.target.value; }}
29+
onKeyDown={lambda e: any -> None {
30+
if e.key == "Enter" { handle_join(); }
31+
}}
32+
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
33+
placeholder="Enter your name (optional)"
34+
autoFocus={True}
35+
/>
36+
</div>
37+
<button
38+
onClick={lambda -> None { handle_join(); }}
39+
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 rounded-md transition"
40+
>
41+
{"Join"}
42+
</button>
43+
</div>
44+
</div>
45+
</div>;
46+
}

0 commit comments

Comments
 (0)