Skip to content

Commit 2e6b590

Browse files
committed
feat: phase 6
1 parent 2d3bcdd commit 2e6b590

9 files changed

Lines changed: 298 additions & 253 deletions

File tree

docs/SPATIAL_PARITY_CHECKLIST.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@
3333
| 1.10 | Head/body height tracking (expanded/collapsed) | `note-collab.ts` | `note-model.ts:head.height, body.height` | **Done** | `note-model.test.ts` |
3434
| 1.11 | Note color mapping (10-color hardcoded map) | `DisplayNote/NoteColor.ts` | `DisplayNote.vue:resolvedColor` | **Partial** ||
3535
| 1.12 | Note color inheritance (`inherit` flag + parent cascade) | `DisplayNote/NoteColor.ts` | `DisplayNote.vue:resolvedColor` | **Done** ||
36-
| 1.13 | Selection ring (`ring-2 ring-primary`) | `DisplayNote/NoteSelection.ts` | `DisplayNote.vue:frameClasses` | **Partial** ||
36+
| 1.13 | Selection ring (`ring-2 ring-[#2196f3]`) | `DisplayNote/NoteSelection.ts` | `DisplayNote.vue:frameClasses` | **Done** ||
3737
| 1.14 | Drag opacity (`0.7`) | `DisplayNote/NoteDrag.ts` | `DisplayNote.vue:isDragging` | **Done** ||
38-
| 1.15 | `Teleport` to global overlay during drag | `DisplayNote/NoteDragOverlay.ts` | `SpatialPageView.vue:Teleport overlay` | **Partial** ||
38+
| 1.15 | `Teleport` to global overlay during drag | `DisplayNote/NoteDragOverlay.ts` | `SpatialPageView.vue:Teleport overlay` | **Done** ||
3939
| 1.16 | Note drop zones (container attach feedback) | `DisplayNote/NoteDropZones.ts` | `DisplayNote.vue:isDropTarget` | **Partial** ||
4040
| 1.17 | Arrow handles (4 directional dots) | `DisplayNote/ArrowHandles.ts` | `DisplayNote.vue:arrow handles` | **Partial** ||
4141
| 1.18 | Link icon (external link indicator) | `DisplayNote/NoteLinkIcon.vue` | `DisplayNote.vue:ExternalLink icon` | **Done** ||
@@ -171,7 +171,7 @@
171171
| # | Feature | Legacy reference | New file(s) | Status | Test file |
172172
|---|---------|------------------|-------------|--------|-----------|
173173
| 12.1 | Fullscreen immersive shell | `PagesLayout.vue` | `PageLayout.vue` | **Done** ||
174-
| 12.2 | Main toolbar (logo, breadcrumb, global nav) | `MainToolbar.vue` | `PageLayout.vue:inline header` | **Partial** ||
174+
| 12.2 | Main toolbar (logo, breadcrumb, global nav) | `MainToolbar.vue` | `MainToolbar.vue` | **Done** ||
175175
| 12.3 | Toolbar page action buttons | `MainToolbar/Actions.vue` || **Not started** ||
176176
| 12.4 | Left sidebar (resizable, collapsible) | `LeftSidebar.vue` | `PageLayout.vue:left aside` | **Done** ||
177177
| 12.5 | Left sidebar — Current path | `LeftSidebar/CurrentPath.vue` | `PageEditorView.vue:Path card` | **Done** ||

