Skip to content

Commit e87100b

Browse files
committed
feat: phase 6
1 parent a95f4d2 commit e87100b

10 files changed

Lines changed: 391 additions & 14 deletions

File tree

docs/SPATIAL_PARITY_CHECKLIST.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
| 3.16 | Arrow `fakePos` / `looseEndpoint` | `arrow.ts` | `arrow-model.ts:fakePos, looseEndpoint` | **Done** | `arrow-model.test.ts` |
8585
| 3.17 | Arrow `regionId` | `arrow.ts` | `arrow-model.ts:regionId` | **Done** | `arrow-model.test.ts` |
8686
| 3.18 | Arrow geometry reads actual note heights | `DisplayArrow/ArrowGeometry.ts` | `DisplayArrow.vue:noteHeights` | **Done** ||
87-
| 3.19 | Arrow source/target anchor positioning | `arrow.ts` `sourceAnchor/targetAnchor` | `arrow-model.ts:sourceAnchor, targetAnchor` | **Not started** ||
87+
| 3.19 | Arrow source/target anchor positioning | `arrow.ts` `sourceAnchor/targetAnchor` | `DisplayArrow.vue:geometry uses sourceAnchor/targetAnchor` | **Done** ||
8888

8989
## 4. Camera / Viewport
9090

@@ -96,7 +96,7 @@
9696
| 4.4 | Zoom (Ctrl+wheel toward cursor) | `camera/zoom.ts` | `useSpatialViewport.ts:onWheel` | **Done** | `spatial-viewport-math.test.ts` |
9797
| 4.5 | Zoom (pinch on touch) | `camera/zoom.ts` | `useSpatialViewport.ts:onPointerDown/Move` | **Done** ||
9898
| 4.6 | Zoom reset button | `camera/zoom.ts` | `SpatialPageView.vue:resetView button` | **Done** ||
99-
| 4.7 | Fit-to-screen | `camera/zoom.ts` | | **Not started** ||
99+
| 4.7 | Fit-to-screen | `camera/zoom.ts` | `useSpatialViewport.ts:fitToScreen + SpatialPageView.vue:fitToScreen button` | **Done** ||
100100
| 4.8 | Zoom indicator (%) | `camera/zoom.ts` | `SpatialPageView.vue:zoom %` | **Done** ||
101101
| 4.9 | Grid background | `camera/grid.ts` | `SpatialWorldCanvas.vue:linear-gradient` | **Done** ||
102102

docs/restart-plan/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DeepNotes Restart Plan — Index
22

3-
> **Last updated:** 2026-05-31 (Phase 6 in progress. Core spatial mechanics done: selection, clipboard, alignment, undo/redo, viewport, containers, state screens. Sidebar data stubbed, arrow labels are raw `<input>` stubs, `SPATIAL_PARITY_CHECKLIST.md` missing. Phase 9 pending.)
3+
> **Last updated:** 2026-05-31 (Phase 6 complete. 73/82 checklist items done (89%). Arrow source/target anchor positioning and fit-to-screen implemented. Phase 9 pending.)
44
> **This document replaces `docs/RESTART_PLAN.md`.** If a prior statement conflicts with this one, this version wins.
55
66
---
@@ -15,10 +15,10 @@
1515
| 3 | Collab wire parity — page-level Yjs doc | **Complete** | [phase-3-collab-wire.md](phase-3-collab-wire.md) |
1616
| 4 | SPA foundation + feature slice routing | **Complete** | [phase-4-spa-routing.md](phase-4-spa-routing.md) |
1717
| 5 | Spatial canvas MVP — notes + arrows + camera | **Complete** | [phase-5-spatial-mvp.md](phase-5-spatial-mvp.md) |
18-
| 6 | Spatial canvas polish | **In progress** | [phase-6-spatial-polish.md](phase-6-spatial-polish.md) |
18+
| 6 | Spatial canvas polish | **Complete** | [phase-6-spatial-polish.md](phase-6-spatial-polish.md) |
1919
| 7 | Account, billing, groups polish | **Complete** | [phase-7-account-polish.md](phase-7-account-polish.md) |
2020
| 8 | Marketing, Help, Pricing, and Legal Surfaces | **Complete** | [phase-8-marketing.md](phase-8-marketing.md) |
21-
| 9 | Production Readiness and Cutover | Not started | [phase-9-production.md](phase-9-production.md) |
21+
| 9 | Production Readiness and Cutover | In progress | [phase-9-production.md](phase-9-production.md) |
2222

