Skip to content

Commit 83c6271

Browse files
committed
docs: update spatial parity checklist with screenshot dialog implementation status and add ScreenshotDialog.test.ts (4 tests)
1 parent 8193dee commit 83c6271

9 files changed

Lines changed: 335 additions & 5 deletions

File tree

docs/SPATIAL_PARITY_CHECKLIST.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@
197197
| 12.23 | Floating UI — selection count | `FloatingUI/SelectionCount.vue` | `SpatialPageView.vue:selection count` | **Done** | `SpatialPageView.test.ts` |
198198
| 12.24 | Floating UI — find/replace toggle | `FloatingUI/FindReplace.vue` | `SpatialPageView.vue:Search button` | **Done** | `SpatialPageView.test.ts` |
199199
| 12.25 | Floating UI — back/forward nav | `FloatingUI/HistoryNav.vue` | `SpatialPageView.vue:router back/forward buttons` | **Done** | `SpatialPageView.test.ts` |
200-
| 12.26 | Floating UI — screenshot | `FloatingUI/Screenshot.vue` | | **Not started** | |
200+
| 12.26 | Floating UI — screenshot | `FloatingUI/Screenshot.vue` | `ScreenshotDialog.vue` | **Done** | `ScreenshotDialog.test.ts` (4 tests) |
201201
| 12.27 | Floating UI — user avatars on canvas | `FloatingUI/Avatars.vue` || **Not started** ||
202202

203203
## 13. Backlinks

docs/restart-plan/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DeepNotes Restart Plan — Index
22

3-
> **Last updated:** 2026-06-01 (Phase 6 in progress. **New this session:** Collab update squashing implemented in `useCollabPush.ts` (adaptive 1500ms debounce) with `useCollabPush.test.ts` (5 tests). Collab pagination limit capped at 100 rows. `PageCollabRoom` broadcast backpressure implemented (batches ≤ 10 sockets). Auth revocation implemented: DO `alarm()` re-verifies all sockets every 30 s via new `collab-ws-verify` internal endpoint; `webSocketMessage` closes socket with code `1008` on 403 from `collab-ws-append`. `page-collab-room.test.ts` expanded to 6 tests. See `phase-6-spatial-polish.md` and `SPATIAL_PARITY_CHECKLIST.md` for details. Phase 9 in progress.)
3+
> **Last updated:** 2026-06-01 (Phase 6 in progress. **New this session:** Screenshot floating UI implemented: `ScreenshotDialog.vue` with html2canvas (margin/scale options, `Alt+Shift+S` shortcut), `ScreenshotDialog.test.ts` (4 tests), and `SpatialPageView.test.ts` updated. Also includes prior session: Collab update squashing implemented in `useCollabPush.ts` (adaptive 1500ms debounce) with `useCollabPush.test.ts` (5 tests). Collab pagination limit capped at 100 rows. `PageCollabRoom` broadcast backpressure implemented (batches ≤ 10 sockets). Auth revocation implemented: DO `alarm()` re-verifies all sockets every 30 s via new `collab-ws-verify` internal endpoint; `webSocketMessage` closes socket with code `1008` on 403 from `collab-ws-append`. `page-collab-room.test.ts` expanded to 6 tests. See `phase-6-spatial-polish.md` and `SPATIAL_PARITY_CHECKLIST.md` for details. Phase 9 in progress.)
44
> **This document replaces `docs/RESTART_PLAN.md`.** If a prior statement conflicts with this one, this version wins.
55
66
---
@@ -92,7 +92,7 @@ A criterion is **not met** until the verification command or check passes in CI.
9292
- **Container rendering fully implemented.** `DisplayNote.vue` enforces spatial vs non-spatial layout, `stretchChildren`, and `wrapChildren`. `DisplayNote.test.ts` includes 5 dedicated container layout tests.
9393
- **`SpatialPageView.vue` refactored.** Keyboard shortcuts extracted to `useSpatialKeyboard.ts`. Box selection, arrow drag, arrow reconnect, and note drag extracted to dedicated composables. Note geometry utilities extracted to `note-geometry.ts`. Canvas actions (double-click, fit-to-screen) extracted to `useCanvasActions.ts`. Context menu handlers extracted to `useCanvasContextMenu.ts`. Component reduced from ~740 lines to ~260 lines.
9494
- **Selection partially improved.** `bringToTop` zIndex bump on selection is now implemented and tested. Formatting integration across selected editors, active element/region keyboard navigation, and `selectAll` including descendant arrows remain missing.
95-
- **Floating UI partially improved.** Back/forward nav buttons added to `SpatialPageView.vue`. Still missing: screenshot, user avatars on canvas.
95+
- **Floating UI partially improved.** Back/forward nav buttons and screenshot dialog added to `SpatialPageView.vue`. Still missing: user avatars on canvas.
9696

