Skip to content

Commit 58a75bb

Browse files
committed
feat: develop phase 5
1 parent a2f37cb commit 58a75bb

18 files changed

Lines changed: 1028 additions & 57 deletions

docs/RESTART_PLAN.md

Lines changed: 51 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# DeepNotes — Restart (greenfield) plan — v4
22

33
> **Last updated:** 2026-05-30
4-
> **Status:** Phase 0 foundation complete. Phase 1 spatial checklist complete. **Phase 2 backend parity verified.** **Phase 3 collab wire parity complete.** Phase 4 routing decision complete.
4+
> **Status:** Phase 0 foundation complete. Phase 1 spatial checklist complete. **Phase 2 backend parity verified.** **Phase 3 collab wire parity complete.** Phase 4 routing decision complete. **Phase 5 spatial canvas MVP in progress (notes + arrows + camera + drag-to-move wired to PageEditorView.vue).**
55
> **This document replaces all prior restart plan versions.** If a prior statement conflicts with this one, this version wins.
66
> **Analyzed:** 2026-05-30 — additional gaps identified in §0.2–0.4, §3, §4, §6–8. Collab protocol gap and routing/product-model divergence newly documented.
77
@@ -25,7 +25,7 @@
2525
| **Page management UI** | `apps/web/src/features/pages/*` | Partial | Bump, favorite, recent, snapshots, soft-delete, restore, purge, move, path breadcrumb. |
2626
| **Rich-text editor** | `apps/web/src/features/pages/PageEditorTiptapCard.vue` | Partial | Tiptap + Yjs, tables, images, tasks, code, math, YouTube, collab carets. |
2727
| **Marketing site** | `apps/marketing/` | Done | `vite-ssg` placeholder. |
28-
| **Spatial / world canvas** | `apps/web/src/features/spatial/*` | **Stub only** | `SpatialWorldStubView` shows page pins on a pan/zoom canvas. **No interactive notes, arrows, or containers.** |
28+
| **Spatial / world canvas** | `apps/web/src/features/spatial/*` | **In progress** | Drag-to-move notes, double-click create, arrows rendered, page-level Yjs doc wired. Tiptap editors inside notes, resize, arrow creation UI, containers pending. |
2929
| **Collab pagination** | `GET /api/pages/:pid/collab-updates` | Done | `?sinceIndex=` + `?limit=` (default 100, max 500). Client loops. |
3030
| **Playwright E2E** | `apps/web/playwright.config.ts` | Skeleton | Config + smoke test created; needs `pnpm install` + `playwright install`. |
3131

@@ -562,55 +562,48 @@ The new `usePageCollabEditor` only syncs a ProseMirror `Y.XmlFragment`. We need
562562

563563
**Deliverables:**
564564

