Line data Source code
1 : import {
2 : type SessionTreeFolder,
3 : folderAt,
4 : folderPathKey,
5 : isFolderPathWithin,
6 : sameFolderPath,
7 : } from '../storage/session_tree.js';
8 :
9 : /// The id of the drop target at the end of the session list, a drop there moves to the end of the root
10 1 : export const TOP_LEVEL_DROP_ID = 'top-level';
11 :
12 : /// A row of the session list, a folder or a session
13 : export type SessionListRow<Item extends { sessionId: string }> =
14 : | {
15 : kind: 'folder';
16 : /// The sortable id
17 : id: string;
18 : folder: SessionTreeFolder;
19 : depth: number;
20 : expanded: boolean;
21 : }
22 : | {
23 : kind: 'session';
24 : /// The sortable id, the session id
25 : id: string;
26 : item: Item;
27 : depth: number;
28 : /// The folder of the session
29 : folderPath: string[];
30 : };
31 :
32 : /// The rows of the session list: the subfolders of a folder before its sessions, the content of a
33 : /// folder only when it is expanded. Sessions without an item, e.g. invalid ones, are left out.
34 1 : export function flattenSessionListRows<Item extends { sessionId: string }>(
35 1 : tree: SessionTreeFolder,
36 1 : items: ReadonlyMap<string, Item>,
37 1 : isExpanded: (folder: SessionTreeFolder) => boolean,
38 1 : ): SessionListRow<Item>[] {
39 1 : const rows: SessionListRow<Item>[] = [];
40 1 : const visit = (folder: SessionTreeFolder, depth: number) => {
41 1 : for (const child of folder.folders) {
42 1 : const expanded = isExpanded(child);
43 1 : rows.push({ kind: 'folder', id: `folder:${folderPathKey(child.path)}`, folder: child, depth, expanded });
44 1 : if (expanded) visit(child, depth + 1);
45 1 : }
46 1 : for (const entry of folder.sessions) {
47 1 : const item = items.get(entry.path);
48 1 : if (item != null) rows.push({ kind: 'session', id: entry.path, item, depth, folderPath: folder.path });
49 1 : }
50 1 : };
51 1 : visit(tree, 0);
52 1 : return rows;
53 1 : }
54 :
55 : /// Where a dragged row would land
56 : export interface SessionListDrop {
57 : /// The folder that receives the row
58 : folderPath: string[];
59 : /// The position of a session among the sessions of the folder without it, past the end appends
60 : index: number;
61 : /// The row that shows the drop, or TOP_LEVEL_DROP_ID
62 : targetId: string;
63 : /// Does the row go into the target, or above or below it?
64 : position: 'into' | 'before' | 'after';
65 : }
66 :
67 : /// Resolve the drop of a dragged row on another row or on the drop target at the end of the list.
68 : ///
69 : /// A row dropped on a folder goes into it, a row dropped at the end of the list goes to the end of the root. A
70 : /// session dropped on a session goes above or below it, in the folder of that session; a folder dropped on a
71 : /// session goes into the folder of that session. Returns null when the drop changes nothing or is not possible,
72 : /// e.g. a folder dropped into itself.
73 1 : export function resolveSessionListDrop<Item extends { sessionId: string }>(
74 1 : rows: readonly SessionListRow<Item>[],
75 1 : tree: SessionTreeFolder,
76 1 : activeId: string,
77 1 : overId: string,
78 : /// Is the dragged row above the middle of the row it is dropped on?
79 1 : aboveMiddle: boolean,
80 1 : ): SessionListDrop | null {
81 1 : if (activeId === overId) return null;
82 1 : const active = rows.find(row => row.id === activeId);
83 1 : if (active == null) return null;
84 1 : let drop: SessionListDrop;
85 1 : if (overId === TOP_LEVEL_DROP_ID) {
86 1 : drop = { folderPath: [], index: Number.MAX_SAFE_INTEGER, targetId: TOP_LEVEL_DROP_ID, position: 'into' };
87 1 : } else {
88 1 : const over = rows.find(row => row.id === overId);
89 1 : if (over == null) return null;
90 1 : if (over.kind === 'folder') {
91 1 : drop = { folderPath: over.folder.path, index: Number.MAX_SAFE_INTEGER, targetId: over.id, position: 'into' };
92 1 : } else if (active.kind === 'session') {
93 1 : const siblings = (folderAt(tree, over.folderPath)?.sessions ?? [])
94 1 : .map(entry => entry.path)
95 1 : .filter(id => id !== active.item.sessionId);
96 1 : const overIndex = siblings.indexOf(over.item.sessionId);
97 1 : drop = {
98 1 : folderPath: over.folderPath,
99 1 : index: aboveMiddle ? overIndex : overIndex + 1,
100 1 : targetId: over.id,
101 1 : position: aboveMiddle ? 'before' : 'after',
102 1 : };
103 1 : } else {
104 : // A folder goes into the folder of the session, shown on that folder or at the end of the list
105 1 : drop = over.folderPath.length === 0
106 1 : ? { folderPath: [], index: Number.MAX_SAFE_INTEGER, targetId: TOP_LEVEL_DROP_ID, position: 'into' }
107 1 : : { folderPath: over.folderPath, index: Number.MAX_SAFE_INTEGER, targetId: `folder:${folderPathKey(over.folderPath)}`, position: 'into' };
108 : }
109 1 : }
110 1 : if (active.kind === 'folder') {
111 1 : const path = active.folder.path;
112 1 : if (isFolderPathWithin(drop.folderPath, path) || sameFolderPath(drop.folderPath, path.slice(0, -1))) return null;
113 1 : return drop;
114 1 : }
115 : // A session that is already in place stays
116 1 : const current = folderAt(tree, active.folderPath)?.sessions.map(entry => entry.path) ?? [];
117 1 : const currentIndex = current.indexOf(active.item.sessionId);
118 1 : if (sameFolderPath(drop.folderPath, active.folderPath)) {
119 1 : const lastIndex = current.length - 1;
120 1 : const target = Math.min(drop.index, lastIndex);
121 1 : if (target === currentIndex) return null;
122 1 : }
123 1 : return drop;
124 1 : }
|