Skip to content

Commit 1a9729e

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

7 files changed

Lines changed: 365 additions & 22 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 (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.**
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.**
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.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@ import DisplayNote from "./DisplayNote.vue";
66
import { useNoteModel } from "./note-model";
77
import { addNoteToPage, createPageYDoc } from "@deepnotes/collab-wire";
88

9-
function createNoteModel(ydoc: Y.Doc, id: string, opts?: { containerEnabled?: boolean }) {
9+
function createNoteModel(
10+
ydoc: Y.Doc,
11+
id: string,
12+
opts?: { containerEnabled?: boolean; collapsingEnabled?: boolean; colorInherit?: boolean; colorValue?: string },
13+
) {
1014
const noteMap = addNoteToPage(ydoc, id);
1115
if (opts?.containerEnabled) {
1216
const containerMap = noteMap.get("container") as Y.Map<unknown>;
1317
containerMap.set("enabled", true);
1418
}
19+
if (opts?.collapsingEnabled) {
20+
const collapsingMap = noteMap.get("collapsing") as Y.Map<boolean>;
21+
collapsingMap.set("enabled", true);
22+
}
23+
if (opts?.colorInherit !== undefined || opts?.colorValue !== undefined) {
24+
const colorMap = noteMap.get("color") as Y.Map<unknown>;
25+
if (opts.colorInherit !== undefined) colorMap.set("inherit", opts.colorInherit);
26+
if (opts.colorValue !== undefined) colorMap.set("value", opts.colorValue);
27+
}
1528
return useNoteModel(noteMap);
1629
}
1730

@@ -21,7 +34,7 @@ describe("DisplayNote", () => {
2134
const model = createNoteModel(ydoc, "note-1");
2235

2336
const wrapper = mount(DisplayNote, {
24-
props: { model, zoom: 1 },
37+
props: { id: "note-1", model, zoom: 1 },
2538
});
2639

2740
expect(wrapper.findAll('[data-testid="display-note"]')).toHaveLength(1);
@@ -33,7 +46,12 @@ describe("DisplayNote", () => {
3346
const childModel = createNoteModel(ydoc, "child-1");
3447

3548
const wrapper = mount(DisplayNote, {
36-
props: { model: parentModel, zoom: 1, childModels: [childModel] },
49+
props: {
50+
id: "parent",
51+
model: parentModel,
52+
zoom: 1,
53+
childModels: [{ id: "child-1", model: childModel }],
54+
},
3755
});
3856

3957
const notes = wrapper.findAll('[data-testid="display-note"]');
@@ -46,9 +64,62 @@ describe("DisplayNote", () => {
4664
const childModel = createNoteModel(ydoc, "child-1");
4765

4866
const wrapper = mount(DisplayNote, {
49-
props: { model: parentModel, zoom: 1, childModels: [childModel] },
67+
props: {
68+
id: "parent",
69+
model: parentModel,
70+
zoom: 1,
71+
childModels: [{ id: "child-1", model: childModel }],
72+
},
5073
});
5174

5275
expect(wrapper.findAll('[data-testid="display-note"]')).toHaveLength(1);
5376
});
77+
78+
it("shows collapse button when collapsing is enabled", () => {
79+
const ydoc = createPageYDoc();
80+
const model = createNoteModel(ydoc, "note-1", { collapsingEnabled: true });
81+
82+
const wrapper = mount(DisplayNote, {
83+
props: { id: "note-1", model, zoom: 1 },
84+
});
85+
86+
expect(wrapper.find("button").exists()).toBe(true);
87+
});
88+
89+
it("hides collapse button when collapsing is disabled", () => {
90+
const ydoc = createPageYDoc();
91+
const model = createNoteModel(ydoc, "note-1");
92+
93+
const wrapper = mount(DisplayNote, {
94+
props: { id: "note-1", model, zoom: 1 },
95+
});
96+
97+
expect(wrapper.find("button").exists()).toBe(false);
98+
});
99+
100+
it("inherits parent color when color.inherit is true", () => {
101+
const ydoc = createPageYDoc();
102+
const model = createNoteModel(ydoc, "note-1", { colorInherit: true });
103+
104+
const wrapper = mount(DisplayNote, {
105+
props: { id: "note-1", model, zoom: 1, parentColor: "#ef4444" },
106+
});
107+
108+
const el = wrapper.find('[data-testid="display-note"]');
109+
const style = el.attributes("style");
110+
expect(style).toContain("border-color: #ef4444");
111+
});
112+
113+
it("uses own color when color.inherit is false", () => {
114+
const ydoc = createPageYDoc();
115+
const model = createNoteModel(ydoc, "note-1", { colorInherit: false, colorValue: "blue" });
116+
117+
const wrapper = mount(DisplayNote, {
118+
props: { id: "note-1", model, zoom: 1, parentColor: "#ef4444" },
119+
});
120+
121+
const el = wrapper.find('[data-testid="display-note"]');
122+
const style = el.attributes("style");
123+
expect(style).toContain("border-color: #3b82f6");
124+
});
54125
});

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

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
<script setup lang="ts">
22
import { computed, ref } from "vue";
3+
import { ChevronDown, ChevronRight } from "lucide-vue-next";
34
import type { NoteModel } from "./note-model";
45
56
const props = defineProps<{
7+
id: string;
68
model: NoteModel;
79
zoom: number;
810
selected?: boolean;
9-
childModels?: NoteModel[];
11+
childModels?: Array<{ id: string; model: NoteModel }>;
12+
parentColor?: string | null;
1013
}>();
1114
1215
const emit = defineEmits<{
1316
select: [];
1417
toggle: [];
1518
shiftClick: [];
19+
dragend: [id: string];
1620
}>();
1721
18-
const noteColor = computed(() => {
22+
const resolvedColor = computed(() => {
1923
const c = props.model.color.value;
20-
if (c.inherit) return null;
24+
if (c.inherit) return props.parentColor ?? null;
2125
// Simple legacy color mapping to CSS color values
2226
const colorMap: Record<string, string> = {
2327
grey: "#9ca3af",
@@ -42,7 +46,7 @@ const transform = computed(() => {
4246
width: props.model.width.value.expanded === "Auto" ? "auto" : `${props.model.width.value.expanded}px`,
4347
zIndex: props.model.zIndex.value,
4448
};
45-
const color = noteColor.value;
49+
const color = resolvedColor.value;
4650
if (color) {
4751
style.borderColor = color;
4852
style.backgroundColor = `${color}18`; // 10% opacity tint
@@ -66,6 +70,7 @@ let startX = 0;
6670
let startY = 0;
6771
let noteStartX = 0;
6872
let noteStartY = 0;
73+
let hasDragged = false;
6974
7075
function onPointerDown(e: PointerEvent) {
7176
if (e.button !== 0) return;
@@ -89,6 +94,7 @@ function onPointerDown(e: PointerEvent) {
8994
startY = e.clientY;
9095
noteStartX = props.model.pos.value.x;
9196
noteStartY = props.model.pos.value.y;
97+
hasDragged = false;
9298
const el = e.currentTarget as HTMLElement;
9399
el.setPointerCapture(e.pointerId);
94100
el.style.cursor = "grabbing";
@@ -98,6 +104,9 @@ function onPointerMove(e: PointerEvent) {
98104
if (dragPointerId !== e.pointerId) return;
99105
const dxScreen = e.clientX - startX;
100106
const dyScreen = e.clientY - startY;
107+
if (dxScreen !== 0 || dyScreen !== 0) {
108+
hasDragged = true;
109+
}
101110
const z = props.zoom || 1;
102111
const noteMap = props.model.rawMap;
103112
const posMap = noteMap.get("pos") as import("yjs").Map<number>;
@@ -117,6 +126,10 @@ function onPointerUp(e: PointerEvent) {
117126
}
118127
}
119128
el.style.cursor = props.model.movable.value ? "grab" : "";
129+
if (hasDragged) {
130+
hasDragged = false;
131+
emit("dragend", props.id);
132+
}
120133
}
121134
122135
// --- resize handle ---
@@ -156,6 +169,11 @@ function onResizePointerUp(e: PointerEvent) {
156169
}
157170
}
158171
}
172+
173+
function toggleCollapsed() {
174+
const collapsingMap = props.model.rawMap.get("collapsing") as import("yjs").Map<boolean>;
175+
collapsingMap.set("collapsed", !props.model.collapsing.collapsed.value);
176+
}
159177
</script>
160178

161179
<template>
@@ -168,16 +186,26 @@ function onResizePointerUp(e: PointerEvent) {
168186
@pointerup="onPointerUp"
169187
@pointercancel="onPointerUp"
170188
>
171-
<div class="border-border border-b px-2 py-1 text-xs font-medium">
172-
{{ model.head.enabled.value ? "Head" : "" }}
173-
<span
174-
v-if="model.body.enabled.value"
175-
class="text-muted-foreground"
189+
<div class="border-border flex items-center gap-1 border-b px-2 py-1 text-xs font-medium">
190+
<button
191+
v-if="model.collapsing.enabled.value"
192+
class="text-muted-foreground hover:text-foreground focus:outline-none"
193+
@pointerdown.stop="toggleCollapsed"
176194
>
177-
/ Body
195+
<ChevronDown v-if="!model.collapsing.collapsed.value" class="h-3 w-3" />
196+
<ChevronRight v-else class="h-3 w-3" />
197+
</button>
198+
<span class="flex-1 truncate">
199+
{{ model.head.enabled.value ? "Head" : "" }}
200+
<span
201+
v-if="model.body.enabled.value"
202+
class="text-muted-foreground"
203+
>
204+
/ Body
205+
</span>
178206
</span>
179207
</div>
180-
<div class="px-2 py-1">
208+
<div v-if="!model.collapsing.collapsed.value" class="px-2 py-1">
181209
<p class="text-muted-foreground text-xs">
182210
pos: {{ model.pos.value.x.toFixed(0) }},{{ model.pos.value.y.toFixed(0) }} · z:
183211
{{ model.zIndex.value }}
@@ -195,16 +223,19 @@ function onResizePointerUp(e: PointerEvent) {
195223
/>
196224

197225
<!-- container children -->
198-
<template v-if="model.container.enabled.value && childModels?.length">
226+
<template v-if="model.container.enabled.value && childModels?.length && !model.collapsing.collapsed.value">
199227
<div
200228
class="border-border pointer-events-none absolute inset-x-0 bottom-0 border-t"
201229
style="top: 3rem"
202230
>
203231
<DisplayNote
204-
v-for="(child, idx) in childModels"
205-
:key="idx"
206-
:model="child"
232+
v-for="child in childModels"
233+
:key="child.id"
234+
:id="child.id"
235+
:model="child.model"
207236
:zoom="zoom"
237+
:parent-color="resolvedColor"
238+
@dragend="$emit('dragend', $event)"
208239
/>
209240
</div>
210241
</template>

0 commit comments

Comments
 (0)