Skip to content

Commit dabb0c0

Browse files
committed
feat: add container layout rendering, toolbar action slot, and insertNoteAtCenter method
1 parent 1810e01 commit dabb0c0

9 files changed

Lines changed: 305 additions & 17 deletions

File tree

docs/restart-plan/phase-6-spatial-polish.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Phase 6: Spatial canvas polish
22

33
> **Prerequisites:** Phase 5 done.
4-
> **Status:** In progress (2026-05-31 — **New this session:** `MainToolbar.test.ts` (7), `PageLayout.test.ts` (10), `RecentPagesCard.test.ts` (5), `FavoritePagesCard.test.ts` (5), `SelectedPagesCard.test.ts` (5), `useNoteContextMenu.test.ts` (5) added. `DisplayNote.test.ts` expanded to 21 tests. `fitToScreen` now reads actual note heights from reactive map. **Per-note context menu implemented:** `NoteContextMenu.vue` + `useNoteContextMenu.ts` composable wired into `DisplayNote.vue` and `SpatialPageView.vue`. `SpatialPageView.vue` previously refactored. Canvas actions, box selection, arrow drag, arrow reconnect, note drag previously extracted into composables.)
4+
> **Status:** In progress (2026-05-31 — **New this session:** Container rendering in `DisplayNote.vue` now enforces `spatial` vs non-spatial layout, `stretchChildren`, and `wrapChildren`. `DisplayNote.test.ts` expanded to 26 tests with 5 new container layout tests. **Toolbar page action buttons started:** `MainToolbar.vue` gained `actions` slot; `PageEditorView.vue` wires "Insert Note" button that calls `SpatialPageView.insertNoteAtCenter()`. **Bug fix:** `SpatialPageView.vue` `setNoteZIndex` corrected to set primitive `zIndex` instead of treating it as nested `Y.Map`. **New tests:** `SpatialPageView.test.ts` added with 5 tests covering rendering, double-click creation, Ctrl+A+Delete, and exposed method.)
55
66
---
77

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CardHeader,
1010
CardTitle,
1111
} from "@/components/ui/card";
12+
import { Plus } from "lucide-vue-next";
1213
1314
import { useSession } from "../auth/useSession";
1415
import SpatialPageView from "../spatial/SpatialPageView.vue";
@@ -54,6 +55,8 @@ const pageId = computed(() => String(route.params.pageId ?? ""));
5455
const snapshots = ref<SnapshotRow[]>([]);
5556
const snapshotLoading = ref(false);
5657
58+
const spatialViewRef = ref<any>(null);
59+
5760
// Track selected note for properties panel
5861
const selectedNoteId = ref<string | null>(null);
5962
const selectedNoteModel = ref<any>(null);
@@ -222,6 +225,7 @@ onMounted(() => {
222225
:on-unlock-password="onUnlockWithPassword"
223226
/>
224227
<SpatialPageView
228+
ref="spatialViewRef"
225229
v-else
226230
:ydoc="ydoc"
227231
:default-note-template="noteTemplate"
@@ -230,6 +234,19 @@ onMounted(() => {
230234
@select-arrow="selectedArrowId = $event?.[0] ?? null; selectedArrowModel = $event?.[1] ?? null"
231235
/>
232236

237+
<!-- === Toolbar actions === -->
238+
<template #toolbar-actions>
239+
<Button
240+
variant="ghost"
241+
size="sm"
242+
class="gap-1"
243+
@click="spatialViewRef?.insertNoteAtCenter()"
244+
>
245+
<Plus class="h-4 w-4" />
246+
<span class="hidden sm:inline">Note</span>
247+
</Button>
248+
</template>
249+
233250
<!-- === Toolbar center: breadcrumb path === -->
234251
<template #toolbar-center>
235252
<nav

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ function createNoteModel(
1111
id: string,
1212
opts?: {
1313
containerEnabled?: boolean;
14+
containerSpatial?: boolean;
15+
containerHorizontal?: boolean;
16+
containerWrapChildren?: boolean;
17+
containerStretchChildren?: boolean;
1418
collapsingEnabled?: boolean;
1519
colorInherit?: boolean;
1620
colorValue?: string;
@@ -23,6 +27,10 @@ function createNoteModel(
2327
if (opts?.containerEnabled) {
2428
const containerMap = noteMap.get("container") as Y.Map<unknown>;
2529
containerMap.set("enabled", true);
30+
if (opts.containerSpatial !== undefined) containerMap.set("spatial", opts.containerSpatial);
31+
if (opts.containerHorizontal !== undefined) containerMap.set("horizontal", opts.containerHorizontal);
32+
if (opts.containerWrapChildren !== undefined) containerMap.set("wrapChildren", opts.containerWrapChildren);
33+
if (opts.containerStretchChildren !== undefined) containerMap.set("stretchChildren", opts.containerStretchChildren);
2634
}
2735
if (opts?.collapsingEnabled) {
2836
const collapsingMap = noteMap.get("collapsing") as Y.Map<boolean>;
@@ -335,4 +343,123 @@ describe("DisplayNote", () => {
335343
await el.trigger('contextmenu');
336344
expect(wrapper.emitted('context-menu')).toBeUndefined();
337345
});
346+
347+
it("renders spatial container children with absolute positioning", () => {
348+
const ydoc = createPageYDoc();
349+
const parentModel = createNoteModel(ydoc, "parent", { containerEnabled: true, containerSpatial: true });
350+
const childModel = createNoteModel(ydoc, "child-1");
351+
352+
wrapper = mount(DisplayNote, {
353+
props: {
354+
id: "parent",
355+
model: parentModel,
356+
zoom: 1,
357+
childModels: [{ id: "child-1", model: childModel }],
358+
},
359+
});
360+
361+
const container = wrapper.find('[data-testid="container-children"]');
362+
expect(container.exists()).toBe(true);
363+
expect(container.classes()).not.toContain("flex");
364+
365+
const child = container.find('[data-testid="display-note"]');
366+
expect(child.classes()).toContain("absolute");
367+
expect(child.classes()).not.toContain("relative");
368+
});
369+
370+
it("renders non-spatial horizontal container with flex row", () => {
371+
const ydoc = createPageYDoc();
372+
const parentModel = createNoteModel(ydoc, "parent", {
373+
containerEnabled: true,
374+
containerSpatial: false,
375+
containerHorizontal: true,
376+
});
377+
const childModel = createNoteModel(ydoc, "child-1");
378+
379+
wrapper = mount(DisplayNote, {
380+
props: {
381+
id: "parent",
382+
model: parentModel,
383+
zoom: 1,
384+
childModels: [{ id: "child-1", model: childModel }],
385+
},
386+
});
387+
388+
const container = wrapper.find('[data-testid="container-children"]');
389+
expect(container.classes()).toContain("flex");
390+
expect(container.classes()).toContain("flex-row");
391+
392+
const child = container.find('[data-testid="display-note"]');
393+
expect(child.classes()).toContain("relative");
394+
expect(child.classes()).not.toContain("absolute");
395+
});
396+
397+
it("renders non-spatial vertical container with flex col", () => {
398+
const ydoc = createPageYDoc();
399+
const parentModel = createNoteModel(ydoc, "parent", {
400+
containerEnabled: true,
401+
containerSpatial: false,
402+
containerHorizontal: false,
403+
});
404+
const childModel = createNoteModel(ydoc, "child-1");
405+
406+
wrapper = mount(DisplayNote, {
407+
props: {
408+
id: "parent",
409+
model: parentModel,
410+
zoom: 1,
411+
childModels: [{ id: "child-1", model: childModel }],
412+
},
413+
});
414+
415+
const container = wrapper.find('[data-testid="container-children"]');
416+
expect(container.classes()).toContain("flex");
417+
expect(container.classes()).toContain("flex-col");
418+
});
419+
420+
it("applies flex-wrap when wrapChildren is true", () => {
421+
const ydoc = createPageYDoc();
422+
const parentModel = createNoteModel(ydoc, "parent", {
423+
containerEnabled: true,
424+
containerSpatial: false,
425+
containerHorizontal: true,
426+
containerWrapChildren: true,
427+
});
428+
const childModel = createNoteModel(ydoc, "child-1");
429+
430+
wrapper = mount(DisplayNote, {
431+
props: {
432+
id: "parent",
433+
model: parentModel,
434+
zoom: 1,
435+
childModels: [{ id: "child-1", model: childModel }],
436+
},
437+
});
438+
439+
const container = wrapper.find('[data-testid="container-children"]');
440+
expect(container.classes()).toContain("flex-wrap");
441+
});
442+
443+
it("applies items-stretch when stretchChildren is true", () => {
444+
const ydoc = createPageYDoc();
445+
const parentModel = createNoteModel(ydoc, "parent", {
446+
containerEnabled: true,
447+
containerSpatial: false,
448+
containerHorizontal: true,
449+
containerStretchChildren: true,
450+
});
451+
const childModel = createNoteModel(ydoc, "child-1");
452+
453+
wrapper = mount(DisplayNote, {
454+
props: {
455+
id: "parent",
456+
model: parentModel,
457+
zoom: 1,
458+
childModels: [{ id: "child-1", model: childModel }],
459+
},
460+
});
461+
462+
const container = wrapper.find('[data-testid="container-children"]');
463+
expect(container.classes()).toContain("items-stretch");
464+
});
338465
});

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, ExternalLink } from "lucide-vue-next";
44
import type { NoteModel } from "./note-model";
55
import NoteTiptapEditor from "./NoteTiptapEditor.vue";
66
import { useNoteHeights } from "./useNoteHeights";
7+
import { CONTAINER_CONTENT_OFFSET_Y } from "./spatial-constants";
78
89
const props = defineProps<{
910
id: string;
@@ -14,6 +15,7 @@ const props = defineProps<{
1415
childModels?: Array<{ id: string; model: NoteModel }>;
1516
parentColor?: string | null;
1617
posOverride?: { x: number; y: number };
18+
isFlexChild?: boolean;
1719
}>();
1820
1921
const emit = defineEmits<{
@@ -64,12 +66,13 @@ const headFrag = computed(() => props.model.head.value.value);
6466
const bodyFrag = computed(() => props.model.body.value.value);
6567
6668
const transform = computed(() => {
67-
const pos = props.posOverride ?? props.model.pos.value;
68-
const style: Record<string, string | number> = {
69-
transform: `translate(${pos.x}px, ${pos.y}px)`,
70-
width: props.model.width.value.expanded === "Auto" ? "auto" : `${props.model.width.value.expanded}px`,
71-
zIndex: props.model.zIndex.value,
72-
};
69+
const style: Record<string, string | number> = {};
70+
if (!props.isFlexChild) {
71+
const pos = props.posOverride ?? props.model.pos.value;
72+
style.transform = `translate(${pos.x}px, ${pos.y}px)`;
73+
style.zIndex = props.model.zIndex.value;
74+
}
75+
style.width = props.model.width.value.expanded === "Auto" ? "auto" : `${props.model.width.value.expanded}px`;
7376
const color = resolvedColor.value;
7477
if (color) {
7578
style.borderColor = color;
@@ -78,18 +81,19 @@ const transform = computed(() => {
7881
return style;
7982
});
8083
81-
const containerLayoutClass = computed(() => {
82-
if (!props.model.container.enabled.value) return '';
83-
return props.model.container.horizontal.value ? 'flex-row' : 'flex-col';
84-
});
84+
const containerSpatial = computed(() => props.model.container.spatial.value);
85+
const containerHorizontal = computed(() => props.model.container.horizontal.value);
86+
const containerWrapChildren = computed(() => props.model.container.wrapChildren.value);
87+
const containerStretchChildren = computed(() => props.model.container.stretchChildren.value);
8588
8689
const isDragging = ref(false);
8790
8891
const frameClasses = computed(() => {
8992
const ro = props.model.readOnly.value;
9093
const movable = props.model.movable.value && !ro;
9194
return [
92-
"border-border bg-card text-card-foreground pointer-events-auto absolute top-0 left-0 rounded-md border shadow-sm select-none transition-opacity",
95+
"border-border bg-card text-card-foreground pointer-events-auto rounded-md border shadow-sm select-none transition-opacity",
96+
props.isFlexChild ? "relative flex-none" : "absolute top-0 left-0",
9397
ro ? "opacity-60 cursor-not-allowed" : "",
9498
isDragging.value ? "opacity-70" : "",
9599
movable ? "cursor-grab active:cursor-grabbing" : "cursor-default",
@@ -355,9 +359,18 @@ function onContextMenu(e: MouseEvent) {
355359
<!-- container children -->
356360
<template v-if="model.container.enabled.value && childModels?.length && !model.collapsing.collapsed.value">
357361
<div
358-
class="border-border pointer-events-none absolute inset-x-0 bottom-0 border-t"
359-
:class="model.container.horizontal.value ? 'left-0 right-0 top-0 bottom-0 border-t-0 border-l' : ''"
360-
:style="model.container.horizontal.value ? 'left: 100%; top: 0; width: auto; height: 100%;' : 'top: 3rem'"
362+
data-testid="container-children"
363+
class="absolute inset-x-0 bottom-0 overflow-visible"
364+
:class="[
365+
containerSpatial
366+
? ''
367+
: [
368+
containerHorizontal ? 'flex flex-row' : 'flex flex-col',
369+
containerWrapChildren ? 'flex-wrap' : 'flex-nowrap',
370+
containerStretchChildren ? 'items-stretch' : 'items-start',
371+
],
372+
]"
373+
:style="{ top: `${CONTAINER_CONTENT_OFFSET_Y}px` }"
361374
>
362375
<DisplayNote
363376
v-for="child in childModels"
@@ -366,6 +379,7 @@ function onContextMenu(e: MouseEvent) {
366379
:model="child.model"
367380
:zoom="zoom"
368381
:parent-color="resolvedColor"
382+
:is-flex-child="!containerSpatial"
369383
@dragend="$emit('dragend', $event)"
370384
/>
371385
</div>

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,16 @@ describe("MainToolbar", () => {
138138

139139
expect(wrapper.find('[data-testid="center-slot"]').exists()).toBe(true);
140140
});
141+
142+
it("renders actions slot content", () => {
143+
mockSession();
144+
wrapper = mount(MainToolbar, {
145+
props: { leftExpanded: true, rightExpanded: true },
146+
slots: {
147+
actions: h("button", { "data-testid": "action-btn" }, "Action"),
148+
},
149+
});
150+
151+
expect(wrapper.find('[data-testid="action-btn"]').exists()).toBe(true);
152+
});
141153
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ async function onLogout() {
6868
<slot />
6969
</div>
7070

71+
<!-- Page actions -->
72+
<div class="hidden flex-none items-center gap-1 pr-1 md:flex">
73+
<slot name="actions" />
74+
</div>
75+
7176
<!-- Right: global nav + theme + sidebar toggle -->
7277
<div class="flex flex-none items-center gap-1 pr-1">
7378
<nav

0 commit comments

Comments
 (0)