565-
1. **Camera / viewport (`features/spatial/camera.ts`)**
566-
- `SpatialWorldCanvas.vue` becomes the actual page editor background.
565+
1. **Camera / viewport (`features/spatial/camera.ts`)**
566+
- `SpatialWorldCanvas.vue` is the page editor background.
567567
- Pan: wheel, space+drag, middle-drag.
568568
- Zoom: ctrl/cmd+wheel toward cursor.
569-
- Pinch: touch pinch-to-zoom.
570-
- Fit-to-screen: button that centers on all notes.
571-
572-
2. **Note model (`features/spatial/note-model.ts`)**
573-
- Class or composable representing a note on the page.
574-
- Properties: `id`, `pos: Vec2`, `width`, `head: { enabled, height, value: Y.XmlFragment }`, `body: { enabled, height, value: Y.XmlFragment }`, `container: { enabled, spatial, horizontal, children }`, `color`, `zIndex`, `collapsing`, `movable`, `resizable`.
575-
- Must read from / write to the page Yjs doc.
576-
577-
3. **Note rendering (`features/spatial/DisplayNote.vue`)**
578-
- Render note frame at `(note.pos.x, note.pos.y)`.
579-
- Head section: Tiptap editor (using existing Tiptap extensions) bound to `note.head.value`.
580-
- Body section: Tiptap editor bound to `note.body.value`.
581-
- Container section: renders child notes inside (if `container.enabled`).
582-
- Resize handles (8 corners/sides).
583-
- Drag handle on note frame.
584-
585-
4. **Arrow model (`features/spatial/arrow-model.ts`)**
586-
- Properties: `id`, `source`, `target`, `sourceAnchor`, `targetAnchor`, `bodyType`, `bodyStyle`, `sourceHead`, `targetHead`, `label: Y.XmlFragment`, `color`.
587-
588-
5. **Arrow rendering (`features/spatial/DisplayArrow.vue`)**
589-
- SVG overlay on top of notes.
590-
- Curve or line body between source and target note edges.
591-
- Arrow heads at source/target.
592-
- Label near midpoint.
593-
594-
6. **Basic interaction**
595-
- Click to select a note.
596-
- Drag to move a note.
597-
- Drag resize handles to resize.
598-
- Create note: double-click on empty canvas (or button).
599-
- Create arrow: drag from note edge handle to another note.
600-
- Delete: `Delete` key when note selected.
601-
602-
7. **Collab for spatial state**
603-
- When a note is moved, the position update syncs via collab WS within 200 ms.
604-
- When an arrow is created, it appears on remote clients within 1 second.
605-
- Remote cursor awareness shows which user is editing which note.
606-
607-
8. **DOM / world coordinate system**
608-
- Replicate legacy `space/pos.ts`, `space/rects.ts`, `space/sizes.ts` behavior:
609-
- `clientToWorld`, `worldToClient`, `screenToWorld`, `worldToScreen` transforms.
610-
- `getContainerWorldRect`, `getOriginWorldPos` for nested regions (containers).
611-
- Required for accurate drag, resize, arrow anchor placement, and fit-to-screen.
612-
613-
9. **Default note / arrow templates**
569+
- Pinch: touch pinch-to-zoom. (structure ready, needs mobile testing)
570+
- Fit-to-screen: pending.
571+
572+
2. **Note model (`features/spatial/note-model.ts`)**
573+
- Composable `useNoteModel` with all legacy properties: `pos`, `width`, `head`, `body`, `container`, `color`, `zIndex`, `collapsing`, `movable`, `resizable`, `anchor`, timestamps.
574+
- Reads/writes to the page Yjs doc via hybrid reactive proxy.
575+
- Tested in `note-model.test.ts`.
576+
577+
3. **Note rendering (`features/spatial/DisplayNote.vue`)** ✅ (partial)
578+
- Render note frame at `(note.pos.x, note.pos.y)` — done.
579+
- Drag to move — done (zoom-aware pointer capture).
580+
- Head/body Tiptap editor — pending (requires inline editor component).
581+
- Container section with child notes — pending.
582+
- Resize handles — pending.
583+
584+
4. **Arrow model (`features/spatial/arrow-model.ts`)**
585+
- Composable `useArrowModel` with all legacy properties.
586+
- Tested in `arrow-model.test.ts`.
587+
588+
5. **Arrow rendering (`features/spatial/DisplayArrow.vue`)** ✅ (partial)
589+
- SVG line between source and target note centers — done.
590+
- Curve/line body styles, arrow heads, label — pending.
591+
592+
6. **Basic interaction** ✅ (partial)
593+
- Drag to move a note — done.
594+
- Create note via double-click on empty canvas — done.
595+
- Click to select, resize handles, create arrow via drag, Delete key — pending.
596+
597+
7. **Collab for spatial state** ✅ (partial)
598+
- `ydoc.on('updateV2')` listener in `usePageEditor.ts` schedules push for non-Tiptap mutations — done in Phase 0.
599+
- Position updates trigger collab push via Yjs diff — done.
600+
- Full two-client integration test — pending.
601+
602+
8. **DOM / world coordinate system** ✅ (partial)
603+
- `screenToWorld`, `worldToScreen`, `wheelZoomCameraTowardScreenPoint`, `panCameraByScreenDelta` — done in `spatial-viewport-math.ts`.
604+
- `getContainerWorldRect`, `getOriginWorldPos` for containers — pending.
605+
606+
9. **Default note / arrow templates**
614607
- On creation, new notes must use the user's `encrypted_default_note` column (decrypted via session keyrings).
615608
- New arrows must use `encrypted_default_arrow`.
616609
- These set default colors, widths, head/body enabled states, and arrow styles.
@@ -623,10 +616,15 @@ The new `usePageCollabEditor` only syncs a ProseMirror `Y.XmlFragment`. We need
623616
- Integration test: two tabs, create note in A, assert note appears in B within 2 seconds.
624617

625618
**Exit criteria:**
626-
- [ ] User can create, move, resize, and delete notes on an infinite canvas.
627-
- [ ] User can create arrows between notes.
628-
- [ ] Canvas pan/zoom works with mouse and touch.
629-
- [ ] Changes sync across tabs via collab WS.
619+
- [x] User can create notes on an infinite canvas.
620+
- [x] User can drag to move notes.
621+
- [ ] User can resize notes.
622+
- [ ] User can delete notes.
623+
- [x] Arrows render between notes (source/target positions tracked).
624+
- [ ] User can create arrows between notes via UI drag interaction.
625+
- [x] Canvas pan/zoom works with mouse.
626+
- [ ] Canvas pan/zoom works with touch (pinch).
627+
- [x] Changes sync across tabs via collab WS (page-level Yjs doc + `updateV2` listener).
630628
- [ ] Phase 1 checklist rows for "Notes (basic)" and "Arrows (basic)" are marked done.
631629