9797
### Other gaps
9898

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,13 @@ Achieve parity with the legacy `/pages/:pageId` immersive spatial canvas experie
105105
| Collapsing notes | **Done** | Chevron toggle + collapsed state wired |
106106
| Z-index ordering | **Done** | `notesByZIndex` computed sort |
107107
| Read-only notes | **Done** | `opacity-60 cursor-not-allowed` styling added |
108+
| Screenshot floating UI | **Done** | `ScreenshotDialog.vue` with html2canvas, margin/scale options, and `Alt+Shift+S` shortcut. `ScreenshotDialog.test.ts` (4 tests). |
108109

109110
---
110111

111112
## Verification
112113

113-
- [ ] Each deliverable has a test (unit, component, or integration). **Partially improved.** New tests this session: `PageToolbarActions.test.ts` (7), `SpatialPageView.test.ts` (8), `MainToolbar.test.ts` (7), `PageLayout.test.ts` (10), `RecentPagesCard.test.ts` (5), `FavoritePagesCard.test.ts` (5), `SelectedPagesCard.test.ts` (5), `useNoteContextMenu.test.ts` (5), `useCanvasActions.test.ts` (5), `DisplayNote.test.ts` (21), `DisplayArrow.test.ts` (12), `useCanvasContextMenu.test.ts` (6), `note-geometry.test.ts` (10), `useBoxSelection.test.ts` (6), `arrow-geometry.test.ts` (5), `useSpatialEditing.test.ts` (4), `selection.test.ts` (12), `useCollabPush.test.ts` (5). Remaining gaps: drag/resize end-to-end interaction, arrow creation/reconnection flow, sidebar properties cards.
114+
- [ ] Each deliverable has a test (unit, component, or integration). **Partially improved.** New tests this session: `PageToolbarActions.test.ts` (7), `SpatialPageView.test.ts` (8), `MainToolbar.test.ts` (7), `PageLayout.test.ts` (10), `RecentPagesCard.test.ts` (5), `FavoritePagesCard.test.ts` (5), `SelectedPagesCard.test.ts` (5), `useNoteContextMenu.test.ts` (5), `useCanvasActions.test.ts` (5), `DisplayNote.test.ts` (21), `DisplayArrow.test.ts` (12), `useCanvasContextMenu.test.ts` (6), `note-geometry.test.ts` (10), `useBoxSelection.test.ts` (6), `arrow-geometry.test.ts` (5), `useSpatialEditing.test.ts` (4), `selection.test.ts` (12), `useCollabPush.test.ts` (5), `ScreenshotDialog.test.ts` (4). Remaining gaps: drag/resize end-to-end interaction, arrow creation/reconnection flow, sidebar properties cards, user avatars on canvas.
114115
- [ ] Phase 1 checklist is >80% marked done. **NOT MET.** Strict enforcement of the checklist's "Done = implemented + passing test" rule drops the true completion rate well below 80%.
115116

116117
---