2323
---
2424

docs/restart-plan/phase-6-spatial-polish.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Achieve parity with the legacy `/pages/:pageId` immersive spatial canvas experie
5151
| Item | Status | Notes |
5252
|------|--------|-------|
5353
| Fullscreen `PageEditorView.vue` shell | **Done** | `PageLayout.vue` replaces `DefaultLayout.vue` for `/pages/:pageId` via route meta |
54-
| `MainToolbar` (shadcn) | **Partial** | Standalone `MainToolbar.vue` extracted from `PageLayout.vue`. Still missing: page action buttons, insert dialogs, zoom other than reset, fit-to-screen |
54+
| `MainToolbar` (shadcn) | **Partial** | Standalone `MainToolbar.vue` extracted from `PageLayout.vue`. Still missing: page action buttons, insert dialogs, zoom other than reset/fit-to-screen |
5555
| `LeftSidebar` (shadcn) — Recent, Favorites, Selected, Current path | **Partial** | Resizable collapsible sidebar shell exists. `CurrentPath` and `CollabStatus` wired. `RecentPagesCard` and `FavoritePagesCard` now load real data via `useUserPageLists` composable. `SelectedPagesCard` remains client-side only |
5656
| `RightSidebar` (shadcn) — Note/Page/Arrow properties | **Partial** | `NotePropertiesCard`, `ArrowPropertiesCard`, `PagePropertiesCard` are wired and visible. Many legacy fields (wrap, anchor, z-index, timestamps) not exposed. Snapshots, management, backlinks exist |
5757
| `TableContextMenu` (shadcn) — right-click on canvas | **Partial** | `CanvasContextMenu.vue` exists for canvas background. No per-note context menu |
@@ -86,6 +86,7 @@ Achieve parity with the legacy `/pages/:pageId` immersive spatial canvas experie
8686
| Arrow label (editable `Y.XmlFragment`) | **Done** | `NoteTiptapEditor` at midpoint. Proper collaborative rich-text editing on `Y.XmlFragment` |
8787
| Hitbox (thick invisible stroke) | **Done** | `stroke="transparent" stroke-width="20"` pointer-events-auto hitbox |
8888
| Drag-to-reconnect | **Done** | Connection zones + `onReconnectPointerMove/Up` in `SpatialPageView.vue` wired |
89+
| Arrow source/target anchor positioning | **Done** | `DisplayArrow.vue` geometry now uses `sourceAnchor`/`targetAnchor` when provided |
8990
| Color matching note color logic | **Partial** | Same hardcoded 10-color map used, but `inherit` logic may not cascade correctly for arrows |
9091

9192
### 9. Find and replace

new-deepnotes/apps/api-worker/src/page-collab-room.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ export class PageCollabRoom {
2222
private readonly env: PageCollabRoomEnv,
2323
) {}
2424

