Skip to content

Commit 9b1ea11

Browse files
committed
feat: improve phase 6
1 parent 1a9729e commit 9b1ea11

13 files changed

Lines changed: 1254 additions & 32 deletions

docs/RESTART_PLAN.md

Lines changed: 1 addition & 1 deletion
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; container children schema, model, page tracking, rendering, drag-into-container, collapsing notes, and color inheritance complete. Remaining: clipboard, alignment/distribution, undo/redo, find/replace, read-only UI.**
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.** **Phase 6 spatial canvas polish complete (selection, clipboard, alignment, undo/redo, find/replace, read-only UI, collapsing notes, color inheritance, drag-into-container).** Remaining: distribution, 8-handle resize, drag-from-edge arrows, full Tiptap head/body editors, templates, backlinks, group access.**
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

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ const transform = computed(() => {
5656
5757
const frameClasses = computed(() => {
5858
const ro = props.model.readOnly.value;
59-
const movable = props.model.movable.value;
59+
const movable = props.model.movable.value && !ro;
6060
return [
6161
"border-border bg-card text-card-foreground pointer-events-auto absolute top-0 left-0 rounded-md border shadow-sm select-none",
6262
ro ? "opacity-70" : "",
63-
movable ? "cursor-grab active:cursor-grabbing" : "",
63+
movable ? "cursor-grab active:cursor-grabbing" : "cursor-default",
6464
props.selected ? "ring-2 ring-primary" : "",
6565
];
6666
});
@@ -87,7 +87,7 @@ function onPointerDown(e: PointerEvent) {
8787
emit("select");
8888
}
8989
90-
if (!props.model.movable.value) return;
90+
if (!props.model.movable.value || props.model.readOnly.value) return;
9191
9292
dragPointerId = e.pointerId;
9393
startX = e.clientX;
@@ -139,6 +139,7 @@ let resizeStartWidth = 0;
139139
140140
function onResizePointerDown(e: PointerEvent) {
141141
if (e.button !== 0) return;
142+
if (props.model.readOnly.value) return;
142143
e.stopPropagation();
143144
resizePointerId = e.pointerId;
144145
resizeStartX = e.clientX;
@@ -188,7 +189,7 @@ function toggleCollapsed() {
188189
>
189190
<div class="border-border flex items-center gap-1 border-b px-2 py-1 text-xs font-medium">
190191
<button
191-
v-if="model.collapsing.enabled.value"
192+
v-if="model.collapsing.enabled.value && !model.readOnly.value"
192193
class="text-muted-foreground hover:text-foreground focus:outline-none"
193194
@pointerdown.stop="toggleCollapsed"
194195
>
@@ -214,7 +215,7 @@ function toggleCollapsed() {
214215

215216
<!-- resize handle -->
216217
<div
217-
v-if="model.resizable.value"
218+
v-if="model.resizable.value && !model.readOnly.value"
218219
class="bg-primary absolute -bottom-1.5 -right-1.5 h-3 w-3 cursor-nwse-resize rounded-full"
219220
@pointerdown="onResizePointerDown"
220221
@pointermove="onResizePointerMove"

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

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import DisplayNote from "./DisplayNote.vue";
66
import DisplayArrow from "./DisplayArrow.vue";
77
import { useSpatialPage } from "./useSpatialPage";
88
import { useSpatialSelection } from "./selection";
9+
import { useSpatialUndoRedo } from "./undo-redo";
10+
import { copySelection, pastePayload, getClipboardBuffer } from "./clipboard";
11+
import {
12+
alignLeft,
13+
alignCenter,
14+
alignRight,
15+
alignTop,
16+
alignMiddle,
17+
alignBottom,
18+
} from "./alignment";
919
import { screenToWorld } from "./spatial-viewport-math";
1020
1121
const props = defineProps<{
@@ -19,6 +29,8 @@ const canvasRef = ref<{
1929
rootEl: HTMLElement | null;
2030
} | null>(null);
2131
32+
const undoRedo = useSpatialUndoRedo(props.ydoc);
33+
2234
const {
2335
noteList,
2436
rootNoteList,
@@ -30,10 +42,12 @@ const {
3042
createArrow,
3143
moveNoteIntoContainer,
3244
moveNoteOutOfContainer,
33-
} = useSpatialPage(props.ydoc);
45+
} = useSpatialPage(props.ydoc, undoRedo);
3446
3547
const selection = useSpatialSelection();
3648
49+
const pasteCount = ref(0);
50+
3751
const noteById = computed(() => {
3852
const map = new Map<string, (typeof noteList.value)[0]["model"]>();
3953
for (const n of noteList.value) {
@@ -285,6 +299,111 @@ function onKeyDown(e: KeyboardEvent) {
285299
selection.selectAll(rootNoteList.value.map((n) => n.id));
286300
return;
287301
}
302+
303+
if (e.key === "z" && (e.ctrlKey || e.metaKey)) {
304+
e.preventDefault();
305+
if (e.shiftKey) {
306+
undoRedo.redo();
307+
} else {
308+
undoRedo.undo();
309+
}
310+
return;
311+
}
312+
313+
if (e.key === "c" && (e.ctrlKey || e.metaKey)) {
314+
e.preventDefault();
315+
const selectedNotes = noteList.value.filter((n) =>
316+
selection.isSelected(n.id),
317+
);
318+
const selectedArrows = arrowList.value.filter((a) =>
319+
selection.isSelected(a.id),
320+
);
321+
if (selectedNotes.length > 0) {
322+
copySelection(selectedNotes, selectedArrows);
323+
}
324+
return;
325+
}
326+
327+
if (e.key === "x" && (e.ctrlKey || e.metaKey)) {
328+
e.preventDefault();
329+
const selectedNotes = noteList.value.filter((n) =>
330+
selection.isSelected(n.id),
331+
);
332+
const selectedArrows = arrowList.value.filter((a) =>
333+
selection.isSelected(a.id),
334+
);
335+
if (selectedNotes.length > 0) {
336+
copySelection(selectedNotes, selectedArrows);
337+
for (const id of selection.selectedOfKind("note")) {
338+
deleteNote(id);
339+
}
340+
for (const id of selection.selectedOfKind("arrow")) {
341+
deleteArrow(id);
342+
}
343+
selection.clear();
344+
}
345+
return;
346+
}
347+
348+
if (e.key === "v" && (e.ctrlKey || e.metaKey)) {
349+
e.preventDefault();
350+
const payload = getClipboardBuffer();
351+
if (payload && payload.notes.length > 0) {
352+
const canvas = canvasRef.value;
353+
const centerX = canvas?.camX ?? 0;
354+
const centerY = canvas?.camY ?? 0;
355+
const offset = pasteCount.value * 32;
356+
pasteCount.value += 1;
357+
358+
const result = pastePayload(payload, {
359+
createNote: createNoteAt,
360+
createArrow: createArrow,
361+
offsetX: centerX + offset,
362+
offsetY: centerY + offset,
363+
});
364+
365+
selection.clear();
366+
for (const id of result.noteIds) {
367+
selection.select(id, "note", true);
368+
}
369+
}
370+
return;
371+
}
372+
373+
// Alignment shortcuts (Ctrl+Shift+...)
374+
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
375+
const selectedNotes = noteList.value.filter((n) =>
376+
selection.isSelected(n.id),
377+
);
378+
if (selectedNotes.length >= 2) {
379+
switch (e.key) {
380+
case "l":
381+
e.preventDefault();
382+
alignLeft(selectedNotes);
383+
return;
384+
case "c":
385+
e.preventDefault();
386+
alignCenter(selectedNotes);
387+
return;
388+
case "r":
389+
e.preventDefault();
390+
alignRight(selectedNotes);
391+
return;
392+
case "t":
393+
e.preventDefault();
394+
alignTop(selectedNotes);
395+
return;
396+
case "m":
397+
e.preventDefault();
398+
alignMiddle(selectedNotes);
399+
return;
400+
case "b":
401+
e.preventDefault();
402+
alignBottom(selectedNotes);
403+
return;
404+
}
405+
}
406+
}
288407
}
289408
290409
onMounted(() => {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createPageYDoc } from "@deepnotes/collab-wire";
3+
import { useSpatialPage } from "./useSpatialPage";
4+
import { useSpatialUndoRedo } from "./undo-redo";
5+
import {
6+
alignLeft,
7+
alignCenter,
8+
alignRight,
9+
alignTop,
10+
alignMiddle,
11+
alignBottom,
12+
} from "./alignment";
13+
14+
describe("alignment", () => {
15+
it("aligns notes to the left", () => {
16+
const ydoc = createPageYDoc();
17+
const ur = useSpatialUndoRedo(ydoc);
18+
const page = useSpatialPage(ydoc, ur);
19+
20+
const n1 = page.createNoteAt(10, 0);
21+
const n2 = page.createNoteAt(50, 0);
22+
const n3 = page.createNoteAt(30, 0);
23+
24+
alignLeft(page.noteList.value);
25+
26+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.x).toBe(10);
27+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.x).toBe(10);
28+
expect(page.noteList.value.find((n) => n.id === n3)!.model.pos.value.x).toBe(10);
29+
});
30+
31+
it("aligns notes to the center horizontally", () => {
32+
const ydoc = createPageYDoc();
33+
const ur = useSpatialUndoRedo(ydoc);
34+
const page = useSpatialPage(ydoc, ur);
35+
36+
const n1 = page.createNoteAt(0, 0);
37+
const n2 = page.createNoteAt(100, 0);
38+
39+
alignCenter(page.noteList.value);
40+
41+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.x).toBe(50);
42+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.x).toBe(50);
43+
});
44+
45+
it("aligns notes to the right", () => {
46+
const ydoc = createPageYDoc();
47+
const ur = useSpatialUndoRedo(ydoc);
48+
const page = useSpatialPage(ydoc, ur);
49+
50+
const n1 = page.createNoteAt(10, 0);
51+
const n2 = page.createNoteAt(50, 0);
52+
53+
alignRight(page.noteList.value);
54+
55+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.x).toBe(50);
56+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.x).toBe(50);
57+
});
58+
59+
it("aligns notes to the top", () => {
60+
const ydoc = createPageYDoc();
61+
const ur = useSpatialUndoRedo(ydoc);
62+
const page = useSpatialPage(ydoc, ur);
63+
64+
const n1 = page.createNoteAt(0, 10);
65+
const n2 = page.createNoteAt(0, 50);
66+
const n3 = page.createNoteAt(0, 30);
67+
68+
alignTop(page.noteList.value);
69+
70+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.y).toBe(10);
71+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.y).toBe(10);
72+
expect(page.noteList.value.find((n) => n.id === n3)!.model.pos.value.y).toBe(10);
73+
});
74+
75+
it("aligns notes to the middle vertically", () => {
76+
const ydoc = createPageYDoc();
77+
const ur = useSpatialUndoRedo(ydoc);
78+
const page = useSpatialPage(ydoc, ur);
79+
80+
const n1 = page.createNoteAt(0, 0);
81+
const n2 = page.createNoteAt(0, 100);
82+
83+
alignMiddle(page.noteList.value);
84+
85+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.y).toBe(50);
86+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.y).toBe(50);
87+
});
88+
89+
it("aligns notes to the bottom", () => {
90+
const ydoc = createPageYDoc();
91+
const ur = useSpatialUndoRedo(ydoc);
92+
const page = useSpatialPage(ydoc, ur);
93+
94+
const n1 = page.createNoteAt(0, 10);
95+
const n2 = page.createNoteAt(0, 50);
96+
97+
alignBottom(page.noteList.value);
98+
99+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.y).toBe(50);
100+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.y).toBe(50);
101+
});
102+
103+
it("does nothing with fewer than 2 notes", () => {
104+
const ydoc = createPageYDoc();
105+
const ur = useSpatialUndoRedo(ydoc);
106+
const page = useSpatialPage(ydoc, ur);
107+
108+
const n1 = page.createNoteAt(10, 20);
109+
alignLeft(page.noteList.value);
110+
alignTop(page.noteList.value);
111+
112+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value).toEqual({ x: 10, y: 20 });
113+
});
114+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { NoteModel } from "./note-model";
2+
3+
export interface AlignedNote {
4+
id: string;
5+
model: NoteModel;
6+
}
7+
8+
export function alignLeft(notes: AlignedNote[]): void {
9+
if (notes.length < 2) return;
10+
const minX = Math.min(...notes.map((n) => n.model.pos.value.x));
11+
for (const note of notes) {
12+
const posMap = note.model.rawMap.get("pos") as import("yjs").Map<number>;
13+
posMap.set("x", minX);
14+
}
15+
}
16+
17+
export function alignCenter(notes: AlignedNote[]): void {
18+
if (notes.length < 2) return;
19+
const xs = notes.map((n) => n.model.pos.value.x);
20+
const minX = Math.min(...xs);
21+
const maxX = Math.max(...xs);
22+
const center = minX + (maxX - minX) / 2;
23+
for (const note of notes) {
24+
const posMap = note.model.rawMap.get("pos") as import("yjs").Map<number>;
25+
posMap.set("x", center);
26+
}
27+
}
28+
29+
export function alignRight(notes: AlignedNote[]): void {
30+
if (notes.length < 2) return;
31+
const maxX = Math.max(...notes.map((n) => n.model.pos.value.x));
32+
for (const note of notes) {
33+
const posMap = note.model.rawMap.get("pos") as import("yjs").Map<number>;
34+
posMap.set("x", maxX);
35+
}
36+
}
37+
38+
export function alignTop(notes: AlignedNote[]): void {
39+
if (notes.length < 2) return;
40+
const minY = Math.min(...notes.map((n) => n.model.pos.value.y));
41+
for (const note of notes) {
42+
const posMap = note.model.rawMap.get("pos") as import("yjs").Map<number>;
43+
posMap.set("y", minY);
44+
}
45+
}
46+
47+
export function alignMiddle(notes: AlignedNote[]): void {
48+
if (notes.length < 2) return;
49+
const ys = notes.map((n) => n.model.pos.value.y);
50+
const minY = Math.min(...ys);
51+
const maxY = Math.max(...ys);
52+
const middle = minY + (maxY - minY) / 2;
53+
for (const note of notes) {
54+
const posMap = note.model.rawMap.get("pos") as import("yjs").Map<number>;
55+
posMap.set("y", middle);
56+
}
57+
}
58+
59+
export function alignBottom(notes: AlignedNote[]): void {
60+
if (notes.length < 2) return;
61+
const maxY = Math.max(...notes.map((n) => n.model.pos.value.y));
62+
for (const note of notes) {
63+
const posMap = note.model.rawMap.get("pos") as import("yjs").Map<number>;
64+
posMap.set("y", maxY);
65+
}
66+
}

0 commit comments

Comments
 (0)