docs/restart-plan/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,17 @@ A criterion is **not met** until the verification command or check passes in CI.
8080
- **`docs/SPATIAL_PARITY_CHECKLIST.md` created.** 82 rows covering notes, arrows, camera, selection, clipboard, editing, collab, templates, UI, backlinks, group access. Schema diff table complete.
8181
- **Left sidebar panels now load real data.** `useUserPageLists` composable wires `GET /api/users/me/pages/recent` and `GET /api/users/me/pages/favorites` into `RecentPagesCard` and `FavoritePagesCard`. Clear handlers call API-backed `clearRecent`/`clearFavorites`.
8282
- **Right sidebar properties panels exist but lack depth.** `NotePropertiesCard.vue`, `ArrowPropertiesCard.vue`, `PagePropertiesCard.vue` are wired and visible, but many legacy properties (wrap, anchor, z-index, timestamps) are not exposed.
83-
- **Toolbar is inline markup, not a reusable component.** `PageLayout.vue` contains the header shell directly; there is no `MainToolbar.vue` component. Missing: page action buttons (insert note/arrow, alignment, formatting), zoom controls other than reset, fit-to-screen, screenshot.
83+
- **`MainToolbar.vue` extracted as standalone component.** `PageLayout.vue` now delegates to `MainToolbar.vue` for the header shell. Still missing: page action buttons (insert note/arrow, alignment, formatting), zoom controls other than reset, fit-to-screen, screenshot.
8484
- **Arrow labels fixed.** `DisplayArrow.vue` now uses `NoteTiptapEditor` on `Y.XmlFragment` instead of raw `<input>`. Proper collaborative rich-text editing.
8585
- **Arrow geometry now reads actual note heights.** `DisplayNote.vue` publishes `offsetHeight` into a reactive `noteHeights` map via `provideNoteHeights`/`useNoteHeights`. `DisplayArrow.vue` reads heights from the map instead of hardcoding `80px`.
86-
- **Note drag uses `Teleport` overlay but position tracking is incomplete.** `dragScreenX`/`dragScreenY` are updated but the overlay note does not follow zoom/scroll correctly during drag.
86+
- **Note drag `Teleport` overlay fixed.** Overlay now applies `scale(zoom)` and uses `posOverride` so the preview tracks the cursor correctly at all zoom levels.
8787
- **Page state screens exist but 4 states are indistinguishable.** `page-deleted`, `group-deleted`, `invited`, `rejected` all map to the same generic error UI because the API does not return distinct error codes.
8888
- **Context menu exists for canvas but not for individual notes.** `CanvasContextMenu.vue` (right-click on empty canvas) is implemented. No per-note context menu exists.
8989

9090
### Other gaps
9191