632630
---

new-deepnotes/apps/web/src/features/pages/PageEditorView.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { RouterLink, useRoute, useRouter } from "vue-router";
55
import { Button } from "@/components/ui/button";
66
77
import { useSession } from "../auth/useSession";
8+
import SpatialPageView from "../spatial/SpatialPageView.vue";
89
import PageEditorCollabStatusCard from "./PageEditorCollabStatusCard.vue";
910
import PageEditorManagementCard from "./PageEditorManagementCard.vue";
1011
import PageEditorPathCard from "./PageEditorPathCard.vue";
@@ -221,6 +222,8 @@ onMounted(() => {
221222
@purge="management.purgeThisPagePermanently()"
222223
/>
223224

225+
<SpatialPageView :ydoc="ydoc" />
226+
224227
<PageEditorCollabStatusCard
225228
:collab-loading="collabLoading"
226229
:load-error="loadError"

new-deepnotes/apps/web/src/features/pages/page-yjs-doc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import * as Y from "yjs";
21
import { Awareness } from "y-protocols/awareness";
2+
import { createPageYDoc } from "@deepnotes/collab-wire";
33

44
export function createPageCollabDoc() {
5-
const ydoc = new Y.Doc();
5+
const ydoc = createPageYDoc();
66
const collabAwareness = new Awareness(ydoc);
77
const collabCaretProvider = { awareness: collabAwareness };
88

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import type { ArrowModel } from "./arrow-model";
4+
import type { NoteModel } from "./note-model";
5+
6+
const props = defineProps<{
7+
model: ArrowModel;
8+
sourceModel?: NoteModel;
9+
targetModel?: NoteModel;
10+
}>();
11+
12+
const line = computed(() => {
13+
const s = props.sourceModel;
14+
const t = props.targetModel;
15+
if (!s || !t) return null;
16+
const x1 = s.pos.value.x;
17+
const y1 = s.pos.value.y;
18+
const x2 = t.pos.value.x;
19+
const y2 = t.pos.value.y;
20+
return { x1, y1, x2, y2 };
21+
});
22+
</script>
23+
24+
<template>
25+
<svg
26+
v-if="line"
27+
data-testid="display-arrow"
28+
class="pointer-events-none absolute top-0 left-0 overflow-visible"
29+
:style="{
30+
width: '1px',
31+
height: '1px',
32+
transform: `translate(${Math.min(line.x1, line.x2)}px, ${Math.min(line.y1, line.y2)}px)`,
33+
}"
34+
>
35+
<line
36+
:x1="line.x1 - Math.min(line.x1, line.x2)"
37+
:y1="line.y1 - Math.min(line.y1, line.y2)"
38+
:x2="line.x2 - Math.min(line.x1, line.x2)"
39+
:y2="line.y2 - Math.min(line.y1, line.y2)"
40+
stroke="currentColor"
41+
stroke-width="2"
42+
/>
43+
</svg>
44+
</template>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from "vue";
3+
import type { NoteModel } from "./note-model";
4+
5+
const props = defineProps<{
6+
model: NoteModel;
7+
zoom: number;
8+
}>();
9+
10+
const transform = computed(() => {
11+
const { x, y } = props.model.pos.value;
12+
return {
13+
transform: `translate(${x}px, ${y}px)`,
14+
};
15+
});
16+
17+
const frameClasses = computed(() => {
18+
const ro = props.model.readOnly.value;
19+
const movable = props.model.movable.value;
20+
return [
21+
"border-border bg-card text-card-foreground pointer-events-auto absolute top-0 left-0 rounded-md border shadow-sm select-none",
22+
ro ? "opacity-70" : "",
23+
movable ? "cursor-grab active:cursor-grabbing" : "",
24+
];
25+
});
26+
27+
let dragPointerId: number | null = null;
28+
let startX = 0;
29+
let startY = 0;
30+
let noteStartX = 0;
31+
let noteStartY = 0;
32+
33+
function onPointerDown(e: PointerEvent) {
34+
if (!props.model.movable.value || e.button !== 0) return;
35+
e.stopPropagation();
36+
dragPointerId = e.pointerId;
37+
startX = e.clientX;
38+
startY = e.clientY;
39+
noteStartX = props.model.pos.value.x;
40+
noteStartY = props.model.pos.value.y;
41+
const el = e.currentTarget as HTMLElement;
42+
el.setPointerCapture(e.pointerId);
43+
el.style.cursor = "grabbing";
44+
}
45+
46+
function onPointerMove(e: PointerEvent) {
47+
if (dragPointerId !== e.pointerId) return;
48+
const dxScreen = e.clientX - startX;
49+
const dyScreen = e.clientY - startY;
50+
const z = props.zoom || 1;
51+
const noteMap = props.model.rawMap;
52+
const posMap = noteMap.get("pos") as import("yjs").Map<number>;
53+
posMap.set("x", noteStartX + dxScreen / z);
54+
posMap.set("y", noteStartY + dyScreen / z);
55+
}
56+
57+
function onPointerUp(e: PointerEvent) {
58+
if (dragPointerId !== e.pointerId) return;
59+
dragPointerId = null;
60+
const el = e.currentTarget as HTMLElement;
61+
if (el.releasePointerCapture) {
62+
try {
63+
el.releasePointerCapture(e.pointerId);
64+
} catch {
65+
/* ignore */
66+
}
67+
}
68+
el.style.cursor = props.model.movable.value ? "grab" : "";
69+
}
70+
</script>
71+
72+
<template>
73+
<div
74+
data-testid="display-note"
75+
:class="frameClasses"
76+
:style="transform"
77+
@pointerdown="onPointerDown"
78+
@pointermove="onPointerMove"
79+
@pointerup="onPointerUp"
80+
@pointercancel="onPointerUp"
81+
>
82+
<div class="border-border border-b px-2 py-1 text-xs font-medium">
83+
{{ model.head.enabled.value ? "Head" : "" }}
84+
<span
85+
v-if="model.body.enabled.value"
86+
class="text-muted-foreground"
87+
>
88+
/ Body
89+
</span>
90+
</div>
91+
<div class="px-2 py-1">
92+
<p class="text-muted-foreground text-xs">
93+
pos: {{ model.pos.value.x.toFixed(0) }},{{ model.pos.value.y.toFixed(0) }} · z:
94+
{{ model.zIndex.value }}
95+
</p>
96+
</div>
97+
</div>
98+
</template>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from "vue";
3+
4+
import SpatialWorldCanvas from "./SpatialWorldCanvas.vue";
5+
import DisplayNote from "./DisplayNote.vue";
6+
import DisplayArrow from "./DisplayArrow.vue";
7+
import { useSpatialPage } from "./useSpatialPage";
8+
import { screenToWorld } from "./spatial-viewport-math";
9+
10+
const props = defineProps<{
11+
ydoc: any;
12+
}>();
13+
14+
const canvasRef = ref<{ camX: number; camY: number; zoom: number } | null>(null);
15+
16+
const { noteList, arrowList, createNoteAt } = useSpatialPage(props.ydoc);
17+
18+
const noteById = computed(() => {
19+
const map = new Map<string, (typeof noteList.value)[0]["model"]>();
20+
for (const n of noteList.value) {
21+
map.set(n.id, n.model);
22+
}
23+
return map;
24+
});
25+
26+
function onCanvasDoubleClick(e: MouseEvent) {
27+
const canvas = canvasRef.value;
28+
if (!canvas) return;
29+
30+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
31+
const cx = rect.left + rect.width / 2;
32+
const cy = rect.top + rect.height / 2;
33+
34+
const world = screenToWorld(
35+
e.clientX,
36+
e.clientY,
37+
cx,
38+
cy,
39+
canvas.camX,
40+
canvas.camY,
41+
canvas.zoom,
42+
);
43+
44+
createNoteAt(world.x, world.y);
45+
}
46+
</script>
47+
48+
<template>
49+
<div class="space-y-2">
50+
<div class="text-muted-foreground text-xs">
51+
Double-click on the canvas to create a note. Scroll to pan, Ctrl+scroll to
52+
zoom.
53+
</div>
54+
<SpatialWorldCanvas ref="canvasRef" @dblclick="onCanvasDoubleClick">
55+
<DisplayArrow
56+
v-for="arrow in arrowList"
57+
:key="arrow.id"
58+
:model="arrow.model"
59+
:source-model="noteById.get(arrow.model.source.value)"
60+
:target-model="noteById.get(arrow.model.target.value)"
61+
/>
62+
<DisplayNote
63+
v-for="note in noteList"
64+
:key="note.id"
65+
:model="note.model"
66+
:zoom="canvasRef?.zoom ?? 1"
67+
/>
68+
</SpatialWorldCanvas>
69+
</div>
70+
</template>

0 commit comments

Comments
 (0)