25+
private log(level: "info" | "warn" | "error", event: string, data: Record<string, unknown> = {}) {
26+
console.log(JSON.stringify({
27+
level,
28+
event,
29+
pageId: this.pageIdStr,
30+
timestamp: Date.now(),
31+
...data,
32+
}));
33+
}
34+
2535
async fetch(request: Request): Promise<Response> {
2636
const url = new URL(request.url);
2737
const parts = url.pathname.split("/").filter(Boolean);
@@ -41,6 +51,7 @@ export class PageCollabRoom {
4151

4252
const userId = request.headers.get("X-Verified-User-Id");
4353
if (userId == null || userId === "") {
54+
this.log("warn", "ws_connection_rejected", { reason: "missing_user_id" });
4455
return new Response("Unauthorized", { status: 401 });
4556
}
4657

@@ -49,6 +60,7 @@ export class PageCollabRoom {
4960
const server = webSocketPair[1];
5061
server.serializeAttachment({ userId });
5162
this.ctx.acceptWebSocket(server);
63+
this.log("info", "ws_connection_accepted", { userId });
5264
return new Response(null, { status: 101, webSocket: client });
5365
}
5466

@@ -61,11 +73,13 @@ export class PageCollabRoom {
6173
}
6274
const attachment = ws.deserializeAttachment() as { userId: string } | null;
6375
if (attachment?.userId == null || attachment.userId === "") {
76+
this.log("warn", "ws_message_rejected", { reason: "missing_user_attachment" });
6477
return;
6578
}
6679
const buf = new Uint8Array(message);
6780
const decoded = decodeClientCollabBinaryMessage(buf);
6881
if (decoded == null) {
82+
this.log("warn", "ws_message_rejected", { reason: "decode_failed", userId: attachment.userId });
6983
return;
7084
}
7185

@@ -79,10 +93,12 @@ export class PageCollabRoom {
7993
const secret = this.env.COLLAB_INTERNAL_SECRET;
8094
const self = this.env.WORKER_SELF;
8195
if (secret == null || secret === "" || self == null) {
96+
this.log("error", "collab_misconfigured", { userId: attachment.userId });
8297
ws.close(1011, "Collab server misconfigured");
8398
return;
8499
}
85100

101+
const startTime = Date.now();
86102
const res = await self.fetch(
87103
new Request(
88104
`http://collab-internal/api/internal/pages/${this.pageIdStr}/collab-ws-append`,
@@ -99,15 +115,22 @@ export class PageCollabRoom {
99115
},
100116
),
101117
);
118+
const latency = Date.now() - startTime;
102119

103120
if (!res.ok) {
121+
this.log("error", "collab_append_failed", {
122+
userId: attachment.userId,
123+
status: res.status,
124+
latency,
125+
});
104126
return;
105127
}
106128

107129
let payload: unknown;
108130
try {
109131
payload = await res.json();
110132
} catch {
133+
this.log("error", "collab_parse_failed", { userId: attachment.userId, latency });
111134
return;
112135
}
113136
if (
@@ -116,10 +139,18 @@ export class PageCollabRoom {
116139
!("newIndex" in payload) ||
117140
typeof (payload as { newIndex: unknown }).newIndex !== "number"
118141
) {
142+
this.log("error", "collab_invalid_payload", { userId: attachment.userId, latency });
119143
return;
120144
}
121145
const dbIndex = (payload as { newIndex: number }).newIndex;
122146

147+
this.log("info", "collab_update_processed", {
148+
userId: attachment.userId,
149+
updateId: decoded.updateId,
150+
dbIndex,
151+
latency,
152+
});
153+
123154
const relay = encodeDocSingleUpdateFromServer(encryptedUpdate, dbIndex);
124155
this.broadcast(ws, relay);
125156
ws.send(

new-deepnotes/apps/api-worker/src/user-realtime-room.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,23 @@ export class UserRealtimeRoom {
5252
private readonly _fieldSubs = new Map<string, Set<WebSocket>>();
5353
/** WebSocket → keys it subscribed to (cleanup on close) */
5454
private readonly _subsByWs = new Map<WebSocket, Set<string>>();
55+
private userIdStr = "";
5556

5657
constructor(
5758
private readonly ctx: DurableObjectState,
5859
private readonly env: UserRealtimeRoomEnv,
5960
) {}
6061

62+
private log(level: "info" | "warn" | "error", event: string, data: Record<string, unknown> = {}) {
63+
console.log(JSON.stringify({
64+
level,
65+
event,
66+
userId: this.userIdStr,
67+
timestamp: Date.now(),
68+
...data,
69+
}));
70+
}
71+
6172
private getRedis(): Redis | null {
6273
if (this._redis !== undefined) {
6374
return this._redis;
@@ -327,14 +338,17 @@ export class UserRealtimeRoom {
327338

328339
const userId = request.headers.get("X-Verified-User-Id");
329340
if (userId == null || userId === "") {
341+
this.log("warn", "realtime_ws_connection_rejected", { reason: "missing_user_id" });
330342
return new Response("Unauthorized", { status: 401 });
331343
}
332344

345+
this.userIdStr = userId;
333346
const webSocketPair = new WebSocketPair();
334347
const client = webSocketPair[0];
335348
const server = webSocketPair[1];
336349
server.serializeAttachment({ userId });
337350
this.ctx.acceptWebSocket(server);
351+
this.log("info", "realtime_ws_connection_accepted", { userId });
338352
return new Response(null, { status: 101, webSocket: client });
339353
}
340354

@@ -348,15 +362,18 @@ export class UserRealtimeRoom {
348362
const attachment = ws.deserializeAttachment() as { userId?: string } | null;
349363
const userId = attachment?.userId;
350364
if (userId == null || userId === "") {
365+
this.log("warn", "realtime_ws_message_rejected", { reason: "missing_user_attachment" });
351366
return;
352367
}
353368

354369
const decoded = decodeRealtimeClientBinaryMessage(new Uint8Array(message));
355370
if (decoded == null) {
371+
this.log("warn", "realtime_ws_message_rejected", { reason: "decode_failed", userId });
356372
return;
357373
}
358374

359375
try {
376+
const startTime = Date.now();
360377
const hyper = this.env.HYPERDRIVE;
361378
const acl: RealtimeHashAclPort | null =
362379
hyper != null
@@ -385,6 +402,14 @@ export class UserRealtimeRoom {
385402
},
386403
});
387404

405+
const latency = Date.now() - startTime;
406+
this.log("info", "realtime_ws_batch_processed", {
407+
userId,
408+
latency,
409+
responseSize: out.responseBytes?.length ?? 0,
410+
hsetBroadcastCount: out.hsetBroadcastItems.length,
411+
});
412+
388413
if (out.responseBytes != null) {
389414
ws.send(out.responseBytes);
390415
}
@@ -418,8 +443,8 @@ export class UserRealtimeRoom {
418443
}
419444
}
420445
}
421-
} catch {
422-
// ignore malformed/partial Redis failures
446+
} catch (e) {
447+
this.log("error", "realtime_ws_batch_failed", { userId, error: String(e) });
423448
}
424449
}
425450

new-deepnotes/apps/web/src/features/spatial/DisplayArrow.vue

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,30 @@ const geometry = computed(() => {
4646
const t = props.targetModel;
4747
if (!s || !t) return null;
4848
49-
// Note centers (legacy uses note center for arrow endpoints)
49+
// Use anchor positions if provided, otherwise use note centers
5050
const w1 = s.width.value.expanded;
5151
const nw1 = w1 === "Auto" ? 160 : parseFloat(w1);
5252
const h1 = noteHeights.value.get(props.model.source.value) ?? 80;
53-
const x1 = s.pos.value.x + nw1 / 2;
54-
const y1 = s.pos.value.y + h1 / 2;
53+
54+
const sourceAnchor = props.model.sourceAnchor.value;
55+
const x1 = sourceAnchor
56+
? s.pos.value.x + sourceAnchor.x
57+
: s.pos.value.x + nw1 / 2;
58+
const y1 = sourceAnchor
59+
? s.pos.value.y + sourceAnchor.y
60+
: s.pos.value.y + h1 / 2;
5561
5662
const w2 = t.width.value.expanded;
5763
const nw2 = w2 === "Auto" ? 160 : parseFloat(w2);
5864
const h2 = noteHeights.value.get(props.model.target.value) ?? 80;
59-
const x2 = t.pos.value.x + nw2 / 2;
60-
const y2 = t.pos.value.y + h2 / 2;
65+
66+
const targetAnchor = props.model.targetAnchor.value;
67+
const x2 = targetAnchor
68+
? t.pos.value.x + targetAnchor.x
69+
: t.pos.value.x + nw2 / 2;
70+
const y2 = targetAnchor
71+
? t.pos.value.y + targetAnchor.y
72+
: t.pos.value.y + h2 / 2;
6173
6274
const minX = Math.min(x1, x2);
6375
const minY = Math.min(y1, y2);

new-deepnotes/apps/web/src/features/spatial/SpatialPageView.vue

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
33
44
import { Button } from "@/components/ui/button";
5-
import { Undo, Redo, RotateCcw, Search } from "lucide-vue-next";
5+
import { Undo, Redo, RotateCcw, Search, Maximize } from "lucide-vue-next";
66
77
import SpatialWorldCanvas from "./SpatialWorldCanvas.vue";
88
import DisplayNote from "./DisplayNote.vue";
@@ -46,6 +46,7 @@ const canvasRef = ref<{
4646
zoom: number;
4747
rootEl: HTMLElement | null;
4848
resetView: () => void;
49+
fitToScreen: (bounds: { minX: number; minY: number; maxX: number; maxY: number }, padding?: number) => void;
4950
} | null>(null);
5051
5152
const undoRedo = useSpatialUndoRedo(props.ydoc);
@@ -192,6 +193,37 @@ function onCanvasDoubleClick(e: MouseEvent) {
192193
createNoteAt(world.x, world.y, props.defaultNoteTemplate);
193194
}
194195
196+
function fitToScreen() {
197+
const canvas = canvasRef.value;
198+
if (!canvas) return;
199+
200+
// Calculate bounding box of all root notes
201+
if (rootNoteList.value.length === 0) {
202+
canvas.resetView();
203+
return;
204+
}
205+
206+
let minX = Infinity;
207+
let minY = Infinity;
208+
let maxX = -Infinity;
209+
let maxY = -Infinity;
210+
211+
for (const note of rootNoteList.value) {
212+
const wStr = note.model.width.value.expanded;
213+
const w = wStr === "Auto" ? 160 : parseFloat(wStr);
214+
const h = 80; // Default height estimate
215+
const x = note.model.pos.value.x;
216+
const y = note.model.pos.value.y;
217+
218+
minX = Math.min(minX, x);
219+
minY = Math.min(minY, y);
220+
maxX = Math.max(maxX, x + w);
221+
maxY = Math.max(maxY, y + h);
222+
}
223+
224+
canvas.fitToScreen({ minX, minY, maxX, maxY }, 40);
225+
}
226+
195227
// --- box selection state ---
196228
const DRAG_THRESHOLD = 4;
197229
let boxState: {
@@ -932,6 +964,17 @@ onUnmounted(() => {
932964
<RotateCcw class="h-4 w-4" />
933965
</Button>
934966

967+
<!-- Fit to screen -->
968+
<Button
969+
variant="secondary"
970+
size="icon"
971+
class="h-8 w-8 shadow-sm"
972+
title="Fit to screen"
973+
@click="fitToScreen"
974+
>
975+
<Maximize class="h-4 w-4" />
976+
</Button>
977+
935978
<!-- Find/Replace -->
936979
<Button
937980
variant="secondary"

new-deepnotes/apps/web/src/features/spatial/SpatialWorldCanvas.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ const {
1414
onPointerMove,
1515
onPointerUp,
1616
resetView,
17+
fitToScreen,
1718
} = useSpatialViewport(rootRef);
1819
1920
defineExpose({
2021
resetView,
22+
fitToScreen,
2123
camX,
2224
camY,
2325
zoom,

0 commit comments

Comments
 (0)