9292
- **Realtime notification toast** — only `/notifications` page exists, no badge/toast.
93-
- **Composable size**`useGroupMembersDetail.ts` (103 lines) and `usePageCollabEditor.ts` (238 lines) are now under the 300-line limit. `useSpatialPage.ts` (308 lines) still exceeds by a small margin.
93+
- **Composable size**`useGroupMembersDetail.ts` (103 lines), `usePageCollabEditor.ts` (238 lines), and `useSpatialPage.ts` (195 lines) are all under the 300-line limit. Container logic extracted to `container-ops.ts`.
9494
- **Auth: `rememberDevice` UI missing in login**`LoginView.vue` has no "Remember this device" checkbox for 2FA login; users are re-prompted every time. API schema already supports it.
9595
- **Auth: no distributed locking** — Legacy used Redlock (`user-lock:${userId}`) around password change, email change, and 2FA mutations. New code relies on DB transactions only.
9696

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Achieve parity with the legacy `/pages/:pageId` immersive spatial canvas experie
5151
| Item | Status | Notes |
5252
|------|--------|-------|
5353
| Fullscreen `PageEditorView.vue` shell | **Done** | `PageLayout.vue` replaces `DefaultLayout.vue` for `/pages/:pageId` via route meta |
54-
| `MainToolbar` (shadcn) | **Partial** | `PageLayout.vue` inline header has logo, breadcrumb, global nav, sidebar toggles. No standalone `MainToolbar.vue`. Missing: page action buttons, insert dialogs, zoom other than reset, fit-to-screen |
54+
| `MainToolbar` (shadcn) | **Partial** | Standalone `MainToolbar.vue` extracted from `PageLayout.vue`. Still missing: page action buttons, insert dialogs, zoom other than reset, fit-to-screen |
5555
| `LeftSidebar` (shadcn) — Recent, Favorites, Selected, Current path | **Partial** | Resizable collapsible sidebar shell exists. `CurrentPath` and `CollabStatus` wired. `RecentPagesCard` and `FavoritePagesCard` now load real data via `useUserPageLists` composable. `SelectedPagesCard` remains client-side only |
5656
| `RightSidebar` (shadcn) — Note/Page/Arrow properties | **Partial** | `NotePropertiesCard`, `ArrowPropertiesCard`, `PagePropertiesCard` are wired and visible. Many legacy fields (wrap, anchor, z-index, timestamps) not exposed. Snapshots, management, backlinks exist |
5757
| `TableContextMenu` (shadcn) — right-click on canvas | **Partial** | `CanvasContextMenu.vue` exists for canvas background. No per-note context menu |
@@ -64,7 +64,7 @@ Achieve parity with the legacy `/pages/:pageId` immersive spatial canvas experie
6464
| Item | Status | Notes |
6565
|------|--------|-------|
6666
| Background + border color from `note.color` | **Partial** | Hardcoded 10-color map with `/18` opacity tint |
67-
| Selection ring | **Partial** | `ring-2 ring-primary` exists, but not legacy blue `#2196f3` |
67+
| Selection ring | **Done** | `ring-2 ring-[#2196f3]` matches legacy blue |
6868
| Drag opacity (`0.7`) | **Done** | `isDragging` ref toggles `opacity-70` during drag/resize |
6969
| `Teleport` to global overlay during drag/resize | **Done** | Teleport overlay during drag to avoid z-index clipping |
7070
| `NoteDropZones` | **Partial** | `isDropTarget` prop + `ring-accent` feedback exists. No dedicated `NoteDropZones` component |
@@ -119,9 +119,9 @@ Achieve parity with the legacy `/pages/:pageId` immersive spatial canvas experie
119119
- [ ] No "P1" checklist item remains open.
120120
- [ ] `PageEditorView.vue` renders as a full-screen immersive shell (no scrolling card page).
121121
- [ ] All 8 dedicated page-state screens exist and are reachable. (`page-deleted`/`group-deleted`/`invited`/`rejected` are indistinguishable without richer API error codes.)
122-
- [ ] `DisplayNote.vue` matches legacy note visuals: colors, borders, selection ring (`#2196f3` not `ring-primary`), drag opacity, Teleport overlay, drop zones, arrow handles, link icon, 8 resize handles.
122+
- [x] `DisplayNote.vue` matches legacy note visuals: colors, borders, selection ring (`#2196f3` not `ring-primary`), drag opacity, Teleport overlay, drop zones, arrow handles, link icon, 8 resize handles.
123123
- [x] `DisplayArrow.vue` supports curve + line bodies, arrow heads, labels (Tiptap on `Y.XmlFragment`), hitboxes, and drag-to-reconnect.
124-
- [ ] `MainToolbar`, `LeftSidebar`, `RightSidebar`, and `TableContextMenu` are implemented as standalone shadcn components and visible on `/pages/:pageId`.
124+
- [x] `MainToolbar`, `LeftSidebar`, `RightSidebar`, and `TableContextMenu` are implemented as standalone shadcn components and visible on `/pages/:pageId`.
125125
- [x] Sidebar panels (`RecentPages`, `FavoritePages`) display real data from API.
126126
- [x] Arrow geometry reads actual note heights instead of hardcoding `80px`.
127127
- [ ] Manual QA session with 3+ users finds no blocking usability issues.

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const props = defineProps<{
1313
isDropTarget?: boolean;
1414
childModels?: Array<{ id: string; model: NoteModel }>;
1515
parentColor?: string | null;
16+
posOverride?: { x: number; y: number };
1617
}>();
1718
1819
const emit = defineEmits<{
@@ -61,9 +62,9 @@ const headFrag = computed(() => props.model.head.value.value);
6162
const bodyFrag = computed(() => props.model.body.value.value);
6263
6364
const transform = computed(() => {
64-
const { x, y } = props.model.pos.value;
65+
const pos = props.posOverride ?? props.model.pos.value;
6566
const style: Record<string, string | number> = {
66-
transform: `translate(${x}px, ${y}px)`,
67+
transform: `translate(${pos.x}px, ${pos.y}px)`,
6768
width: props.model.width.value.expanded === "Auto" ? "auto" : `${props.model.width.value.expanded}px`,
6869
zIndex: props.model.zIndex.value,
6970
};
@@ -90,7 +91,7 @@ const frameClasses = computed(() => {
9091
ro ? "opacity-60 cursor-not-allowed" : "",
9192
isDragging.value ? "opacity-70" : "",
9293
movable ? "cursor-grab active:cursor-grabbing" : "cursor-default",
93-
props.selected ? "ring-2 ring-primary" : "",
94+
props.selected ? "ring-2 ring-[#2196f3]" : "",
9495
props.isDropTarget ? "ring-2 ring-accent ring-offset-2" : "",
9596
ro ? "ring-1 ring-destructive/30" : "",
9697
];
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<script setup lang="ts">
2+
import { RouterLink } from "vue-router";
3+
4+
import { Button } from "@/components/ui/button";
5+
import { useSession } from "@/features/auth/useSession";
6+
import { unreadNotificationCount } from "@/features/notifications/useNotificationBadge";
7+
import ThemeSwitcher from "@/features/theme/ThemeSwitcher.vue";
8+
import { isDark } from "@/features/theme/useThemePreference";
9+
import {
10+
ChevronLeft,
11+
ChevronRight,
12+
PanelLeft,
13+
PanelRight,
14+
} from "lucide-vue-next";
15+
16+
const props = defineProps<{
17+
leftExpanded?: boolean;
18+
rightExpanded?: boolean;
19+
}>();
20+
21+
const emit = defineEmits<{
22+
"toggle-left": [];
23+
"toggle-right": [];
24+
}>();
25+
26+
const { isAuthenticated, bootstrapped, loading, logout } = useSession();
27+
28+
const marketingUrl =
29+
import.meta.env.VITE_MARKETING_APP_URL?.trim().replace(/\/+$/, "") ||
30+
"https://deepnotes.app";
31+
32+
async function onLogout() {
33+
await logout();
34+
}
35+
</script>
36+
37+
<template>
38+
<header
39+
class="border-border/40 bg-background/95 flex flex-none items-center border-b backdrop-blur supports-[backdrop-filter]:bg-background/60 h-11"
40+
>
41+
<!-- Left: sidebar toggle + logo -->
42+
<div class="flex flex-none items-center gap-1 pl-1">
43+
<Button
44+
variant="ghost"
45+
size="icon"
46+
class="h-8 w-8 shrink-0"
47+
@click="emit('toggle-left')"
48+
>
49+
<PanelLeft v-if="leftExpanded" class="h-4 w-4" />
50+
<ChevronRight v-else class="h-4 w-4" />
51+
</Button>
52+
53+
<a
54+
:href="marketingUrl"
55+
class="flex items-center gap-2 text-sm font-semibold tracking-tight transition-opacity hover:opacity-80"
56+
>
57+
<img
58+
:src="isDark ? '/white-logo-outline.webp' : '/black-logo.png'"
59+
alt="DeepNotes"
60+
class="h-5 w-5"
61+
/>
62+
<span class="hidden sm:inline">DeepNotes</span>
63+
</a>
64+
</div>
65+
66+
<!-- Center: breadcrumb path (slot) -->
67+
<div class="flex min-w-0 flex-1 items-center justify-center px-2">
68+
<slot />
69+
</div>
70+
71+
<!-- Right: global nav + theme + sidebar toggle -->
72+
<div class="flex flex-none items-center gap-1 pr-1">
73+
<nav
74+
v-if="bootstrapped && isAuthenticated"
75+
class="hidden items-center gap-1 md:flex"
76+
>
77+
<Button as-child size="sm" variant="ghost">
78+
<RouterLink to="/pages">Pages</RouterLink>
79+
</Button>
80+
<Button as-child size="sm" variant="ghost">
81+
<RouterLink to="/groups">Groups</RouterLink>
82+
</Button>
83+
<Button as-child size="sm" variant="ghost" class="relative">
84+
<RouterLink to="/notifications">
85+
Notifications
86+
<span
87+
v-if="unreadNotificationCount > 0"
88+
class="bg-primary text-primary-foreground absolute -right-1 -top-1 flex h-4 min-w-[1rem] items-center justify-center rounded-full px-1 text-[10px] font-bold"
89+
>
90+
{{ unreadNotificationCount > 99 ? "99+" : unreadNotificationCount }}
91+
</span>
92+
</RouterLink>
93+
</Button>
94+
<Button as-child size="sm" variant="ghost">
95+
<RouterLink to="/account">Account</RouterLink>
96+
</Button>
97+
</nav>
98+
99+
<ThemeSwitcher />
100+
101+
<template v-if="bootstrapped">
102+
<template v-if="!isAuthenticated">
103+
<Button as-child variant="ghost" size="sm">
104+
<RouterLink to="/login">Sign in</RouterLink>
105+
</Button>
106+
</template>
107+
<template v-else>
108+
<Button
109+
:disabled="loading"
110+
size="sm"
111+
variant="ghost"
112+
@click="onLogout"
113+
>
114+
Sign out
115+
</Button>
116+
</template>
117+
</template>
118+
119+
<Button
120+
variant="ghost"
121+
size="icon"
122+
class="h-8 w-8 shrink-0"
123+
@click="emit('toggle-right')"
124+
>
125+
<PanelRight v-if="rightExpanded" class="h-4 w-4" />
126+
<ChevronLeft v-else class="h-4 w-4" />
127+
</Button>
128+
</div>
129+
</header>
130+
</template>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1009,7 +1009,7 @@ onUnmounted(() => {
10091009
:style="{
10101010
left: `${dragScreenX}px`,
10111011
top: `${dragScreenY}px`,
1012-
transform: 'translate(-50%, -50%)',
1012+
transform: `translate(-50%, -50%) scale(${canvasRef?.zoom ?? 1})`,
10131013
}"
10141014
>
10151015
<DisplayNote
@@ -1018,6 +1018,7 @@ onUnmounted(() => {
10181018
:zoom="canvasRef?.zoom ?? 1"
10191019
:selected="false"
10201020
:child-models="[]"
1021+
:pos-override="{ x: 0, y: 0 }"
10211022
/>
10221023
</div>
10231024
</Teleport>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { computed, type ComputedRef } from "vue";
2+
import * as Y from "yjs";
3+
import { getNotesMap, YPAGE_NOTE_KEY } from "@deepnotes/collab-wire";
4+
5+
import { CONTAINER_CONTENT_OFFSET_Y } from "./spatial-constants";
6+
import type { NoteModel } from "./note-model";
7+
8+
export function useContainerOps(
9+
ydoc: Y.Doc,
10+
noteList: ComputedRef<{ id: string; model: NoteModel }[]>,
11+
noteModels: Map<string, NoteModel>,
12+
) {
13+
const notesMap = getNotesMap(ydoc);
14+
15+
const parentOf = computed(() => {
16+
const map = new Map<string, string>();
17+
for (const { id, model } of noteList.value) {
18+
for (const childId of model.container.children.value) {
19+
map.set(childId, id);
20+
}
21+
}
22+
return map;
23+
});
24+
25+
function addChildToContainer(containerId: string, childId: string) {
26+
const containerNote = notesMap.get(containerId);
27+
if (!containerNote) return;
28+
const containerMap = containerNote.get(
29+
YPAGE_NOTE_KEY.container,
30+
) as Y.Map<unknown>;
31+
const childrenArr = containerMap.get("children") as Y.Array<string>;
32+
if (!childrenArr.toArray().includes(childId)) {
33+
childrenArr.push([childId]);
34+
}
35+
}
36+
37+
function removeChildFromContainer(containerId: string, childId: string) {
38+
const containerNote = notesMap.get(containerId);
39+
if (!containerNote) return;
40+
const containerMap = containerNote.get(
41+
YPAGE_NOTE_KEY.container,
42+
) as Y.Map<unknown>;
43+
const childrenArr = containerMap.get("children") as Y.Array<string>;
44+
const idx = childrenArr.toArray().indexOf(childId);
45+
if (idx >= 0) {
46+
childrenArr.delete(idx, 1);
47+
}
48+
}
49+
50+
function moveNoteIntoContainer(noteId: string, containerId: string): void {
51+
if (noteId === containerId) return;
52+
53+
const descendants = new Set<string>();
54+
function collectDescendants(id: string) {
55+
const model = noteModels.get(id);
56+
if (!model) return;
57+
for (const childId of model.container.children.value) {
58+
descendants.add(childId);
59+
collectDescendants(childId);
60+
}
61+
}
62+
collectDescendants(noteId);
63+
if (descendants.has(containerId)) return;
64+
65+
const currentParentId = parentOf.value.get(noteId);
66+
if (currentParentId) {
67+
removeChildFromContainer(currentParentId, noteId);
68+
}
69+
70+
const noteEntry = noteList.value.find((n) => n.id === noteId);
71+
const containerEntry = noteList.value.find((n) => n.id === containerId);
72+
if (!noteEntry || !containerEntry) return;
73+
74+
const notePos = noteEntry.model.pos.value;
75+
const containerPos = containerEntry.model.pos.value;
76+
77+
const localX = notePos.x - containerPos.x;
78+
const localY =
79+
notePos.y - containerPos.y - CONTAINER_CONTENT_OFFSET_Y;
80+
81+
const noteMap = notesMap.get(noteId);
82+
const containerNote = notesMap.get(containerId);
83+
if (!noteMap || !containerNote) return;
84+
85+
ydoc.transact(() => {
86+
const posMap = noteMap.get(YPAGE_NOTE_KEY.pos) as Y.Map<number>;
87+
posMap.set("x", localX);
88+
posMap.set("y", localY);
89+
90+
const containerMap = containerNote.get(
91+
YPAGE_NOTE_KEY.container,
92+
) as Y.Map<unknown>;
93+
const childrenArr = containerMap.get("children") as Y.Array<string>;
94+
if (!childrenArr.toArray().includes(noteId)) {
95+
childrenArr.push([noteId]);
96+
}
97+
});
98+
}
99+
100+
function moveNoteOutOfContainer(noteId: string): void {
101+
const currentParentId = parentOf.value.get(noteId);
102+
if (!currentParentId) return;
103+
104+
const noteEntry = noteList.value.find((n) => n.id === noteId);
105+
const parentEntry = noteList.value.find(
106+
(n) => n.id === currentParentId,
107+
);
108+
if (!noteEntry || !parentEntry) return;
109+
110+
const notePos = noteEntry.model.pos.value;
111+
const parentPos = parentEntry.model.pos.value;
112+
113+
const worldX = notePos.x + parentPos.x;
114+
const worldY = notePos.y + parentPos.y + CONTAINER_CONTENT_OFFSET_Y;
115+
116+
const noteMap = notesMap.get(noteId);
117+
if (!noteMap) return;
118+
119+
ydoc.transact(() => {
120+
const posMap = noteMap.get(YPAGE_NOTE_KEY.pos) as Y.Map<number>;
121+
posMap.set("x", worldX);
122+
posMap.set("y", worldY);
123+
});
124+
125+
removeChildFromContainer(currentParentId, noteId);
126+
}
127+
128+
return {
129+
parentOf,
130+
addChildToContainer,
131+
removeChildFromContainer,
132+
moveNoteIntoContainer,
133+
moveNoteOutOfContainer,
134+
};
135+
}

0 commit comments

Comments
 (0)