Skip to content

Commit 671cb00

Browse files
committed
feat: improve phase 6
1 parent b4146ec commit 671cb00

9 files changed

Lines changed: 192 additions & 7 deletions

File tree

docs/RESTART_PLAN.md

Lines changed: 2 additions & 2 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 SPA routing + lint complete.** **Phase 5 spatial canvas MVP core interactions complete (create, move, resize, delete notes; Shift+click arrows; mouse + touch pan/zoom).** **Phase 6 in progress: selection system complete (click, multi-select, box select, select all, active element); arrow click-to-select + deletion; note color + z-index rendering; grid background verified.**
4+
> **Status:** Phase 0 foundation complete. Phase 1 spatial checklist complete. **Phase 2 backend parity verified.** **Phase 3 collab wire parity complete.** **Phase 4 SPA routing + lint complete.** **Phase 5 spatial canvas MVP core interactions complete (create, move, resize, delete notes; Shift+click arrows; mouse + touch pan/zoom).** **Phase 6 in progress: selection system complete (click, multi-select, box select, select all, active element); arrow click-to-select + deletion; note color + z-index rendering; grid background verified; container children schema, model, page tracking, and rendering complete; drag-into-container pending.**
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/*` | **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. |
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, container schema/model/rendering complete; drag-into-container 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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from "vitest";
2+
import { mount } from "@vue/test-utils";
3+
import * as Y from "yjs";
4+
5+
import DisplayNote from "./DisplayNote.vue";
6+
import { useNoteModel } from "./note-model";
7+
import { addNoteToPage, createPageYDoc } from "@deepnotes/collab-wire";
8+
9+
function createNoteModel(ydoc: Y.Doc, id: string, opts?: { containerEnabled?: boolean }) {
10+
const noteMap = addNoteToPage(ydoc, id);
11+
if (opts?.containerEnabled) {
12+
const containerMap = noteMap.get("container") as Y.Map<unknown>;
13+
containerMap.set("enabled", true);
14+
}
15+
return useNoteModel(noteMap);
16+
}
17+
18+
describe("DisplayNote", () => {
19+
it("renders a single note without children", () => {
20+
const ydoc = createPageYDoc();
21+
const model = createNoteModel(ydoc, "note-1");
22+
23+
const wrapper = mount(DisplayNote, {
24+
props: { model, zoom: 1 },
25+
});
26+
27+
expect(wrapper.findAll('[data-testid="display-note"]')).toHaveLength(1);
28+
});
29+
30+
it("renders child notes when container is enabled", () => {
31+
const ydoc = createPageYDoc();
32+
const parentModel = createNoteModel(ydoc, "parent", { containerEnabled: true });
33+
const childModel = createNoteModel(ydoc, "child-1");
34+
35+
const wrapper = mount(DisplayNote, {
36+
props: { model: parentModel, zoom: 1, childModels: [childModel] },
37+
});
38+
39+
const notes = wrapper.findAll('[data-testid="display-note"]');
40+
expect(notes).toHaveLength(2);
41+
});
42+
43+
it("does not render children when container is disabled", () => {
44+
const ydoc = createPageYDoc();
45+
const parentModel = createNoteModel(ydoc, "parent", { containerEnabled: false });
46+
const childModel = createNoteModel(ydoc, "child-1");
47+
48+
const wrapper = mount(DisplayNote, {
49+
props: { model: parentModel, zoom: 1, childModels: [childModel] },
50+
});
51+
52+
expect(wrapper.findAll('[data-testid="display-note"]')).toHaveLength(1);
53+
});
54+
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const props = defineProps<{
66
model: NoteModel;
77
zoom: number;
88
selected?: boolean;
9+
childModels?: NoteModel[];
910
}>();
1011
1112
const emit = defineEmits<{
@@ -192,5 +193,20 @@ function onResizePointerUp(e: PointerEvent) {
192193
@pointerup="onResizePointerUp"
193194
@pointercancel="onResizePointerUp"
194195
/>
196+
197+
<!-- container children -->
198+
<template v-if="model.container.enabled.value && childModels?.length">
199+
<div
200+
class="border-border pointer-events-none absolute inset-x-0 bottom-0 border-t"
201+
style="top: 3rem"
202+
>
203+
<DisplayNote
204+
v-for="(child, idx) in childModels"
205+
:key="idx"
206+
:model="child"
207+
:zoom="zoom"
208+
/>
209+
</div>
210+
</template>
195211
</div>
196212
</template>

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ const canvasRef = ref<{
1919
rootEl: HTMLElement | null;
2020
} | null>(null);
2121
22-
const { noteList, arrowList, createNoteAt, deleteNote, deleteArrow, createArrow } =
23-
useSpatialPage(props.ydoc);
22+
const {
23+
noteList,
24+
rootNoteList,
25+
arrowList,
26+
createNoteAt,
27+
deleteNote,
28+
deleteArrow,
29+
createArrow,
30+
} = useSpatialPage(props.ydoc);
2431
2532
const selection = useSpatialSelection();
2633
@@ -33,7 +40,7 @@ const noteById = computed(() => {
3340
});
3441
3542
const notesByZIndex = computed(() => {
36-
return [...noteList.value].sort(
43+
return [...rootNoteList.value].sort(
3744
(a, b) => a.model.zIndex.value - b.model.zIndex.value,
3845
);
3946
});
@@ -149,7 +156,7 @@ function finalizeBoxSelect() {
149156
const boxW = Math.max(w1.x, w2.x) - boxX;
150157
const boxH = Math.max(w1.y, w2.y) - boxY;
151158
152-
for (const note of noteList.value) {
159+
for (const note of rootNoteList.value) {
153160
const nx = note.model.pos.value.x;
154161
const ny = note.model.pos.value.y;
155162
const nwStr = note.model.width.value.expanded;
@@ -190,7 +197,7 @@ function onKeyDown(e: KeyboardEvent) {
190197
191198
if (e.key === "a" && (e.ctrlKey || e.metaKey)) {
192199
e.preventDefault();
193-
selection.selectAll(noteList.value.map((n) => n.id));
200+
selection.selectAll(rootNoteList.value.map((n) => n.id));
194201
return;
195202
}
196203
}
@@ -236,6 +243,11 @@ onUnmounted(() => {
236243
:model="note.model"
237244
:zoom="canvasRef?.zoom ?? 1"
238245
:selected="selection.isSelected(note.id)"
246+
:child-models="
247+
note.model.container.children.value
248+
.map((childId) => noteById.get(childId))
249+
.filter((m): m is NonNullable<typeof m> => m !== undefined)
250+
"
239251
@select="selection.select(note.id, 'note')"
240252
@toggle="selection.toggle(note.id, 'note')"
241253
@shift-click="

new-deepnotes/apps/web/src/features/spatial/note-model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useYMapNumber,
88
useYMapString,
99
useYMapValue,
10+
useYArrayValues,
1011
} from "./yjs-reactivity";
1112

1213
export type NoteModel = ReturnType<typeof useNoteModel>;
@@ -78,6 +79,8 @@ export function useNoteModel(noteMap: Y.Map<unknown>) {
7879
"forceColorInheritance",
7980
false,
8081
);
82+
const containerChildrenArr = containerMap.get("children") as Y.Array<string>;
83+
const containerChildren = useYArrayValues<string>(containerChildrenArr);
8184

8285
// --- collapsing ---
8386
const collapsingMap = noteMap.get(
@@ -134,6 +137,7 @@ export function useNoteModel(noteMap: Y.Map<unknown>) {
134137
wrapChildren: containerWrapChildren,
135138
stretchChildren: containerStretchChildren,
136139
forceColorInheritance: containerForceColorInheritance,
140+
children: containerChildren,
137141
},
138142
collapsing: {
139143
enabled: collapsingEnabled,

new-deepnotes/apps/web/src/features/spatial/useSpatialPage.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,44 @@ describe("useSpatialPage", () => {
6767
posMap.set("x", 99);
6868
expect(model.pos.value).toEqual({ x: 99, y: 20 });
6969
});
70+
71+
it("adds a child to a container", () => {
72+
const ydoc = createPageYDoc();
73+
const page = useSpatialPage(ydoc);
74+
75+
const containerId = page.createNoteAt(0, 0);
76+
const childId = page.createNoteAt(50, 50);
77+
78+
page.addChildToContainer(containerId, childId);
79+
expect(page.parentOf.value.get(childId)).toBe(containerId);
80+
expect(page.rootNoteList.value.map((n) => n.id)).not.toContain(childId);
81+
});
82+
83+
it("removes a child from a container", () => {
84+
const ydoc = createPageYDoc();
85+
const page = useSpatialPage(ydoc);
86+
87+
const containerId = page.createNoteAt(0, 0);
88+
const childId = page.createNoteAt(50, 50);
89+
90+
page.addChildToContainer(containerId, childId);
91+
page.removeChildFromContainer(containerId, childId);
92+
93+
expect(page.parentOf.value.has(childId)).toBe(false);
94+
expect(page.rootNoteList.value.map((n) => n.id)).toContain(childId);
95+
});
96+
97+
it("deletes container children recursively", () => {
98+
const ydoc = createPageYDoc();
99+
const page = useSpatialPage(ydoc);
100+
101+
const containerId = page.createNoteAt(0, 0);
102+
const childId = page.createNoteAt(50, 50);
103+
104+
page.addChildToContainer(containerId, childId);
105+
page.deleteNote(containerId);
106+
107+
expect(page.noteList.value.map((n) => n.id)).not.toContain(containerId);
108+
expect(page.noteList.value.map((n) => n.id)).not.toContain(childId);
109+
});
70110
});

new-deepnotes/apps/web/src/features/spatial/useSpatialPage.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,59 @@ export function useSpatialPage(ydoc: Y.Doc) {
9292
return id;
9393
}
9494

95+
// --- container parent tracking ---
96+
const parentOf = computed(() => {
97+
const map = new Map<string, string>();
98+
for (const { id, model } of noteList.value) {
99+
for (const childId of model.container.children.value) {
100+
map.set(childId, id);
101+
}
102+
}
103+
return map;
104+
});
105+
106+
const rootNoteList = computed(() =>
107+
noteList.value.filter((n) => !parentOf.value.has(n.id)),
108+
);
109+
110+
function addChildToContainer(containerId: string, childId: string) {
111+
const containerNote = notesMap.get(containerId);
112+
if (!containerNote) return;
113+
const containerMap = containerNote.get(
114+
YPAGE_NOTE_KEY.container,
115+
) as Y.Map<unknown>;
116+
const childrenArr = containerMap.get("children") as Y.Array<string>;
117+
if (!childrenArr.toArray().includes(childId)) {
118+
childrenArr.push([childId]);
119+
}
120+
}
121+
122+
function removeChildFromContainer(containerId: string, childId: string) {
123+
const containerNote = notesMap.get(containerId);
124+
if (!containerNote) return;
125+
const containerMap = containerNote.get(
126+
YPAGE_NOTE_KEY.container,
127+
) as Y.Map<unknown>;
128+
const childrenArr = containerMap.get("children") as Y.Array<string>;
129+
const idx = childrenArr.toArray().indexOf(childId);
130+
if (idx >= 0) {
131+
childrenArr.delete(idx, 1);
132+
}
133+
}
134+
95135
function deleteNote(noteId: string) {
136+
// Remove from parent container if nested
137+
const parentId = parentOf.value.get(noteId);
138+
if (parentId) {
139+
removeChildFromContainer(parentId, noteId);
140+
}
141+
// Delete children recursively
142+
const model = noteModels.get(noteId);
143+
if (model) {
144+
for (const childId of [...model.container.children.value]) {
145+
deleteNote(childId);
146+
}
147+
}
96148
removeNoteFromPage(ydoc, noteId);
97149
noteModels.delete(noteId);
98150
refreshNoteIds();
@@ -118,10 +170,14 @@ export function useSpatialPage(ydoc: Y.Doc) {
118170
return {
119171
noteIds,
120172
noteList,
173+
rootNoteList,
121174
arrowList,
175+
parentOf,
122176
createNoteAt,
123177
deleteNote,
124178
deleteArrow,
125179
createArrow,
180+
addChildToContainer,
181+
removeChildFromContainer,
126182
};
127183
}

new-deepnotes/packages/collab-wire/src/page-doc-schema.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe("page-doc-schema", () => {
6969
expect(container.get("wrapChildren")).toBe(false);
7070
expect(container.get("stretchChildren")).toBe(true);
7171
expect(container.get("forceColorInheritance")).toBe(false);
72+
expect(container.get("children")).toBeInstanceOf(Y.Array);
7273

7374
const collapsing = note.get(YPAGE_NOTE_KEY.collapsing) as Y.Map<boolean>;
7475
expect(collapsing.get("enabled")).toBe(false);

new-deepnotes/packages/collab-wire/src/page-doc-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const YPAGE_NOTE_KEY = {
4343
createdAt: "createdAt",
4444
editedAt: "editedAt",
4545
movedAt: "movedAt",
46+
containerChildren: "containerChildren",
4647
} as const;
4748

4849
export const YPAGE_ARROW_KEY = {
@@ -113,6 +114,7 @@ function createDefaultContainer(): Y.Map<unknown> {
113114
m.set("wrapChildren", false);
114115
m.set("stretchChildren", true);
115116
m.set("forceColorInheritance", false);
117+
m.set("children", new Y.Array<string>());
116118
return m;
117119
}
118120

0 commit comments

Comments
 (0)