Skip to content

Commit a7fcb35

Browse files
committed
chore: refactor ctx menu
1 parent 7aaff44 commit a7fcb35

File tree

5 files changed

+397
-267
lines changed

5 files changed

+397
-267
lines changed

sites/preview/src/lib/components/tree/Tree.svelte

Lines changed: 157 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { FileNode, FolderNode, type FileTree, type FileTreeNode } from "$lib/tree.svelte.js";
2+
import { FileNode, FolderNode, type FileTreeNode } from "$lib/tree.svelte.js";
33
import { composeEventHandlers, formatSize } from "$lib/utils.js";
44
import { FolderIcon } from "@lucide/svelte";
55
import { ContextMenu } from "bits-ui";
@@ -17,15 +17,40 @@
1717
import NameFormDialog from "./NameFormDialog.svelte";
1818
import TreeContextMenu from "./TreeContextMenu.svelte";
1919
import TreeItem from "./TreeItem.svelte";
20-
import { NameConflictDialogState, NameFormDialogState } from "./state.svelte.js";
21-
import type { FileDropState, TreeContextMenuState, TreeItemState, TreeProps } from "./types.js";
20+
import {
21+
createContextMenuState,
22+
createFileInputState,
23+
createNameConflictDialogState,
24+
createNameFormDialogState,
25+
} from "./state.svelte.js";
26+
import type { FileDropState, TreeItemState, TreeProps, UploadFilesArgs } from "./types.js";
2227
2328
let {
2429
tree,
2530
onRenameItem = ({ target, name }) => {
2631
target.node.name = name;
2732
return true;
2833
},
34+
onCreateFolder = ({ target, name }) => {
35+
const folder = new FolderNode({
36+
id: crypto.randomUUID(),
37+
name,
38+
children: [],
39+
});
40+
target.children.push(folder);
41+
return true;
42+
},
43+
onUploadFiles = ({ target, files }) => {
44+
for (const file of files) {
45+
const node = new FileNode({
46+
id: crypto.randomUUID(),
47+
name: file.name,
48+
size: file.size,
49+
});
50+
target.children.push(node);
51+
}
52+
return true;
53+
},
2954
defaultSelectedIds,
3055
selectedIds = new SvelteSet(defaultSelectedIds),
3156
defaultExpandedIds,
@@ -39,12 +64,16 @@
3964
}: TreeProps = $props();
4065
4166
let treeComponent: Tree<FileTreeNode> | null = $state.raw(null);
42-
let menuState: TreeContextMenuState | undefined = $state.raw();
67+
let fileInput: HTMLInputElement | null = $state.raw(null);
68+
4369
let focusedItemId: string | undefined = $state.raw();
4470
let fileDropState: FileDropState | undefined = $state.raw();
4571
46-
const nameConflictDialogState = new NameConflictDialogState();
47-
const nameFormDialogState = new NameFormDialogState();
72+
const nameConflictDialogState = createNameConflictDialogState();
73+
const nameFormDialogState = createNameFormDialogState();
74+
const fileInputState = createFileInputState({
75+
ref: () => fileInput,
76+
});
4877
4978
const pasteDirection: string | undefined = $derived.by(() => {
5079
if (pasteOperation === undefined || focusedItemId === undefined) {
@@ -58,59 +87,14 @@
5887
return "After";
5988
});
6089
61-
function handleResolveNameConflict({
62-
operation,
63-
name,
64-
}: ResolveNameConflictArgs): Promise<NameConflictResolution> {
65-
return new Promise((resolve) => {
66-
let title: string;
67-
switch (operation) {
68-
case "move": {
69-
title = "Failed to move items";
70-
break;
71-
}
72-
case "copy-paste": {
73-
title = "Failed to paste items";
74-
break;
75-
}
76-
}
77-
78-
nameConflictDialogState.show({
79-
title,
80-
description: `An item with the name "${name}" already exists`,
81-
onClose: resolve,
82-
});
83-
});
84-
}
85-
86-
function handleCircularReferenceError({
87-
target,
88-
position,
89-
}: CircularReferenceErrorArgs<FileTreeNode>): void {
90-
toast.error(`Cannot move "${target.name}" ${position} itself`);
91-
}
92-
93-
const handleTriggerContextMenu: EventHandler<Event, HTMLDivElement> = (event) => {
94-
if (event.defaultPrevented) {
95-
// A tree item handled the event.
96-
return;
97-
}
98-
99-
if (event.target instanceof Element && event.target.closest("[role='treeitem']") === null) {
100-
menuState = {
101-
type: "tree",
102-
};
103-
}
104-
};
105-
10690
function showAlreadyExistsToast(name: string): void {
10791
toast.error(`An item with the name "${name}" already exists`);
10892
}
10993
11094
function handleRename(target: TreeItemState): void {
111-
nameFormDialogState.name = target.node.name;
11295
nameFormDialogState.show({
11396
title: "Rename",
97+
name: target.node.name,
11498
onSubmit: async (name) => {
11599
if (name === target.node.name) {
116100
nameFormDialogState.close();
@@ -133,39 +117,99 @@
133117
});
134118
}
135119
136-
function handleUploadFiles(target: FolderNode | FileTree, files: FileList): void {
137-
for (const file of files) {
138-
const node = new FileNode({
139-
id: crypto.randomUUID(),
140-
name: file.name,
141-
size: file.size,
142-
});
143-
target.children.push(node);
120+
async function handleUploadFiles({ target, files }: UploadFilesArgs): Promise<void> {
121+
for (const child of target.children) {
122+
for (const file of files) {
123+
if (child.name === file.name) {
124+
showAlreadyExistsToast(file.name);
125+
return;
126+
}
127+
}
128+
}
129+
130+
const didUpload = await onUploadFiles({ target, files });
131+
if (didUpload) {
132+
// TODO: show toast after upload is done
144133
}
145134
}
146135
147-
function handleCreateFolder(target: FolderNode | FileTree): void {
148-
nameFormDialogState.show({
149-
title: "New Folder",
150-
onSubmit: (name) => {
151-
for (const child of target.children) {
152-
if (child.name === name) {
153-
showAlreadyExistsToast(name);
154-
return;
136+
const contextMenuState = createContextMenuState({
137+
onRename: handleRename,
138+
onCopy: (target) => treeComponent!.copy(target, "copy"),
139+
onCut: (target) => treeComponent!.copy(target, "cut"),
140+
onPaste: (target) => treeComponent!.paste(target),
141+
onRemove: (target) => treeComponent!.remove(target),
142+
onCreateFolder: (target) => {
143+
nameFormDialogState.show({
144+
title: "New Folder",
145+
onSubmit: async (name) => {
146+
for (const child of target.children) {
147+
if (child.name === name) {
148+
showAlreadyExistsToast(name);
149+
return;
150+
}
155151
}
152+
153+
const didCreate = await onCreateFolder({ target, name });
154+
if (didCreate) {
155+
nameFormDialogState.close();
156+
}
157+
},
158+
});
159+
},
160+
onUploadFiles: (target) => {
161+
fileInputState.showPicker({
162+
onPick: (files) => handleUploadFiles({ target, files }),
163+
});
164+
},
165+
});
166+
167+
const handleTriggerContextMenu: EventHandler<Event, HTMLDivElement> = (event) => {
168+
if (event.defaultPrevented) {
169+
// A tree item handled the event.
170+
return;
171+
}
172+
173+
if (event.target instanceof Element && event.target.closest("[role='treeitem']") === null) {
174+
contextMenuState.setTarget({
175+
type: "tree",
176+
tree: () => tree,
177+
});
178+
}
179+
};
180+
181+
function handleResolveNameConflict({
182+
operation,
183+
name,
184+
}: ResolveNameConflictArgs): Promise<NameConflictResolution> {
185+
return new Promise((resolve) => {
186+
let title: string;
187+
switch (operation) {
188+
case "move": {
189+
title = "Failed to move items";
190+
break;
191+
}
192+
case "copy-paste": {
193+
title = "Failed to paste items";
194+
break;
156195
}
196+
}
157197
158-
const node = new FolderNode({
159-
id: crypto.randomUUID(),
160-
name,
161-
children: [],
162-
});
163-
target.children.push(node);
164-
nameFormDialogState.close();
165-
},
198+
nameConflictDialogState.show({
199+
title,
200+
description: `An item with the name "${name}" already exists`,
201+
onClose: resolve,
202+
});
166203
});
167204
}
168205
206+
function handleCircularReferenceError({
207+
target,
208+
position,
209+
}: CircularReferenceErrorArgs<FileTreeNode>): void {
210+
toast.error(`Cannot move "${target.name}" ${position} itself`);
211+
}
212+
169213
function handleExpand(target: TreeItemState): void {
170214
expandedIds.add(target.node.id);
171215
}
@@ -218,22 +262,26 @@
218262
return;
219263
}
220264
221-
handleUploadFiles(tree, files);
265+
handleUploadFiles({
266+
target: tree,
267+
files,
268+
});
222269
event.preventDefault();
223270
};
224271
225272
function handleCleanup(target: TreeItemState): void {
226-
if (menuState?.type === "item" && menuState.item() === target) {
227-
menuState = undefined;
228-
}
229-
230273
if (focusedItemId === target.node.id) {
231274
focusedItemId = undefined;
232275
}
233276
234277
if (fileDropState?.type === "item" && fileDropState.item() === target) {
235278
fileDropState = undefined;
236279
}
280+
281+
const menuTarget = contextMenuState.target();
282+
if (menuTarget?.type === "item" && menuTarget.item() === target) {
283+
contextMenuState.close();
284+
}
237285
}
238286
</script>
239287

@@ -247,14 +295,15 @@
247295
</div>
248296

249297
<TreeContextMenu
250-
{tree}
251-
bind:state={menuState}
252-
onRename={handleRename}
253-
onCopy={(target, operation) => treeComponent!.copy(target, operation)}
254-
onPaste={(target) => treeComponent!.paste(target)}
255-
onRemove={(target) => treeComponent!.remove(target)}
256-
onUploadFiles={handleUploadFiles}
257-
onCreateFolder={handleCreateFolder}
298+
target={contextMenuState.target()}
299+
onRename={contextMenuState.rename}
300+
onCopy={contextMenuState.copy}
301+
onCut={contextMenuState.cut}
302+
onPaste={contextMenuState.paste}
303+
onRemove={contextMenuState.remove}
304+
onCreateFolder={contextMenuState.createFolder}
305+
onUploadFiles={contextMenuState.uploadFiles}
306+
onClose={contextMenuState.close}
258307
>
259308
<ContextMenu.Trigger
260309
class="relative grow overflow-y-auto"
@@ -281,11 +330,11 @@
281330
{#snippet item({ item })}
282331
<TreeItem
283332
{item}
284-
bind:menuState
285333
bind:fileDropState
286334
onExpand={handleExpand}
287335
onCollapse={handleCollapse}
288336
onRename={handleRename}
337+
onContextMenuTargetChange={contextMenuState.setTarget}
289338
onUploadFiles={handleUploadFiles}
290339
onCleanup={handleCleanup}
291340
onfocusin={() => handleItemFocusIn(item)}
@@ -343,18 +392,27 @@
343392
</div>
344393

345394
<NameConflictDialog
346-
open={nameConflictDialogState.open}
347-
title={nameConflictDialogState.title}
348-
description={nameConflictDialogState.description}
349-
onClose={(result) => nameConflictDialogState.close(result)}
395+
open={nameConflictDialogState.open()}
396+
title={nameConflictDialogState.title()}
397+
description={nameConflictDialogState.description()}
398+
onClose={nameConflictDialogState.close}
350399
/>
351400

352401
<NameFormDialog
353-
bind:name={nameFormDialogState.name}
354-
open={nameFormDialogState.open}
355-
title={nameFormDialogState.title}
356-
onSubmit={() => nameFormDialogState.submit()}
357-
onClose={() => nameFormDialogState.close()}
402+
bind:name={nameFormDialogState.name, nameFormDialogState.setName}
403+
open={nameFormDialogState.open()}
404+
title={nameFormDialogState.title()}
405+
onSubmit={nameFormDialogState.submit}
406+
onClose={nameFormDialogState.close}
407+
/>
408+
409+
<input
410+
bind:this={fileInput}
411+
type="file"
412+
multiple
413+
class="hidden"
414+
onchange={fileInputState.onChange}
415+
oncancel={fileInputState.onCancel}
358416
/>
359417

360418
<style>

0 commit comments

Comments
 (0)