Skip to content

Commit daa8e71

Browse files
committed
feat: add reverse children and import children features to note containers
- Add marked v18.0.4 dependency for markdown parsing - Implement reverseChildren function to reverse order of container children - Add importChildrenFromFiles function to create child notes from text/markdown files - Add "Reverse children" and "Import children" buttons to NotePropertiesCard - Export serializeNote/serializeArrow from clipboard.ts and add cloneSelection helper - Wire up reverse-children and import-children events
1 parent 65350a5 commit daa8e71

17 files changed

Lines changed: 486 additions & 27 deletions

new-deepnotes/apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"html2canvas": "^1.4.1",
5252
"katex": "0.16.22",
5353
"lowlight": "3.3.0",
54+
"marked": "^18.0.4",
5455
"msgpackr": "^1.11.2",
5556
"nanoid": "^5.1.5",
5657
"openapi-fetch": "^0.17.0",

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,19 @@ async function handleSetNoteAsDefault() {
440440
}
441441
}
442442
443+
function handleReverseChildren() {
444+
if (!selectedNoteId.value) return;
445+
spatialViewRef.value?.reverseChildren?.(selectedNoteId.value);
446+
}
447+
448+
async function handleImportChildren(files: FileList) {
449+
if (!selectedNoteId.value) return;
450+
await spatialViewRef.value?.importChildrenFromFiles?.(
451+
selectedNoteId.value,
452+
Array.from(files),
453+
);
454+
}
455+
443456
async function handleCreateNewPage() {
444457
pageOpsMessage.value = null;
445458
const id = pageId.value;
@@ -799,6 +812,8 @@ onMounted(() => {
799812
@swap-head-body="handleSwapHeadBody"
800813
@copy-link="handleCopyNoteLink"
801814
@set-as-default="handleSetNoteAsDefault"
815+
@reverse-children="handleReverseChildren"
816+
@import-children="handleImportChildren"
802817
/>
803818

804819
<ArrowPropertiesCard

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,22 @@ describe("MainToolbar", () => {
9797
expect(wrapper.emitted("toggle-right")).toHaveLength(1);
9898
});
9999

100-
it("shows notification icon link when authenticated and bootstrapped", () => {
100+
it("shows notification icon button (inline popup) when authenticated and bootstrapped", () => {
101101
mockSession({ isAuthenticated: true, bootstrapped: true });
102102
wrapper = mount(MainToolbar, {
103103
props: { leftExpanded: true, rightExpanded: true },
104104
});
105105

106106
const links = wrapper.findAll('[data-testid="router-link"]');
107107
const toValues = links.map((l) => l.attributes("data-to"));
108-
expect(toValues).toContain("/notifications");
108+
// Notifications now open an inline popup, not a router link
109+
expect(toValues).not.toContain("/notifications");
109110
expect(toValues).not.toContain("/pages");
110111
expect(toValues).not.toContain("/groups");
112+
113+
// Verify the notification popover component is rendered
114+
const popover = wrapper.findComponent({ name: "NotificationsPopover" });
115+
expect(popover.exists()).toBe(true);
111116
});
112117

113118
it("shows sign in link when not authenticated", () => {

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue'
2+
import { computed, ref } from 'vue'
33
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
44
import { Button } from '@/components/ui/button'
55
import { Input } from '@/components/ui/input'
@@ -13,7 +13,7 @@ import {
1313
SelectTrigger,
1414
SelectValue,
1515
} from '@/components/ui/select'
16-
import { ExternalLink, Copy, ArrowUpDown, FilePlus, Save, Download } from '@lucide/vue'
16+
import { ExternalLink, Copy, ArrowUpDown, FilePlus, Save, Download, ArrowDownUp, Import } from '@lucide/vue'
1717
import ColorPalette from '@/components/ColorPalette.vue'
1818
import { getNoteEditor } from './note-editor-registry'
1919
import TurndownService from 'turndown'
@@ -55,6 +55,8 @@ const emit = defineEmits<{
5555
'swap-head-body': []
5656
'copy-link': []
5757
'set-as-default': []
58+
'reverse-children': []
59+
'import-children': [files: FileList]
5860
}>()
5961
6062
const link = computed(() => props.noteModel?.link?.value ?? '')
@@ -98,6 +100,24 @@ function handleCopyLink() {
98100
navigator.clipboard.writeText(url)
99101
}
100102
103+
const fileInput = ref<HTMLInputElement | null>(null)
104+
105+
function handleReverseChildren() {
106+
emit('reverse-children')
107+
}
108+
109+
function handleImportClick() {
110+
fileInput.value?.click()
111+
}
112+
113+
function handleFileChange(event: Event) {
114+
const target = event.target as HTMLInputElement
115+
if (target.files && target.files.length > 0) {
116+
emit('import-children', target.files)
117+
target.value = ''
118+
}
119+
}
120+
101121
function handleColorSelect(colorName: string) {
102122
emit('update:color', colorName)
103123
emit('update:color-inherit', false)
@@ -588,6 +608,34 @@ function exportAsMarkdown(download: boolean) {
588608
/>
589609
<Label>Force color inheritance</Label>
590610
</div>
611+
<div class="flex items-center gap-2 pl-6 pt-1">
612+
<Button
613+
variant="outline"
614+
size="sm"
615+
:disabled="readOnly || !containerEnabled || (props.noteModel?.container?.children?.value?.length ?? 0) < 2"
616+
@click="handleReverseChildren"
617+
>
618+
<ArrowDownUp class="mr-1 h-3 w-3" />
619+
Reverse children
620+
</Button>
621+
<Button
622+
variant="outline"
623+
size="sm"
624+
:disabled="readOnly || (containerEnabled && containerSpatial)"
625+
@click="handleImportClick"
626+
>
627+
<Import class="mr-1 h-3 w-3" />
628+
Import children
629+
</Button>
630+
<input
631+
ref="fileInput"
632+
type="file"
633+
accept=".txt,.md"
634+
multiple
635+
class="hidden"
636+
@change="handleFileChange"
637+
/>
638+
</div>
591639
</div>
592640

593641
<!-- Movable/Resizable -->

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,15 @@ describe("SpatialPageView", () => {
137137
expect(titles).toContain("Redo");
138138
});
139139

140-
it("renders toolbar with zoom and fit-to-screen buttons", () => {
140+
it("renders floating camera buttons with zoom and fit-to-screen", () => {
141141
const { ydoc } = setupDocWithNotes();
142142
wrapper = mount(SpatialPageView, {
143143
props: { ydoc },
144144
global: { stubs: { Teleport: true } },
145145
});
146146

147-
const buttons = wrapper.findAll("button");
148-
const titles = buttons.map((b) => b.attributes("title"));
149-
expect(titles).toContain("Zoom in");
150-
expect(titles).toContain("Zoom out");
151-
expect(titles).toContain("Fit to screen");
147+
// Zoom/reset/fit buttons moved from toolbar to FloatingCameraButtons
148+
const cameraButtons = wrapper.findComponent({ name: "FloatingCameraButtons" });
149+
expect(cameraButtons.exists()).toBe(true);
152150
});
153151
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ const {
6666
createArrow,
6767
moveNoteIntoContainer,
6868
moveNoteOutOfContainer,
69+
reverseChildren,
70+
importChildrenFromFiles,
6971
} = useSpatialPage(props.ydoc, undoRedo);
7072
7173
function getNoteZIndex(id: string): number {
@@ -214,6 +216,8 @@ defineExpose({
214216
zoomIn,
215217
zoomOut,
216218
fitToScreen,
219+
reverseChildren,
220+
importChildrenFromFiles,
217221
});
218222
219223
const {

new-deepnotes/apps/web/src/features/spatial/clipboard.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ async function readFromClipboard(): Promise<ClipboardPayload | null> {
130130
return null;
131131
}
132132

133-
function serializeNote(model: NoteModel, id: string): ClipboardNote {
133+
export function serializeNote(model: NoteModel, id: string): ClipboardNote {
134134
return {
135135
id,
136136
pos: model.pos.value,
@@ -174,7 +174,7 @@ function serializeNote(model: NoteModel, id: string): ClipboardNote {
174174
};
175175
}
176176

177-
function serializeArrow(model: ArrowModel, id: string): ClipboardArrow {
177+
export function serializeArrow(model: ArrowModel, id: string): ClipboardArrow {
178178
return {
179179
id,
180180
source: model.source.value,
@@ -299,3 +299,24 @@ export function pastePayload(
299299

300300
return { noteIds, arrowIds };
301301
}
302+
303+
export function cloneSelection(
304+
noteEntries: { id: string; model: NoteModel }[],
305+
arrowEntries: { id: string; model: ArrowModel }[],
306+
options: {
307+
createNote: (worldX: number, worldY: number, template?: Partial<ClipboardNote>) => string;
308+
createArrow: (sourceId: string, targetId: string, template?: Partial<ClipboardArrow>) => string;
309+
offsetX?: number;
310+
offsetY?: number;
311+
},
312+
): { noteIds: string[]; arrowIds: string[] } {
313+
const noteIds = new Set(noteEntries.map((n) => n.id));
314+
const arrows = arrowEntries
315+
.filter(
316+
(a) => noteIds.has(a.model.source.value) && noteIds.has(a.model.target.value),
317+
)
318+
.map((a) => serializeArrow(a.model, a.id));
319+
const notes = noteEntries.map((n) => serializeNote(n.model, n.id));
320+
321+
return pastePayload({ notes, arrows }, options);
322+
}

new-deepnotes/apps/web/src/features/spatial/container-ops.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,29 @@ export function useContainerOps(
125125
removeChildFromContainer(currentParentId, noteId);
126126
}
127127

128+
function reverseChildren(containerId: string): void {
129+
const containerNote = notesMap.get(containerId);
130+
if (!containerNote) return;
131+
const containerMap = containerNote.get(
132+
YPAGE_NOTE_KEY.container,
133+
) as Y.Map<unknown>;
134+
const childrenArr = containerMap.get("children") as Y.Array<string>;
135+
const length = childrenArr.length;
136+
if (length < 2) return;
137+
138+
const children = childrenArr.toArray();
139+
ydoc.transact(() => {
140+
childrenArr.delete(0, length);
141+
childrenArr.push(children.reverse());
142+
});
143+
}
144+
128145
return {
129146
parentOf,
130147
addChildToContainer,
131148
removeChildFromContainer,
132149
moveNoteIntoContainer,
133150
moveNoteOutOfContainer,
151+
reverseChildren,
134152
};
135153
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Editor } from "@tiptap/core";
2+
import * as Y from "yjs";
3+
4+
import { createNoteEditorTipTapExtensions } from "./note-editor-tiptap-extensions";
5+
6+
/**
7+
* Populate a note's Y.XmlFragment with HTML content using a temporary
8+
* Tiptap Editor. The editor is created, content is set, and the editor is
9+
* immediately destroyed so the fragment retains the structured document.
10+
*/
11+
export function setNoteFragmentContent(
12+
fragment: Y.XmlFragment,
13+
html: string,
14+
): void {
15+
const editor = new Editor({
16+
extensions: createNoteEditorTipTapExtensions({ fragment }),
17+
editable: false,
18+
});
19+
editor.commands.setContent(html);
20+
editor.destroy();
21+
}

0 commit comments

Comments
 (0)