Skip to content

Commit 4946ae4

Browse files
committed
feat: improve phase 6
1 parent 98f97d3 commit 4946ae4

6 files changed

Lines changed: 258 additions & 20 deletions

File tree

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.** **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.**
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, distribution, undo/redo, find/replace, read-only UI, collapsing notes, color inheritance, drag-into-container, 8-handle resize, drag-from-edge arrows).** Remaining: 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: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const emit = defineEmits<{
1717
toggle: [];
1818
shiftClick: [];
1919
dragend: [id: string];
20+
arrowDragStart: [payload: { noteId: string }];
2021
}>();
2122
2223
const resolvedColor = computed(() => {
@@ -132,35 +133,55 @@ function onPointerUp(e: PointerEvent) {
132133
}
133134
}
134135
135-
// --- resize handle ---
136+
// --- 8-handle resize ---
137+
type ResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
138+
136139
let resizePointerId: number | null = null;
140+
let resizeHandle: ResizeHandle | null = null;
137141
let resizeStartX = 0;
142+
let resizeStartY = 0;
138143
let resizeStartWidth = 0;
144+
let resizeStartPosX = 0;
139145
140-
function onResizePointerDown(e: PointerEvent) {
146+
function onResizePointerDown(e: PointerEvent, handle: ResizeHandle) {
141147
if (e.button !== 0) return;
142148
if (props.model.readOnly.value) return;
143149
e.stopPropagation();
144150
resizePointerId = e.pointerId;
151+
resizeHandle = handle;
145152
resizeStartX = e.clientX;
153+
resizeStartY = e.clientY;
146154
const w = props.model.width.value.expanded;
147155
resizeStartWidth = w === "Auto" ? 160 : parseFloat(w);
156+
resizeStartPosX = props.model.pos.value.x;
148157
const el = e.currentTarget as HTMLElement;
149158
el.setPointerCapture(e.pointerId);
150159
}
151160
152161
function onResizePointerMove(e: PointerEvent) {
153-
if (resizePointerId !== e.pointerId) return;
162+
if (resizePointerId !== e.pointerId || !resizeHandle) return;
154163
const z = props.zoom || 1;
155164
const dx = (e.clientX - resizeStartX) / z;
156-
const next = Math.max(80, Math.round(resizeStartWidth + dx));
165+
166+
const isWest = resizeHandle.includes("w");
167+
const nextWidth = Math.max(
168+
80,
169+
Math.round(isWest ? resizeStartWidth - dx : resizeStartWidth + dx),
170+
);
171+
157172
const widthMap = props.model.rawMap.get("width") as import("yjs").Map<string>;
158-
widthMap.set("expanded", String(next));
173+
widthMap.set("expanded", String(nextWidth));
174+
175+
if (isWest) {
176+
const posMap = props.model.rawMap.get("pos") as import("yjs").Map<number>;
177+
posMap.set("x", Math.round(resizeStartPosX + dx));
178+
}
159179
}
160180
161181
function onResizePointerUp(e: PointerEvent) {
162182
if (resizePointerId !== e.pointerId) return;
163183
resizePointerId = null;
184+
resizeHandle = null;
164185
const el = e.currentTarget as HTMLElement;
165186
if (el.releasePointerCapture) {
166187
try {
@@ -180,6 +201,7 @@ function toggleCollapsed() {
180201
<template>
181202
<div
182203
data-testid="display-note"
204+
:data-note-id="id"
183205
:class="frameClasses"
184206
:style="transform"
185207
@pointerdown="onPointerDown"
@@ -213,15 +235,46 @@ function toggleCollapsed() {
213235
</p>
214236
</div>
215237

216-
<!-- resize handle -->
217-
<div
218-
v-if="model.resizable.value && !model.readOnly.value"
219-
class="bg-primary absolute -bottom-1.5 -right-1.5 h-3 w-3 cursor-nwse-resize rounded-full"
220-
@pointerdown="onResizePointerDown"
221-
@pointermove="onResizePointerMove"
222-
@pointerup="onResizePointerUp"
223-
@pointercancel="onResizePointerUp"
224-
/>
238+
<!-- 8 resize handles -->
239+
<template v-if="model.resizable.value && !model.readOnly.value">
240+
<div
241+
v-for="h in ([
242+
{ key: 'nw', cls: '-top-1.5 -left-1.5 cursor-nwse-resize' },
243+
{ key: 'n', cls: '-top-1.5 left-1/2 -translate-x-1/2 cursor-ns-resize' },
244+
{ key: 'ne', cls: '-top-1.5 -right-1.5 cursor-nesw-resize' },
245+
{ key: 'e', cls: '-right-1.5 top-1/2 -translate-y-1/2 cursor-ew-resize' },
246+
{ key: 'se', cls: '-bottom-1.5 -right-1.5 cursor-nwse-resize' },
247+
{ key: 's', cls: '-bottom-1.5 left-1/2 -translate-x-1/2 cursor-ns-resize' },
248+
{ key: 'sw', cls: '-bottom-1.5 -left-1.5 cursor-nesw-resize' },
249+
{ key: 'w', cls: '-left-1.5 top-1/2 -translate-y-1/2 cursor-ew-resize' },
250+
] as const)"
251+
:key="h.key"
252+
class="bg-primary absolute h-3 w-3 rounded-full"
253+
:class="h.cls"
254+
@pointerdown="(e: PointerEvent) => onResizePointerDown(e, h.key)"
255+
@pointermove="onResizePointerMove"
256+
@pointerup="onResizePointerUp"
257+
@pointercancel="onResizePointerUp"
258+
/>
259+
</template>
260+
261+
<!-- arrow handles -->
262+
<template v-if="selected && !model.readOnly.value">
263+
<div
264+
v-for="h in ([
265+
{ cls: '-top-3 left-1/2 -translate-x-1/2' },
266+
{ cls: '-right-3 top-1/2 -translate-y-1/2' },
267+
{ cls: '-bottom-3 left-1/2 -translate-x-1/2' },
268+
{ cls: '-left-3 top-1/2 -translate-y-1/2' },
269+
] as const)"
270+
:key="h.cls"
271+
class="bg-primary/80 hover:bg-primary absolute h-2.5 w-2.5 cursor-crosshair rounded-full"
272+
:class="h.cls"
273+
@pointerdown.stop="(e: PointerEvent) => {
274+
emit('arrowDragStart', { noteId: props.id });
275+
}"
276+
/>
277+
</template>
225278

226279
<!-- container children -->
227280
<template v-if="model.container.enabled.value && childModels?.length && !model.collapsing.collapsed.value">

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

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import {
1515
alignTop,
1616
alignMiddle,
1717
alignBottom,
18+
distributeHorizontally,
19+
distributeVertically,
1820
} from "./alignment";
19-
import { screenToWorld } from "./spatial-viewport-math";
21+
import { screenToWorld, worldToScreen } from "./spatial-viewport-math";
2022
2123
const props = defineProps<{
2224
ydoc: any;
@@ -48,6 +50,13 @@ const selection = useSpatialSelection();
4850
4951
const pasteCount = ref(0);
5052
53+
// --- arrow drag state ---
54+
const arrowDrag = ref<{
55+
sourceId: string;
56+
endX: number;
57+
endY: number;
58+
} | null>(null);
59+
5160
const noteById = computed(() => {
5261
const map = new Map<string, (typeof noteList.value)[0]["model"]>();
5362
for (const n of noteList.value) {
@@ -62,6 +71,40 @@ const notesByZIndex = computed(() => {
6271
);
6372
});
6473
74+
const previewLine = computed(() => {
75+
if (!arrowDrag.value || !canvasRef.value?.rootEl) return null;
76+
const sourceNote = noteList.value.find(
77+
(n) => n.id === arrowDrag.value!.sourceId,
78+
);
79+
if (!sourceNote) return null;
80+
81+
const rect = canvasRef.value.rootEl.getBoundingClientRect();
82+
const cx = rect.left + rect.width / 2;
83+
const cy = rect.top + rect.height / 2;
84+
const z = canvasRef.value.zoom;
85+
const camX = canvasRef.value.camX;
86+
const camY = canvasRef.value.camY;
87+
88+
const wStr = sourceNote.model.width.value.expanded;
89+
const w = wStr === "Auto" ? 160 : parseFloat(wStr);
90+
const sourceScreen = worldToScreen(
91+
sourceNote.model.pos.value.x + w / 2,
92+
sourceNote.model.pos.value.y + 40,
93+
cx,
94+
cy,
95+
camX,
96+
camY,
97+
z,
98+
);
99+
100+
return {
101+
x1: sourceScreen.x,
102+
y1: sourceScreen.y,
103+
x2: arrowDrag.value.endX,
104+
y2: arrowDrag.value.endY,
105+
};
106+
});
107+
65108
function onCanvasDoubleClick(e: MouseEvent) {
66109
const canvas = canvasRef.value;
67110
if (!canvas || !canvas.rootEl) return;
@@ -140,6 +183,40 @@ function onCanvasPointerUp() {
140183
boxState = null;
141184
}
142185
186+
// --- arrow drag ---
187+
function onArrowDragStart(sourceId: string) {
188+
arrowDrag.value = { sourceId, endX: 0, endY: 0 };
189+
window.addEventListener("pointermove", onArrowDragMove);
190+
window.addEventListener("pointerup", onArrowDragEnd);
191+
}
192+
193+
function onArrowDragMove(e: PointerEvent) {
194+
if (!arrowDrag.value) return;
195+
arrowDrag.value.endX = e.clientX;
196+
arrowDrag.value.endY = e.clientY;
197+
}
198+
199+
function onArrowDragEnd(e: PointerEvent) {
200+
window.removeEventListener("pointermove", onArrowDragMove);
201+
window.removeEventListener("pointerup", onArrowDragEnd);
202+
203+
if (!arrowDrag.value) return;
204+
const sourceId = arrowDrag.value.sourceId;
205+
arrowDrag.value = null;
206+
207+
// Find target note under cursor
208+
const targetEl = document.elementFromPoint(e.clientX, e.clientY);
209+
if (!targetEl) return;
210+
211+
const noteEl = targetEl.closest("[data-note-id]") as HTMLElement | null;
212+
if (!noteEl) return;
213+
214+
const targetId = noteEl.dataset.noteId;
215+
if (!targetId || targetId === sourceId) return;
216+
217+
createArrow(sourceId, targetId);
218+
}
219+
143220
function rectsIntersect(
144221
ax: number,
145222
ay: number,
@@ -401,6 +478,14 @@ function onKeyDown(e: KeyboardEvent) {
401478
e.preventDefault();
402479
alignBottom(selectedNotes);
403480
return;
481+
case "h":
482+
e.preventDefault();
483+
distributeHorizontally(selectedNotes);
484+
return;
485+
case "v":
486+
e.preventDefault();
487+
distributeVertically(selectedNotes);
488+
return;
404489
}
405490
}
406491
}
@@ -421,7 +506,9 @@ onUnmounted(() => {
421506
Double-click on the canvas to create a note. Scroll to pan, Ctrl+scroll to
422507
zoom. Click a note to select, Ctrl+click to multi-select, drag on empty
423508
canvas to box-select, Ctrl+A to select all, then press Delete to remove.
424-
Shift+click another note to connect with an arrow.
509+
Shift+click another note to connect with an arrow, or drag the small
510+
handles that appear on selected note edges to draw an arrow.
511+
Ctrl+Shift+L/C/R/T/M/B aligns selected notes; H/V distributes them.
425512
</div>
426513
<div class="relative">
427514
<SpatialWorldCanvas
@@ -463,6 +550,7 @@ onUnmounted(() => {
463550
? createArrow(selection.activeId.value, note.id)
464551
: undefined
465552
"
553+
@arrow-drag-start="onArrowDragStart($event.noteId)"
466554
@dragend="onNoteDragEnd"
467555
/>
468556
</SpatialWorldCanvas>
@@ -478,6 +566,23 @@ onUnmounted(() => {
478566
height: `${selection.boxRect.value.height}px`,
479567
}"
480568
/>
569+
570+
<!-- arrow drag preview line -->
571+
<svg
572+
v-if="previewLine"
573+
class="pointer-events-none absolute inset-0 z-50 overflow-visible"
574+
>
575+
<line
576+
:x1="previewLine.x1"
577+
:y1="previewLine.y1"
578+
:x2="previewLine.x2"
579+
:y2="previewLine.y2"
580+
stroke="currentColor"
581+
stroke-width="2"
582+
stroke-dasharray="4 4"
583+
class="text-primary"
584+
/>
585+
</svg>
481586
</div>
482587
</div>
483588
</template>

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
alignTop,
1010
alignMiddle,
1111
alignBottom,
12+
distributeHorizontally,
13+
distributeVertically,
1214
} from "./alignment";
1315

1416
describe("alignment", () => {
@@ -111,4 +113,50 @@ describe("alignment", () => {
111113

112114
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value).toEqual({ x: 10, y: 20 });
113115
});
116+
117+
it("distributes notes horizontally", () => {
118+
const ydoc = createPageYDoc();
119+
const ur = useSpatialUndoRedo(ydoc);
120+
const page = useSpatialPage(ydoc, ur);
121+
122+
const n1 = page.createNoteAt(0, 0);
123+
const n2 = page.createNoteAt(50, 0);
124+
const n3 = page.createNoteAt(100, 0);
125+
126+
distributeHorizontally(page.noteList.value);
127+
128+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.x).toBe(0);
129+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.x).toBe(50);
130+
expect(page.noteList.value.find((n) => n.id === n3)!.model.pos.value.x).toBe(100);
131+
});
132+
133+
it("distributes notes vertically", () => {
134+
const ydoc = createPageYDoc();
135+
const ur = useSpatialUndoRedo(ydoc);
136+
const page = useSpatialPage(ydoc, ur);
137+
138+
const n1 = page.createNoteAt(0, 0);
139+
const n2 = page.createNoteAt(0, 40);
140+
const n3 = page.createNoteAt(0, 80);
141+
142+
distributeVertically(page.noteList.value);
143+
144+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value.y).toBe(0);
145+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value.y).toBe(40);
146+
expect(page.noteList.value.find((n) => n.id === n3)!.model.pos.value.y).toBe(80);
147+
});
148+
149+
it("does nothing for distribution with fewer than 3 notes", () => {
150+
const ydoc = createPageYDoc();
151+
const ur = useSpatialUndoRedo(ydoc);
152+
const page = useSpatialPage(ydoc, ur);
153+
154+
const n1 = page.createNoteAt(10, 20);
155+
const n2 = page.createNoteAt(100, 200);
156+
distributeHorizontally(page.noteList.value);
157+
distributeVertically(page.noteList.value);
158+
159+
expect(page.noteList.value.find((n) => n.id === n1)!.model.pos.value).toEqual({ x: 10, y: 20 });
160+
expect(page.noteList.value.find((n) => n.id === n2)!.model.pos.value).toEqual({ x: 100, y: 200 });
161+
});
114162
});

0 commit comments

Comments
 (0)