new-deepnotes/apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"class-variance-authority": "^0.7.1",
4747
"clsx": "^2.1.1",
4848
"highlight.js": "^11.11.1",
49+
"html2canvas": "^1.4.1",
4950
"katex": "0.16.22",
5051
"lowlight": "3.3.0",
5152
"lucide-vue-next": "^1.0.0",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { mount } from "@vue/test-utils";
3+
4+
import ScreenshotDialog from "./ScreenshotDialog.vue";
5+
6+
vi.mock("html2canvas", () => ({
7+
default: vi.fn(() =>
8+
Promise.resolve({
9+
toDataURL: () => "data:image/png;base64,test",
10+
}),
11+
),
12+
}));
13+
14+
function createMockNote(id: string, x: number, y: number) {
15+
return {
16+
id,
17+
model: {
18+
pos: { value: { x, y } },
19+
width: { value: { expanded: "200px", collapsed: "Auto" } },
20+
head: {
21+
enabled: { value: true },
22+
height: { value: { expanded: "Auto", collapsed: "Auto" } },
23+
},
24+
body: {
25+
enabled: { value: false },
26+
height: { value: { expanded: "Auto", collapsed: "Auto" } },
27+
},
28+
},
29+
};
30+
}
31+
32+
describe("ScreenshotDialog", () => {
33+
afterEach(() => {
34+
document.querySelectorAll("[data-testid='screenshot-margin']").forEach((el) => el.remove());
35+
document.querySelectorAll("[data-testid='screenshot-scale']").forEach((el) => el.remove());
36+
document.querySelectorAll(".bg-black/50").forEach((el) => el.remove());
37+
});
38+
39+
function mountComponent(props: Record<string, unknown> = {}) {
40+
return mount(ScreenshotDialog, {
41+
props: {
42+
open: true,
43+
canvasElement: null,
44+
selectedNoteIds: [],
45+
notes: [],
46+
zoom: 1,
47+
camX: 0,
48+
camY: 0,
49+
...props,
50+
},
51+
attachTo: document.body,
52+
});
53+
}
54+
55+
it("renders when open", () => {
56+
mountComponent();
57+
expect(document.body.textContent).toContain("Take Screenshot");
58+
});
59+
60+
it("does not render when closed", () => {
61+
mountComponent({ open: false });
62+
expect(document.body.querySelector(".bg-black/50")).toBeNull();
63+
});
64+
65+
it("renders cancel and download buttons", () => {
66+
mountComponent();
67+
const buttons = document.body.querySelectorAll("button");
68+
const texts = Array.from(buttons).map((b) => b.textContent);
69+
expect(texts.some((t) => t?.includes("Cancel"))).toBe(true);
70+
expect(texts.some((t) => t?.includes("Download"))).toBe(true);
71+
});
72+
73+
it("renders margin and scale inputs with defaults", () => {
74+
mountComponent();
75+
const marginInput = document.body.querySelector('[data-testid="screenshot-margin"]') as HTMLInputElement;
76+
const scaleInput = document.body.querySelector('[data-testid="screenshot-scale"]') as HTMLInputElement;
77+
expect(marginInput).not.toBeNull();
78+
expect(scaleInput).not.toBeNull();
79+
expect(marginInput.value).toBe("100");
80+
expect(scaleInput.value).toBe("100");
81+
});
82+
});
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from "vue";
3+
import { Camera, X } from "lucide-vue-next";
4+
import { Button } from "@/components/ui/button";
5+
import { Label } from "@/components/ui/label";
6+
import {
7+
Card,
8+
CardContent,
9+
CardHeader,
10+
CardTitle,
11+
} from "@/components/ui/card";
12+
import html2canvas from "html2canvas";
13+
import type { NoteModel } from "./note-model";
14+
15+
const props = defineProps<{
16+
open: boolean;
17+
canvasElement: HTMLElement | null;
18+
selectedNoteIds: string[];
19+
notes: { id: string; model: NoteModel }[];
20+
zoom: number;
21+
camX: number;
22+
camY: number;
23+
}>();
24+
25+
const emit = defineEmits<{
26+
close: [];
27+
}>();
28+
29+
const margin = ref(100);
30+
const scale = ref(100);
31+
32+
function handleClose() {
33+
emit("close");
34+
}
35+
36+
const selectedNotes = computed(() =>
37+
props.notes.filter((n) => props.selectedNoteIds.includes(n.id)),
38+
);
39+
40+
function getNoteBounds() {
41+
if (selectedNotes.value.length === 0) return null;
42+
43+
let minX = Infinity;
44+
let minY = Infinity;
45+
let maxX = -Infinity;
46+
let maxY = -Infinity;
47+
48+
for (const note of selectedNotes.value) {
49+
const x = note.model.pos.value.x;
50+
const y = note.model.pos.value.y;
51+
52+
let w = 200;
53+
const wStr = note.model.width.value.expanded;
54+
if (wStr !== "Auto" && wStr.endsWith("px")) {
55+
w = parseInt(wStr, 10);
56+
}
57+
58+
let h = 80;
59+
if (note.model.head.enabled.value) {
60+
const hStr = note.model.head.height.value.expanded;
61+
if (hStr !== "Auto" && hStr.endsWith("px")) {
62+
h = parseInt(hStr, 10);
63+
} else {
64+
h = 40;
65+
}
66+
}
67+
if (note.model.body.enabled.value) {
68+
const bStr = note.model.body.height.value.expanded;
69+
if (bStr !== "Auto" && bStr.endsWith("px")) {
70+
h += parseInt(bStr, 10);
71+
} else {
72+
h += 40;
73+
}
74+
}
75+
76+
minX = Math.min(minX, x);
77+
minY = Math.min(minY, y);
78+
maxX = Math.max(maxX, x + w);
79+
maxY = Math.max(maxY, y + h);
80+
}
81+
82+
return { minX, minY, maxX, maxY };
83+
}
84+
85+
async function takeScreenshot() {
86+
if (!props.canvasElement) return;
87+
88+
const bounds = getNoteBounds();
89+
const rect = props.canvasElement.getBoundingClientRect();
90+
const cx = rect.left + rect.width / 2;
91+
const cy = rect.top + rect.height / 2;
92+
93+
let worldMinX: number;
94+
let worldMinY: number;
95+
let worldMaxX: number;
96+
let worldMaxY: number;
97+
98+
if (bounds) {
99+
worldMinX = bounds.minX;
100+
worldMinY = bounds.minY;
101+
worldMaxX = bounds.maxX;
102+
worldMaxY = bounds.maxY;
103+
} else {
104+
// Capture visible viewport in world coordinates
105+
worldMinX = props.camX - rect.width / 2 / props.zoom;
106+
worldMinY = props.camY - rect.height / 2 / props.zoom;
107+
worldMaxX = props.camX + rect.width / 2 / props.zoom;
108+
worldMaxY = props.camY + rect.height / 2 / props.zoom;
109+
}
110+
111+
const m = margin.value;
112+
const z = props.zoom;
113+
114+
// Convert world bounds to screen coordinates relative to canvas element
115+
const screenMinX = (worldMinX - props.camX) * z + cx - rect.left;
116+
const screenMinY = (worldMinY - props.camY) * z + cy - rect.top;
117+
const screenMaxX = (worldMaxX - props.camX) * z + cx - rect.left;
118+
const screenMaxY = (worldMaxY - props.camY) * z + cy - rect.top;
119+
120+
const captureX = screenMinX - m * z;
121+
const captureY = screenMinY - m * z;
122+
const captureWidth = screenMaxX - screenMinX + m * z * 2;
123+
const captureHeight = screenMaxY - screenMinY + m * z * 2;
124+
125+
const canvas = await html2canvas(props.canvasElement, {
126+
scale: (1 / z) * (scale.value / 100),
127+
x: captureX,
128+
y: captureY,
129+
width: Math.round(captureWidth),
130+
height: Math.round(captureHeight),
131+
useCORS: true,
132+
allowTaint: true,
133+
backgroundColor: null,
134+
});
135+
136+
const dataUrl = canvas.toDataURL("image/png");
137+
138+
const link = document.createElement("a");
139+
link.href = dataUrl;
140+
link.download = "DeepNotes-screenshot.png";
141+
document.body.appendChild(link);
142+
link.click();
143+
document.body.removeChild(link);
144+
145+
handleClose();
146+
}
147+
</script>
148+
149+
<template>
150+
<Teleport to="body">
151+
<div
152+
v-if="open"
153+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
154+
@click.self="handleClose"
155+
>
156+
<Card class="w-full max-w-sm">
157+
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-4">
158+
<CardTitle>Take Screenshot</CardTitle>
159+
<Button variant="ghost" size="icon" @click="handleClose">
160+
<X class="h-4 w-4" />
161+
</Button>
162+
</CardHeader>
163+
164+
<CardContent class="space-y-4">
165+
<div class="space-y-2">
166+
<Label>Margin (px)</Label>
167+
<input
168+
v-model.number="margin"
169+
type="number"
170+
min="0"
171+
data-testid="screenshot-margin"
172+
class="border-input focus-visible:border-ring focus-visible:ring-ring/50 h-8 w-full rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors focus-visible:ring-3 md:text-sm min-w-0 outline-none"
173+
/>
174+
</div>
175+
176+
<div class="space-y-2">
177+
<Label>Scale (%)</Label>
178+
<input
179+
v-model.number="scale"
180+
type="number"
181+
min="10"
182+
max="500"
183+
data-testid="screenshot-scale"
184+
class="border-input focus-visible:border-ring focus-visible:ring-ring/50 h-8 w-full rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors focus-visible:ring-3 md:text-sm min-w-0 outline-none"
185+
/>
186+
</div>
187+
188+
<div class="flex justify-end gap-2">
189+
<Button variant="outline" @click="handleClose">Cancel</Button>
190+
<Button @click="takeScreenshot">
191+
<Camera class="mr-2 h-4 w-4" />
192+
Download
193+
</Button>
194+
</div>
195+
</CardContent>
196+
</Card>
197+
</div>
198+
</Teleport>
199+
</template>

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,18 @@ describe("SpatialPageView", () => {
172172
expect(titles).toContain("Find and Replace (Ctrl+F)");
173173
});
174174

175+
it("renders screenshot toggle button", () => {
176+
const { ydoc } = setupDocWithNotes();
177+
wrapper = mount(SpatialPageView, {
178+
props: { ydoc },
179+
global: { stubs: { Teleport: true } },
180+
});
181+
182+
const buttons = wrapper.findAll("button");
183+
const titles = buttons.map((b) => b.attributes("title"));
184+
expect(titles).toContain("Take Screenshot (Alt+Shift+S)");
185+
});
186+
175187
it("renders reset zoom and fit-to-screen buttons", () => {
176188
const { ydoc } = setupDocWithNotes();
177189
wrapper = mount(SpatialPageView, {

0 commit comments

Comments
 (0)