@abraca/nuxt 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -56
- package/dist/module.json +1 -1
- package/dist/runtime/components/ADocumentTree.d.vue.ts +8 -43
- package/dist/runtime/components/ADocumentTree.vue +1239 -274
- package/dist/runtime/components/ADocumentTree.vue.d.ts +8 -43
- package/dist/runtime/components/AEditor.d.vue.ts +5 -0
- package/dist/runtime/components/AEditor.vue +85 -29
- package/dist/runtime/components/AEditor.vue.d.ts +5 -0
- package/dist/runtime/components/AFloatingWindow.vue +1 -1
- package/dist/runtime/components/ANodePanel.vue +1 -1
- package/dist/runtime/components/AWindowLayer.vue +1 -1
- package/dist/runtime/composables/useConnectionStatus.d.ts +5 -1
- package/dist/runtime/composables/useConnectionStatus.js +36 -11
- package/dist/runtime/composables/useEditorSuggestions.js +10 -0
- package/dist/runtime/extensions/meta-field.d.ts +16 -0
- package/dist/runtime/extensions/meta-field.js +110 -0
- package/dist/runtime/extensions/views/MetaFieldView.d.vue.ts +4 -0
- package/dist/runtime/extensions/views/MetaFieldView.vue +489 -0
- package/dist/runtime/extensions/views/MetaFieldView.vue.d.ts +4 -0
- package/dist/runtime/plugin-abracadabra.client.js +55 -4
- package/dist/runtime/plugins/core.plugin.js +7 -3
- package/dist/runtime/server/plugins/abracadabra-service.d.ts +1 -1
- package/dist/runtime/server/plugins/abracadabra-service.js +3 -0
- package/dist/runtime/utils/docDragDrop.d.ts +13 -0
- package/dist/runtime/utils/docDragDrop.js +26 -0
- package/package.json +14 -12
|
@@ -1,350 +1,1315 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, shallowRef } from "vue";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { resolveDocType } from "../utils/docTypes";
|
|
7
|
-
import { useAbraLocale } from "../composables/useAbraLocale";
|
|
2
|
+
import { ref, computed, nextTick, shallowRef, watch } from "vue";
|
|
3
|
+
import { resolveDocType, getAvailableDocTypes } from "../utils/docTypes";
|
|
4
|
+
import { avatarBorderStyle } from "../utils/avatarStyle";
|
|
5
|
+
import { DOC_DRAG_MIME, isDocDrag, parseDocDragPayload, isDescendantInMap } from "../utils/docDragDrop";
|
|
8
6
|
const props = defineProps({
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
selectedId: { type: [String, null], required: false, default: null },
|
|
13
|
-
labels: { type: Object, required: false }
|
|
7
|
+
collapsed: { type: Boolean, required: false },
|
|
8
|
+
editable: { type: Boolean, required: false, default: true },
|
|
9
|
+
selectedId: { type: [String, null], required: false }
|
|
14
10
|
});
|
|
15
|
-
const emit = defineEmits(["
|
|
16
|
-
const
|
|
17
|
-
const {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
11
|
+
const emit = defineEmits(["navigate", "create"]);
|
|
12
|
+
const { doc, isReady, provider, client, userName } = useAbracadabra();
|
|
13
|
+
const { trashMap, trashedCount, moveToTrash, restoreFromTrash, permanentlyDelete, emptyTrash } = useTrash();
|
|
14
|
+
const _chat = import.meta.client ? useChat() : null;
|
|
15
|
+
const chatChannels = computed(() => _chat?.channels.value ?? {});
|
|
16
|
+
const _voice = import.meta.client ? useVoice() : null;
|
|
17
|
+
const voiceStatus = computed(() => _voice?.voiceStatus.value ?? "idle");
|
|
18
|
+
const voiceRoomId = computed(() => _voice?.voiceRoomId.value ?? null);
|
|
19
|
+
function joinRoom(docId) {
|
|
20
|
+
_voice?.joinRoom(docId);
|
|
21
|
+
}
|
|
22
|
+
function leaveRoom() {
|
|
23
|
+
_voice?.leaveRoom();
|
|
24
|
+
}
|
|
25
|
+
const toast = useToast();
|
|
26
|
+
const appConfig = useAppConfig();
|
|
27
|
+
const presenceNeutral = computed(() => appConfig.ui?.colors?.neutral ?? "zinc");
|
|
28
|
+
const treeMap = useSyncedMap(doc, "doc-tree");
|
|
29
|
+
function buildTree(parentId = null, depth = 0) {
|
|
30
|
+
return Object.entries(treeMap.data).filter(([, v]) => (v.parentId ?? null) === parentId).sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0)).map(([id, v]) => ({
|
|
33
31
|
id,
|
|
34
|
-
label: v.label ||
|
|
32
|
+
label: v.label || "Untitled",
|
|
35
33
|
parentId: v.parentId,
|
|
36
34
|
order: v.order ?? 0,
|
|
37
35
|
type: v.type,
|
|
38
|
-
meta: v.meta
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return allEntries.value.filter((e) => e.parentId === parentId).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
36
|
+
meta: v.meta,
|
|
37
|
+
children: buildTree(id, depth + 1),
|
|
38
|
+
depth
|
|
39
|
+
}));
|
|
43
40
|
}
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const treeItems = computed(() => buildTree(null));
|
|
42
|
+
const nodeMap = computed(() => {
|
|
43
|
+
const map = /* @__PURE__ */ new Map();
|
|
44
|
+
function walk(nodes) {
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
map.set(node.id, node);
|
|
47
|
+
walk(node.children);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(treeItems.value);
|
|
51
|
+
return map;
|
|
52
|
+
});
|
|
53
|
+
const expandedIds = ref(/* @__PURE__ */ new Set());
|
|
54
|
+
const {
|
|
55
|
+
externalDragActive,
|
|
56
|
+
onDragEnter: onImportDragEnter,
|
|
57
|
+
onDragLeave: onImportDragLeave,
|
|
58
|
+
onDragOver: onImportDragOver,
|
|
59
|
+
onFileDrop: onExternalFileDrop,
|
|
60
|
+
importFiles,
|
|
61
|
+
importFolder
|
|
62
|
+
} = useDocImport();
|
|
63
|
+
const { exportDoc: exportSingleDoc, exportDocs, exportAll } = useDocExport();
|
|
64
|
+
function isExternalFileDrag(e) {
|
|
65
|
+
return !!e.dataTransfer?.types.includes("Files");
|
|
46
66
|
}
|
|
47
|
-
function
|
|
67
|
+
function toggleExpand(id) {
|
|
68
|
+
if (expandedIds.value.has(id)) expandedIds.value.delete(id);
|
|
69
|
+
else expandedIds.value.add(id);
|
|
70
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
71
|
+
}
|
|
72
|
+
function expandAncestors(id) {
|
|
73
|
+
if (!id) return;
|
|
74
|
+
let node = nodeMap.value.get(id);
|
|
75
|
+
let changed = false;
|
|
76
|
+
while (node?.parentId) {
|
|
77
|
+
expandedIds.value.add(node.parentId);
|
|
78
|
+
changed = true;
|
|
79
|
+
node = nodeMap.value.get(node.parentId);
|
|
80
|
+
}
|
|
81
|
+
if (changed) expandedIds.value = new Set(expandedIds.value);
|
|
82
|
+
}
|
|
83
|
+
const allExpanded = computed(() => {
|
|
84
|
+
const folders = [...nodeMap.value.values()].filter((n) => n.children.length > 0);
|
|
85
|
+
return folders.length > 0 && folders.every((n) => expandedIds.value.has(n.id));
|
|
86
|
+
});
|
|
87
|
+
function toggleExpandAll() {
|
|
88
|
+
if (allExpanded.value) {
|
|
89
|
+
expandedIds.value = /* @__PURE__ */ new Set();
|
|
90
|
+
} else {
|
|
91
|
+
const ids = /* @__PURE__ */ new Set();
|
|
92
|
+
for (const [, node] of nodeMap.value) {
|
|
93
|
+
if (node.children.length > 0) ids.add(node.id);
|
|
94
|
+
}
|
|
95
|
+
expandedIds.value = ids;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function sortAlphabetically() {
|
|
99
|
+
const entries = treeMap.data;
|
|
100
|
+
const groups = /* @__PURE__ */ new Map();
|
|
101
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
102
|
+
const parentId = entry.parentId ?? null;
|
|
103
|
+
if (!groups.has(parentId)) groups.set(parentId, []);
|
|
104
|
+
groups.get(parentId).push({ id, label: entry.label });
|
|
105
|
+
}
|
|
106
|
+
for (const siblings of groups.values()) {
|
|
107
|
+
siblings.sort((a, b) => a.label.localeCompare(b.label));
|
|
108
|
+
siblings.forEach((s, i) => {
|
|
109
|
+
treeMap.set(s.id, { ...entries[s.id], order: i });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const trashExpanded = ref(false);
|
|
114
|
+
const flatItems = computed(() => {
|
|
48
115
|
const result = [];
|
|
49
|
-
|
|
50
|
-
const
|
|
116
|
+
function walk(nodes) {
|
|
117
|
+
for (const node of nodes) {
|
|
118
|
+
const hasChildren = node.children.length > 0;
|
|
119
|
+
const expanded = expandedIds.value.has(node.id);
|
|
120
|
+
result.push({
|
|
121
|
+
id: node.id,
|
|
122
|
+
name: node.label,
|
|
123
|
+
depth: node.depth,
|
|
124
|
+
hasChildren,
|
|
125
|
+
expanded,
|
|
126
|
+
type: node.type,
|
|
127
|
+
meta: node.meta
|
|
128
|
+
});
|
|
129
|
+
if (hasChildren && expanded) walk(node.children);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
walk(treeItems.value);
|
|
133
|
+
const trashEntries = Object.entries(trashMap.data);
|
|
134
|
+
if (trashEntries.length > 0) {
|
|
135
|
+
let buildTrashChildren = function(parentId, depth) {
|
|
136
|
+
return trashEntries.filter(([, e]) => e.parentId === parentId && trashIds.has(parentId)).sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0)).map(([id, e]) => ({
|
|
137
|
+
id,
|
|
138
|
+
label: e.label || "Untitled",
|
|
139
|
+
type: e.type,
|
|
140
|
+
meta: e.meta,
|
|
141
|
+
children: buildTrashChildren(id, depth + 1),
|
|
142
|
+
depth
|
|
143
|
+
}));
|
|
144
|
+
};
|
|
145
|
+
const trashIds = new Set(trashEntries.map(([id]) => id));
|
|
146
|
+
const topTrash = trashEntries.filter(([, e]) => !e.parentId || !trashIds.has(e.parentId)).sort(([, a], [, b]) => b.deletedAt - a.deletedAt).map(([id, e]) => ({
|
|
147
|
+
id,
|
|
148
|
+
label: e.label || "Untitled",
|
|
149
|
+
type: e.type,
|
|
150
|
+
meta: e.meta,
|
|
151
|
+
children: buildTrashChildren(id, 2),
|
|
152
|
+
depth: 1
|
|
153
|
+
}));
|
|
51
154
|
result.push({
|
|
52
|
-
id:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
order: entry.order,
|
|
59
|
-
isExpanded: expanded,
|
|
60
|
-
hasChildren: hasChildren(entry.id)
|
|
155
|
+
id: "__trash__",
|
|
156
|
+
name: "Trash",
|
|
157
|
+
depth: 0,
|
|
158
|
+
hasChildren: topTrash.length > 0,
|
|
159
|
+
expanded: trashExpanded.value,
|
|
160
|
+
isTrashRoot: true
|
|
61
161
|
});
|
|
62
|
-
if (
|
|
63
|
-
|
|
162
|
+
if (trashExpanded.value) {
|
|
163
|
+
let walkTrash = function(nodes) {
|
|
164
|
+
for (const node of nodes) {
|
|
165
|
+
const hasChildren = node.children.length > 0;
|
|
166
|
+
const expanded = expandedIds.value.has(node.id);
|
|
167
|
+
result.push({
|
|
168
|
+
id: node.id,
|
|
169
|
+
name: node.label,
|
|
170
|
+
depth: node.depth,
|
|
171
|
+
hasChildren,
|
|
172
|
+
expanded,
|
|
173
|
+
type: node.type,
|
|
174
|
+
meta: node.meta,
|
|
175
|
+
isTrash: true
|
|
176
|
+
});
|
|
177
|
+
if (hasChildren && expanded) walkTrash(node.children);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
walkTrash(topTrash);
|
|
64
181
|
}
|
|
65
182
|
}
|
|
66
183
|
return result;
|
|
184
|
+
});
|
|
185
|
+
function getItemIcon(item) {
|
|
186
|
+
const meta = item.meta;
|
|
187
|
+
if (meta?.icon && typeof meta.icon === "string") return `i-lucide-${meta.icon}`;
|
|
188
|
+
const userIconField = meta?._metaFields?.find((f) => f.type === "icon" && f.key);
|
|
189
|
+
if (userIconField?.key) {
|
|
190
|
+
const val = meta?.[userIconField.key];
|
|
191
|
+
if (val && typeof val === "string") return `i-lucide-${val}`;
|
|
192
|
+
}
|
|
193
|
+
const ownSchema = resolveDocType(item.type).metaSchema;
|
|
194
|
+
const ownIconField = ownSchema?.find((f) => f.type === "icon");
|
|
195
|
+
if (ownIconField && "key" in ownIconField) {
|
|
196
|
+
const val = meta?.[ownIconField.key];
|
|
197
|
+
if (val && typeof val === "string") return `i-lucide-${val}`;
|
|
198
|
+
}
|
|
199
|
+
const parentId = nodeMap.value.get(item.id)?.parentId;
|
|
200
|
+
if (parentId) {
|
|
201
|
+
const parentNode = nodeMap.value.get(parentId);
|
|
202
|
+
const parentSchema = resolveDocType(parentNode?.type).metaSchema;
|
|
203
|
+
const schemaIconField = parentSchema?.find((f) => f.type === "icon");
|
|
204
|
+
if (schemaIconField && "key" in schemaIconField) {
|
|
205
|
+
const val = meta?.[schemaIconField.key];
|
|
206
|
+
if (val && typeof val === "string") return `i-lucide-${val}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return resolveDocType(item.type).icon;
|
|
67
210
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
211
|
+
function getItemIconColor(item) {
|
|
212
|
+
const meta = item.meta;
|
|
213
|
+
if (!meta) return void 0;
|
|
214
|
+
if (typeof meta.color === "string" && meta.color) return meta.color;
|
|
215
|
+
const userColorField = meta._metaFields?.find(
|
|
216
|
+
(f) => (f.type === "colorPreset" || f.type === "colorPicker") && f.key
|
|
217
|
+
);
|
|
218
|
+
if (userColorField?.key) {
|
|
219
|
+
const val = meta[userColorField.key];
|
|
220
|
+
if (typeof val === "string" && val) return val;
|
|
221
|
+
}
|
|
222
|
+
const ownSchema = resolveDocType(item.type).metaSchema;
|
|
223
|
+
const ownColorField = ownSchema?.find((f) => f.type === "colorPreset" || f.type === "colorPicker");
|
|
224
|
+
if (ownColorField && "key" in ownColorField) {
|
|
225
|
+
const val = meta[ownColorField.key];
|
|
226
|
+
if (typeof val === "string" && val) return val;
|
|
227
|
+
}
|
|
228
|
+
const parentId = nodeMap.value.get(item.id)?.parentId;
|
|
229
|
+
if (parentId) {
|
|
230
|
+
const parentNode = nodeMap.value.get(parentId);
|
|
231
|
+
const parentSchema = resolveDocType(parentNode?.type).metaSchema;
|
|
232
|
+
const schemaColorField = parentSchema?.find(
|
|
233
|
+
(f) => f.type === "colorPreset" || f.type === "colorPicker"
|
|
234
|
+
);
|
|
235
|
+
if (schemaColorField && "key" in schemaColorField) {
|
|
236
|
+
const val = meta[schemaColorField.key];
|
|
237
|
+
if (typeof val === "string" && val) return val;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return void 0;
|
|
241
|
+
}
|
|
242
|
+
function createDirectly(parentId) {
|
|
243
|
+
if (!isReady.value) return;
|
|
244
|
+
const id = crypto.randomUUID();
|
|
245
|
+
treeMap.set(id, { label: "Untitled", parentId, order: Date.now(), type: "doc" });
|
|
246
|
+
if (parentId) {
|
|
247
|
+
expandedIds.value.add(parentId);
|
|
248
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
249
|
+
client.value?.createChild(parentId, { child_id: id }).catch(() => {
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
emit("navigate", id);
|
|
253
|
+
}
|
|
254
|
+
function deleteDoc(docId) {
|
|
255
|
+
if (!docId || !isReady.value) return;
|
|
256
|
+
moveToTrash(docId, userName.value);
|
|
257
|
+
}
|
|
258
|
+
const renameId = ref(null);
|
|
259
|
+
const renameValue = ref("");
|
|
260
|
+
const renameInputRef = ref(null);
|
|
261
|
+
let _renameJustOpened = false;
|
|
262
|
+
function startRename(docId) {
|
|
263
|
+
if (!docId) return;
|
|
264
|
+
const node = nodeMap.value.get(docId);
|
|
265
|
+
if (!node) return;
|
|
266
|
+
renameId.value = docId;
|
|
267
|
+
renameValue.value = node.label;
|
|
268
|
+
_renameJustOpened = true;
|
|
78
269
|
nextTick(() => {
|
|
79
|
-
const input =
|
|
80
|
-
input?.
|
|
270
|
+
const input = renameInputRef.value?.input ?? renameInputRef.value?.$el?.querySelector("input") ?? renameInputRef.value;
|
|
271
|
+
if (input?.focus) {
|
|
272
|
+
input.focus();
|
|
273
|
+
input.select?.();
|
|
274
|
+
}
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
_renameJustOpened = false;
|
|
277
|
+
}, 150);
|
|
81
278
|
});
|
|
82
279
|
}
|
|
83
|
-
function commitRename(
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
280
|
+
function commitRename() {
|
|
281
|
+
if (_renameJustOpened) return;
|
|
282
|
+
if (!renameId.value || !renameValue.value.trim()) {
|
|
283
|
+
renameId.value = null;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const entry = treeMap.get(renameId.value);
|
|
287
|
+
if (entry)
|
|
288
|
+
treeMap.set(renameId.value, { ...entry, label: renameValue.value.trim() });
|
|
289
|
+
renameId.value = null;
|
|
290
|
+
}
|
|
291
|
+
const dragId = ref(null);
|
|
292
|
+
const dropIndicator = ref(null);
|
|
293
|
+
let expandTimeout = null;
|
|
294
|
+
const multiSelected = ref(/* @__PURE__ */ new Set());
|
|
295
|
+
const lastClickedId = ref(null);
|
|
296
|
+
function onItemClick(e, item) {
|
|
297
|
+
if (e.shiftKey && lastClickedId.value) {
|
|
298
|
+
const idxA = flatItems.value.findIndex((i) => i.id === lastClickedId.value);
|
|
299
|
+
const idxB = flatItems.value.findIndex((i) => i.id === item.id);
|
|
300
|
+
const [lo, hi] = [Math.min(idxA, idxB), Math.max(idxA, idxB)];
|
|
301
|
+
const next = new Set(multiSelected.value);
|
|
302
|
+
for (let i = lo; i <= hi; i++) next.add(flatItems.value[i].id);
|
|
303
|
+
multiSelected.value = next;
|
|
304
|
+
} else if (e.metaKey || e.ctrlKey) {
|
|
305
|
+
const next = new Set(multiSelected.value);
|
|
306
|
+
if (next.has(item.id)) next.delete(item.id);
|
|
307
|
+
else next.add(item.id);
|
|
308
|
+
multiSelected.value = next;
|
|
309
|
+
lastClickedId.value = item.id;
|
|
310
|
+
} else {
|
|
311
|
+
multiSelected.value = /* @__PURE__ */ new Set();
|
|
312
|
+
lastClickedId.value = item.id;
|
|
313
|
+
emit("navigate", item.id);
|
|
88
314
|
}
|
|
89
|
-
renamingId.value = null;
|
|
90
315
|
}
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
function onItemDragStart(e, id) {
|
|
95
|
-
if (!props.draggable) return;
|
|
96
|
-
draggingId.value = id;
|
|
97
|
-
e.dataTransfer?.setData(DOC_DRAG_MIME, id);
|
|
316
|
+
function deleteSelected() {
|
|
317
|
+
for (const id of multiSelected.value) deleteDoc(id);
|
|
318
|
+
multiSelected.value = /* @__PURE__ */ new Set();
|
|
98
319
|
}
|
|
99
|
-
function
|
|
100
|
-
|
|
320
|
+
function isDescendantOf(ancestorId, nodeId) {
|
|
321
|
+
let current = nodeMap.value.get(nodeId);
|
|
322
|
+
while (current) {
|
|
323
|
+
if (current.id === ancestorId) return true;
|
|
324
|
+
if (!current.parentId) break;
|
|
325
|
+
current = nodeMap.value.get(current.parentId);
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
function computeOrder(target, position, excludeId) {
|
|
330
|
+
const exclude = excludeId ?? dragId.value;
|
|
331
|
+
const siblings = Object.entries(treeMap.data).filter(([id, v]) => (v.parentId ?? null) === target.parentId && id !== exclude).sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0));
|
|
332
|
+
const idx = siblings.findIndex(([id]) => id === target.id);
|
|
333
|
+
if (position === "before") {
|
|
334
|
+
const prev = siblings[idx - 1];
|
|
335
|
+
return prev ? (prev[1].order + target.order) / 2 : target.order - 1e3;
|
|
336
|
+
} else {
|
|
337
|
+
const next = siblings[idx + 1];
|
|
338
|
+
return next ? (target.order + next[1].order) / 2 : target.order + 1e3;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function onDragStart(e, item) {
|
|
342
|
+
if (!multiSelected.value.has(item.id)) multiSelected.value = /* @__PURE__ */ new Set();
|
|
343
|
+
dragId.value = item.id;
|
|
344
|
+
if (e.dataTransfer) {
|
|
345
|
+
e.dataTransfer.effectAllowed = "move";
|
|
346
|
+
e.dataTransfer.setData("text/plain", item.id);
|
|
347
|
+
e.dataTransfer.setData(DOC_DRAG_MIME, JSON.stringify({ id: item.id, label: item.name }));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function onDragOver(e, item) {
|
|
351
|
+
if (dragId.value === null && isExternalFileDrag(e)) {
|
|
352
|
+
if (item.isTrash || item.isTrashRoot) {
|
|
353
|
+
if (dropIndicator.value !== null) dropIndicator.value = null;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
358
|
+
if (dropIndicator.value?.targetId !== item.id || dropIndicator.value?.position !== "inside") {
|
|
359
|
+
dropIndicator.value = { targetId: item.id, position: "inside" };
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!dragId.value && isDocDrag(e)) {
|
|
364
|
+
if (item.isTrash || item.isTrashRoot) {
|
|
365
|
+
if (item.isTrashRoot) {
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
368
|
+
if (dropIndicator.value?.targetId !== "__trash__" || dropIndicator.value?.position !== "inside") {
|
|
369
|
+
dropIndicator.value = { targetId: "__trash__", position: "inside" };
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (dropIndicator.value !== null) dropIndicator.value = null;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
378
|
+
const rect2 = e.currentTarget.getBoundingClientRect();
|
|
379
|
+
const y2 = e.clientY - rect2.top;
|
|
380
|
+
const h2 = rect2.height;
|
|
381
|
+
let position2;
|
|
382
|
+
if (y2 < h2 * 0.25) position2 = "before";
|
|
383
|
+
else if (y2 > h2 * 0.75)
|
|
384
|
+
position2 = item.expanded && item.hasChildren ? "inside" : "after";
|
|
385
|
+
else position2 = "inside";
|
|
386
|
+
if (dropIndicator.value?.targetId !== item.id || dropIndicator.value?.position !== position2) {
|
|
387
|
+
dropIndicator.value = { targetId: item.id, position: position2 };
|
|
388
|
+
}
|
|
389
|
+
if (position2 === "inside" && item.hasChildren && !item.expanded) {
|
|
390
|
+
if (expandTimeout) clearTimeout(expandTimeout);
|
|
391
|
+
expandTimeout = setTimeout(() => {
|
|
392
|
+
if (dropIndicator.value?.targetId === item.id) {
|
|
393
|
+
expandedIds.value.add(item.id);
|
|
394
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
395
|
+
}
|
|
396
|
+
}, 600);
|
|
397
|
+
} else if (expandTimeout) {
|
|
398
|
+
clearTimeout(expandTimeout);
|
|
399
|
+
expandTimeout = null;
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const dragIsTrash = dragId.value && trashMap.data[dragId.value];
|
|
404
|
+
if (item.isTrash && dragIsTrash) {
|
|
405
|
+
if (dropIndicator.value !== null) dropIndicator.value = null;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (item.isTrashRoot) {
|
|
409
|
+
if (!dragId.value || dragIsTrash) {
|
|
410
|
+
if (dropIndicator.value !== null) dropIndicator.value = null;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
415
|
+
if (dropIndicator.value?.targetId !== "__trash__" || dropIndicator.value?.position !== "inside") {
|
|
416
|
+
dropIndicator.value = { targetId: "__trash__", position: "inside" };
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (!dragId.value || dragId.value === item.id || !dragIsTrash && isDescendantOf(dragId.value, item.id)) {
|
|
421
|
+
if (dropIndicator.value !== null) dropIndicator.value = null;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
101
424
|
e.preventDefault();
|
|
102
|
-
|
|
425
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
426
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
427
|
+
const y = e.clientY - rect.top;
|
|
428
|
+
const h = rect.height;
|
|
429
|
+
let position;
|
|
430
|
+
if (y < h * 0.25) position = "before";
|
|
431
|
+
else if (y > h * 0.75)
|
|
432
|
+
position = item.expanded && item.hasChildren ? "inside" : "after";
|
|
433
|
+
else position = "inside";
|
|
434
|
+
if (dropIndicator.value?.targetId !== item.id || dropIndicator.value?.position !== position) {
|
|
435
|
+
dropIndicator.value = { targetId: item.id, position };
|
|
436
|
+
}
|
|
437
|
+
if (position === "inside" && item.hasChildren && !item.expanded) {
|
|
438
|
+
if (expandTimeout) clearTimeout(expandTimeout);
|
|
439
|
+
expandTimeout = setTimeout(() => {
|
|
440
|
+
if (dropIndicator.value?.targetId === item.id) {
|
|
441
|
+
expandedIds.value.add(item.id);
|
|
442
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
443
|
+
}
|
|
444
|
+
}, 600);
|
|
445
|
+
} else {
|
|
446
|
+
if (expandTimeout) {
|
|
447
|
+
clearTimeout(expandTimeout);
|
|
448
|
+
expandTimeout = null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
103
451
|
}
|
|
104
|
-
function
|
|
452
|
+
function onListDragLeave(e) {
|
|
453
|
+
const related = e.relatedTarget;
|
|
454
|
+
if (related && e.currentTarget.contains(related)) return;
|
|
455
|
+
dropIndicator.value = null;
|
|
456
|
+
}
|
|
457
|
+
function onDrop(e) {
|
|
105
458
|
e.preventDefault();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
459
|
+
if (!dragId.value) {
|
|
460
|
+
const crossPayload = parseDocDragPayload(e);
|
|
461
|
+
if (crossPayload) {
|
|
462
|
+
const { targetId: targetId3, position: position2 } = dropIndicator.value ?? { targetId: null, position: null };
|
|
463
|
+
dropIndicator.value = null;
|
|
464
|
+
if (targetId3 === "__trash__") {
|
|
465
|
+
moveToTrash(crossPayload.id, userName.value);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (!targetId3 || !position2) return;
|
|
469
|
+
const targetNode2 = nodeMap.value.get(targetId3);
|
|
470
|
+
const entry2 = treeMap.get(crossPayload.id);
|
|
471
|
+
if (!targetNode2 || !entry2) return;
|
|
472
|
+
if (crossPayload.id === targetId3) return;
|
|
473
|
+
if (isDescendantInMap(treeMap.data, crossPayload.id, targetId3)) return;
|
|
474
|
+
let newParentId2;
|
|
475
|
+
let newOrder2;
|
|
476
|
+
if (position2 === "before") {
|
|
477
|
+
newParentId2 = targetNode2.parentId;
|
|
478
|
+
newOrder2 = computeOrder(targetNode2, "before", crossPayload.id);
|
|
479
|
+
} else if (position2 === "after") {
|
|
480
|
+
newParentId2 = targetNode2.parentId;
|
|
481
|
+
newOrder2 = computeOrder(targetNode2, "after", crossPayload.id);
|
|
482
|
+
} else {
|
|
483
|
+
newParentId2 = targetId3;
|
|
484
|
+
newOrder2 = Date.now();
|
|
485
|
+
expandedIds.value.add(targetId3);
|
|
486
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
487
|
+
}
|
|
488
|
+
treeMap.set(crossPayload.id, { ...entry2, parentId: newParentId2, order: newOrder2 });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const targetId2 = dropIndicator.value?.targetId ?? null;
|
|
492
|
+
dropIndicator.value = null;
|
|
493
|
+
onExternalFileDrop(e, targetId2 !== "__trash__" ? targetId2 : null);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (!dropIndicator.value) {
|
|
497
|
+
cleanupDrag();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const docId = dragId.value;
|
|
501
|
+
const { targetId, position } = dropIndicator.value;
|
|
502
|
+
const dragIsTrash = !!trashMap.data[docId];
|
|
503
|
+
if (targetId === "__trash__" && !dragIsTrash) {
|
|
504
|
+
const idsToMove2 = multiSelected.value.size > 0 ? [...multiSelected.value] : [docId];
|
|
505
|
+
for (const id of idsToMove2) moveToTrash(id, userName.value);
|
|
506
|
+
multiSelected.value = /* @__PURE__ */ new Set();
|
|
507
|
+
cleanupDrag();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (dragIsTrash && !flatItems.value.find((i) => i.id === targetId)?.isTrash && targetId !== "__trash__") {
|
|
511
|
+
restoreFromTrash(docId);
|
|
512
|
+
nextTick(() => {
|
|
513
|
+
const targetNode2 = nodeMap.value.get(targetId);
|
|
514
|
+
const entry2 = treeMap.get(docId);
|
|
515
|
+
if (!targetNode2 || !entry2) {
|
|
516
|
+
cleanupDrag();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
let newParentId2;
|
|
520
|
+
let newOrder2;
|
|
521
|
+
if (position === "before") {
|
|
522
|
+
newParentId2 = targetNode2.parentId;
|
|
523
|
+
newOrder2 = computeOrder(targetNode2, "before");
|
|
524
|
+
} else if (position === "after") {
|
|
525
|
+
newParentId2 = targetNode2.parentId;
|
|
526
|
+
newOrder2 = computeOrder(targetNode2, "after");
|
|
527
|
+
} else {
|
|
528
|
+
newParentId2 = targetId;
|
|
529
|
+
newOrder2 = Date.now();
|
|
530
|
+
expandedIds.value.add(targetId);
|
|
531
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
532
|
+
}
|
|
533
|
+
treeMap.set(docId, { ...entry2, parentId: newParentId2, order: newOrder2 });
|
|
534
|
+
});
|
|
535
|
+
toast.add({ title: "Restored from trash", icon: "i-lucide-undo-2" });
|
|
536
|
+
cleanupDrag();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const targetNode = nodeMap.value.get(targetId);
|
|
540
|
+
const entry = treeMap.get(docId);
|
|
541
|
+
if (!targetNode || !entry) {
|
|
542
|
+
cleanupDrag();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
let newParentId;
|
|
546
|
+
let newOrder;
|
|
547
|
+
if (position === "before") {
|
|
548
|
+
newParentId = targetNode.parentId;
|
|
549
|
+
newOrder = computeOrder(targetNode, "before");
|
|
550
|
+
} else if (position === "after") {
|
|
551
|
+
newParentId = targetNode.parentId;
|
|
552
|
+
newOrder = computeOrder(targetNode, "after");
|
|
553
|
+
} else {
|
|
554
|
+
newParentId = targetId;
|
|
555
|
+
newOrder = Date.now();
|
|
556
|
+
expandedIds.value.add(targetId);
|
|
557
|
+
expandedIds.value = new Set(expandedIds.value);
|
|
558
|
+
}
|
|
559
|
+
const idsToMove = multiSelected.value.size > 0 ? [...multiSelected.value] : [docId];
|
|
560
|
+
idsToMove.forEach((id, idx) => {
|
|
561
|
+
const e2 = treeMap.get(id);
|
|
562
|
+
if (e2) treeMap.set(id, { ...e2, parentId: newParentId, order: newOrder + idx });
|
|
119
563
|
});
|
|
120
|
-
|
|
564
|
+
cleanupDrag();
|
|
565
|
+
}
|
|
566
|
+
function onDragEnd() {
|
|
567
|
+
cleanupDrag();
|
|
568
|
+
}
|
|
569
|
+
function cleanupDrag() {
|
|
570
|
+
dragId.value = null;
|
|
571
|
+
dropIndicator.value = null;
|
|
572
|
+
if (expandTimeout) {
|
|
573
|
+
clearTimeout(expandTimeout);
|
|
574
|
+
expandTimeout = null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function duplicateDoc(id) {
|
|
578
|
+
const idsMap = /* @__PURE__ */ new Map();
|
|
579
|
+
function collectSubtree(nodeId) {
|
|
580
|
+
idsMap.set(nodeId, crypto.randomUUID());
|
|
581
|
+
for (const [cid, v] of Object.entries(treeMap.data))
|
|
582
|
+
if (v.parentId === nodeId) collectSubtree(cid);
|
|
583
|
+
}
|
|
584
|
+
collectSubtree(id);
|
|
585
|
+
for (const [oldId, newId] of idsMap) {
|
|
586
|
+
const entry = treeMap.get(oldId);
|
|
587
|
+
if (!entry) continue;
|
|
588
|
+
const newParentId = entry.parentId && idsMap.has(entry.parentId) ? idsMap.get(entry.parentId) : entry.parentId;
|
|
589
|
+
treeMap.set(newId, { ...entry, parentId: newParentId, order: entry.order + 1 });
|
|
590
|
+
}
|
|
591
|
+
const newRootId = idsMap.get(id);
|
|
592
|
+
if (newRootId) emit("navigate", newRootId);
|
|
593
|
+
}
|
|
594
|
+
function changeDocType(id, type) {
|
|
595
|
+
const entry = treeMap.get(id);
|
|
596
|
+
if (entry) treeMap.set(id, { ...entry, type });
|
|
597
|
+
}
|
|
598
|
+
const overlayNodeId = ref(null);
|
|
599
|
+
const overlayProvider = shallowRef(null);
|
|
600
|
+
const overlayLabel = ref("");
|
|
601
|
+
const overlayLoading = ref(false);
|
|
602
|
+
async function openAsOverlay(id) {
|
|
603
|
+
if (!provider.value) return;
|
|
604
|
+
const entry = treeMap.get(id);
|
|
605
|
+
overlayLabel.value = entry?.label || "Untitled";
|
|
606
|
+
overlayNodeId.value = id;
|
|
607
|
+
overlayLoading.value = true;
|
|
608
|
+
try {
|
|
609
|
+
const childProv = await provider.value.loadChild(id);
|
|
610
|
+
if (!childProv.isSynced) {
|
|
611
|
+
await new Promise((resolve) => {
|
|
612
|
+
const done = () => {
|
|
613
|
+
childProv.off("synced", done);
|
|
614
|
+
resolve();
|
|
615
|
+
};
|
|
616
|
+
childProv.on("synced", done);
|
|
617
|
+
setTimeout(resolve, 6e3);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
overlayProvider.value = childProv;
|
|
621
|
+
} finally {
|
|
622
|
+
overlayLoading.value = false;
|
|
623
|
+
}
|
|
121
624
|
}
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
625
|
+
function closeOverlay() {
|
|
626
|
+
overlayNodeId.value = null;
|
|
627
|
+
overlayProvider.value = null;
|
|
125
628
|
}
|
|
126
|
-
function
|
|
629
|
+
async function openAsFloatingWindow(id) {
|
|
630
|
+
if (!provider.value) return;
|
|
631
|
+
const entry = treeMap.get(id);
|
|
632
|
+
const wm = useWindowManager();
|
|
633
|
+
if (wm.windows.has(id)) {
|
|
634
|
+
wm.focusWindow(id);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const childProv = await provider.value.loadChild(id);
|
|
638
|
+
if (!childProv.isSynced) {
|
|
639
|
+
await new Promise((resolve) => {
|
|
640
|
+
const done = () => {
|
|
641
|
+
childProv.off("synced", done);
|
|
642
|
+
resolve();
|
|
643
|
+
};
|
|
644
|
+
childProv.on("synced", done);
|
|
645
|
+
setTimeout(resolve, 6e3);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
wm.openWindow({
|
|
649
|
+
id,
|
|
650
|
+
title: entry?.label || "Untitled",
|
|
651
|
+
docId: id,
|
|
652
|
+
docType: entry?.type,
|
|
653
|
+
provider: childProv
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const isVoiceConnected = computed(
|
|
657
|
+
() => voiceStatus.value === "connected" || voiceStatus.value === "connecting"
|
|
658
|
+
);
|
|
659
|
+
function isCallDoc(item) {
|
|
660
|
+
return item.type === "call";
|
|
661
|
+
}
|
|
662
|
+
function isInThisCall(item) {
|
|
663
|
+
return isCallDoc(item) && isVoiceConnected.value && voiceRoomId.value === item.id;
|
|
664
|
+
}
|
|
665
|
+
function treeNodeMenuItems(item) {
|
|
666
|
+
const multiDeleteSection = multiSelected.value.size > 1 && multiSelected.value.has(item.id) ? [[{
|
|
667
|
+
label: `Move ${multiSelected.value.size} items to Trash`,
|
|
668
|
+
icon: "i-lucide-trash-2",
|
|
669
|
+
color: "error",
|
|
670
|
+
onSelect: deleteSelected
|
|
671
|
+
}]] : [];
|
|
672
|
+
const multiExportSection = multiSelected.value.size > 1 && multiSelected.value.has(item.id) ? [[{
|
|
673
|
+
label: `Export ${multiSelected.value.size} items`,
|
|
674
|
+
icon: "i-lucide-download",
|
|
675
|
+
children: [
|
|
676
|
+
{ label: "Markdown", icon: "i-lucide-file-text", onSelect: () => exportDocs([...multiSelected.value], "md") },
|
|
677
|
+
{ label: "HTML", icon: "i-lucide-file-code", onSelect: () => exportDocs([...multiSelected.value], "html") }
|
|
678
|
+
]
|
|
679
|
+
}]] : [];
|
|
127
680
|
return [
|
|
128
681
|
[
|
|
129
682
|
{
|
|
130
|
-
label:
|
|
683
|
+
label: "Open",
|
|
684
|
+
icon: "i-lucide-external-link",
|
|
685
|
+
onSelect: () => emit("navigate", item.id)
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
label: "Open as overlay",
|
|
689
|
+
icon: "i-lucide-panel-right",
|
|
690
|
+
onSelect: () => openAsOverlay(item.id)
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
label: "Open as window",
|
|
694
|
+
icon: "i-lucide-picture-in-picture-2",
|
|
695
|
+
onSelect: () => openAsFloatingWindow(item.id)
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
label: "Rename",
|
|
131
699
|
icon: "i-lucide-pencil",
|
|
132
|
-
onSelect: () => startRename(item.id
|
|
700
|
+
onSelect: () => startRename(item.id)
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
label: "Duplicate",
|
|
704
|
+
icon: "i-lucide-copy",
|
|
705
|
+
onSelect: () => duplicateDoc(item.id)
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
label: "Add child page",
|
|
709
|
+
icon: "i-lucide-file-plus",
|
|
710
|
+
onSelect: () => createDirectly(item.id)
|
|
133
711
|
}
|
|
134
712
|
],
|
|
135
713
|
[
|
|
136
714
|
{
|
|
137
|
-
label:
|
|
138
|
-
icon: "i-lucide-
|
|
139
|
-
|
|
715
|
+
label: "Change type",
|
|
716
|
+
icon: "i-lucide-layers",
|
|
717
|
+
children: getAvailableDocTypes().map((typeDef) => ({
|
|
718
|
+
label: typeDef.label,
|
|
719
|
+
icon: typeDef.icon,
|
|
720
|
+
type: "checkbox",
|
|
721
|
+
checked: item.type === typeDef.key,
|
|
722
|
+
onSelect: (e) => {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
changeDocType(item.id, typeDef.key);
|
|
725
|
+
}
|
|
726
|
+
}))
|
|
727
|
+
}
|
|
728
|
+
],
|
|
729
|
+
[
|
|
730
|
+
{
|
|
731
|
+
label: "Export",
|
|
732
|
+
icon: "i-lucide-download",
|
|
733
|
+
children: [
|
|
734
|
+
{ label: "Markdown", icon: "i-lucide-file-text", onSelect: () => exportSingleDoc(item.id, "md") },
|
|
735
|
+
{ label: "HTML", icon: "i-lucide-file-code", onSelect: () => exportSingleDoc(item.id, "html") }
|
|
736
|
+
]
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
label: "Import into...",
|
|
740
|
+
icon: "i-lucide-file-up",
|
|
741
|
+
onSelect: () => importFiles(item.id)
|
|
140
742
|
}
|
|
141
743
|
],
|
|
142
744
|
[
|
|
143
745
|
{
|
|
144
|
-
label:
|
|
145
|
-
icon: "i-lucide-trash",
|
|
746
|
+
label: "Move to Trash",
|
|
747
|
+
icon: "i-lucide-trash-2",
|
|
146
748
|
color: "error",
|
|
147
|
-
onSelect: () =>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
749
|
+
onSelect: () => deleteDoc(item.id)
|
|
750
|
+
}
|
|
751
|
+
],
|
|
752
|
+
...multiExportSection,
|
|
753
|
+
...multiDeleteSection
|
|
754
|
+
];
|
|
755
|
+
}
|
|
756
|
+
function onRestoreItem(item) {
|
|
757
|
+
restoreFromTrash(item.id);
|
|
758
|
+
toast.add({ title: `Restored "${item.name}"`, icon: "i-lucide-undo-2" });
|
|
759
|
+
}
|
|
760
|
+
function trashNodeMenuItems(item) {
|
|
761
|
+
return [
|
|
762
|
+
[{
|
|
763
|
+
label: "Restore",
|
|
764
|
+
icon: "i-lucide-undo-2",
|
|
765
|
+
onSelect: () => onRestoreItem(item)
|
|
766
|
+
}],
|
|
767
|
+
[{
|
|
768
|
+
label: "Delete permanently",
|
|
769
|
+
icon: "i-lucide-trash-2",
|
|
770
|
+
color: "error",
|
|
771
|
+
onSelect: () => {
|
|
772
|
+
permanentlyDelete(item.id);
|
|
773
|
+
toast.add({ title: `Permanently deleted "${item.name}"`, color: "error" });
|
|
153
774
|
}
|
|
775
|
+
}]
|
|
776
|
+
];
|
|
777
|
+
}
|
|
778
|
+
function trashRootMenuItems() {
|
|
779
|
+
return [
|
|
780
|
+
[{
|
|
781
|
+
label: "Empty trash",
|
|
782
|
+
icon: "i-lucide-trash-2",
|
|
783
|
+
color: "error",
|
|
784
|
+
onSelect: () => {
|
|
785
|
+
emptyTrash();
|
|
786
|
+
toast.add({ title: "Trash emptied", color: "error" });
|
|
787
|
+
}
|
|
788
|
+
}]
|
|
789
|
+
];
|
|
790
|
+
}
|
|
791
|
+
function treeAreaMenuItems() {
|
|
792
|
+
return [
|
|
793
|
+
[{ label: "New page", icon: "i-lucide-file-plus", onSelect: () => createDirectly(null) }],
|
|
794
|
+
[
|
|
795
|
+
{ label: "Import files...", icon: "i-lucide-file-up", onSelect: () => importFiles(null) },
|
|
796
|
+
{ label: "Import folder...", icon: "i-lucide-folder-up", onSelect: () => importFolder(null) }
|
|
797
|
+
],
|
|
798
|
+
[
|
|
799
|
+
{ label: "Export all as Markdown", icon: "i-lucide-download", onSelect: () => exportAll("md") },
|
|
800
|
+
{ label: "Export all as HTML", icon: "i-lucide-file-code", onSelect: () => exportAll("html") }
|
|
154
801
|
]
|
|
155
802
|
];
|
|
156
803
|
}
|
|
157
|
-
|
|
804
|
+
function nodeToFlatItem(node) {
|
|
805
|
+
return {
|
|
806
|
+
id: node.id,
|
|
807
|
+
name: node.label,
|
|
808
|
+
depth: node.depth,
|
|
809
|
+
hasChildren: node.children.length > 0,
|
|
810
|
+
expanded: expandedIds.value.has(node.id),
|
|
811
|
+
type: node.type,
|
|
812
|
+
meta: node.meta
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const ancestorChain = computed(() => {
|
|
816
|
+
if (!props.selectedId) return [];
|
|
817
|
+
const chain = [];
|
|
818
|
+
let node = nodeMap.value.get(props.selectedId);
|
|
819
|
+
while (node) {
|
|
820
|
+
chain.unshift(node);
|
|
821
|
+
node = node.parentId ? nodeMap.value.get(node.parentId) : void 0;
|
|
822
|
+
}
|
|
823
|
+
return chain;
|
|
824
|
+
});
|
|
825
|
+
const { states: awarenessStates } = useAwareness();
|
|
826
|
+
const selfClientId = computed(() => provider.value?.awareness?.clientID);
|
|
827
|
+
const MAX_PRESENCE = 3;
|
|
828
|
+
const docPresence = computed(() => {
|
|
829
|
+
const map = /* @__PURE__ */ new Map();
|
|
830
|
+
for (const [clientId, s] of awarenessStates.value) {
|
|
831
|
+
if (clientId === selfClientId.value) continue;
|
|
832
|
+
const docId = s.docId;
|
|
833
|
+
if (!docId) continue;
|
|
834
|
+
const users = map.get(docId) ?? [];
|
|
835
|
+
users.push({
|
|
836
|
+
name: s.user?.name || "?",
|
|
837
|
+
color: s.user?.color || "#888",
|
|
838
|
+
clientId
|
|
839
|
+
});
|
|
840
|
+
map.set(docId, users);
|
|
841
|
+
}
|
|
842
|
+
return map;
|
|
843
|
+
});
|
|
844
|
+
watch(
|
|
845
|
+
[() => props.selectedId, nodeMap],
|
|
846
|
+
([id]) => expandAncestors(id),
|
|
847
|
+
{ immediate: true }
|
|
848
|
+
);
|
|
849
|
+
function onOuterDrop(e) {
|
|
850
|
+
const crossPayload = parseDocDragPayload(e);
|
|
851
|
+
if (crossPayload) {
|
|
852
|
+
const entry = treeMap.get(crossPayload.id);
|
|
853
|
+
if (entry) {
|
|
854
|
+
treeMap.set(crossPayload.id, { ...entry, parentId: null, order: Date.now() });
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
onExternalFileDrop(e, null);
|
|
859
|
+
}
|
|
860
|
+
function onTreeDragEnter(e) {
|
|
861
|
+
onImportDragEnter(e);
|
|
862
|
+
}
|
|
863
|
+
function onTreeDragLeave(e) {
|
|
864
|
+
onImportDragLeave(e);
|
|
865
|
+
}
|
|
866
|
+
function onTreeDragOver(e) {
|
|
867
|
+
if (dragId.value === null && isDocDrag(e)) {
|
|
868
|
+
e.preventDefault();
|
|
869
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
onImportDragOver(e);
|
|
873
|
+
}
|
|
874
|
+
defineExpose({
|
|
875
|
+
handleExternalDrop: (e, parentId = null) => onExternalFileDrop(e, parentId)
|
|
876
|
+
});
|
|
158
877
|
</script>
|
|
159
878
|
|
|
160
879
|
<template>
|
|
161
880
|
<div
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@dragenter="allowFileDrop ? onDragEnter($event) : void 0"
|
|
165
|
-
@dragleave="allowFileDrop ? onDragLeave($event) : void 0"
|
|
166
|
-
@dragover="allowFileDrop ? onDragOver($event) : void 0"
|
|
167
|
-
@drop="allowFileDrop ? onFileDrop($event) : void 0"
|
|
881
|
+
v-if="collapsed && ancestorChain.length"
|
|
882
|
+
class="flex flex-col items-center gap-0.5 py-1.5"
|
|
168
883
|
>
|
|
169
|
-
|
|
170
|
-
|
|
884
|
+
<template v-for="(node, i) in ancestorChain" :key="node.id">
|
|
885
|
+
<div v-if="i > 0" class="h-2 w-px bg-(--ui-border)" />
|
|
886
|
+
<UTooltip :text="node.label" :content="{ side: 'right' }">
|
|
887
|
+
<UButton
|
|
888
|
+
:icon="getItemIcon(nodeToFlatItem(node))"
|
|
889
|
+
size="xs"
|
|
890
|
+
square
|
|
891
|
+
variant="ghost"
|
|
892
|
+
:color="selectedId === node.id ? 'primary' : 'neutral'"
|
|
893
|
+
@click="emit('navigate', node.id)"
|
|
894
|
+
/>
|
|
895
|
+
</UTooltip>
|
|
896
|
+
</template>
|
|
171
897
|
|
|
172
|
-
<!--
|
|
173
|
-
<div
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
898
|
+
<!-- Collapsed trash icon -->
|
|
899
|
+
<div v-if="trashedCount > 0" class="mt-auto pt-2">
|
|
900
|
+
<UChip :text="trashedCount" color="neutral" size="sm" inset>
|
|
901
|
+
<UTooltip text="Trash" :content="{ side: 'right' }">
|
|
902
|
+
<UButton
|
|
903
|
+
icon="i-lucide-trash-2"
|
|
904
|
+
size="xs"
|
|
905
|
+
square
|
|
906
|
+
variant="ghost"
|
|
907
|
+
color="neutral"
|
|
908
|
+
@click="trashExpanded = !trashExpanded"
|
|
909
|
+
/>
|
|
910
|
+
</UTooltip>
|
|
911
|
+
</UChip>
|
|
181
912
|
</div>
|
|
913
|
+
</div>
|
|
182
914
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
@dragover="onItemDragOver($event, item.id)"
|
|
200
|
-
@drop="onItemDrop($event, item.id)"
|
|
201
|
-
@dragend="onItemDragEnd"
|
|
202
|
-
>
|
|
203
|
-
<!-- Expand toggle -->
|
|
915
|
+
<UContextMenu
|
|
916
|
+
v-else-if="!collapsed"
|
|
917
|
+
:items="treeAreaMenuItems()"
|
|
918
|
+
class="relative transition-colors duration-150"
|
|
919
|
+
:class="externalDragActive ? 'ring-2 ring-inset ring-(--ui-primary)/30 rounded-(--ui-radius) bg-(--ui-primary)/3' : ''"
|
|
920
|
+
@keydown.escape.window="multiSelected = /* @__PURE__ */ new Set()"
|
|
921
|
+
@dragenter="onTreeDragEnter"
|
|
922
|
+
@dragleave="onTreeDragLeave"
|
|
923
|
+
@dragover="onTreeDragOver"
|
|
924
|
+
@drop.prevent="onOuterDrop"
|
|
925
|
+
>
|
|
926
|
+
<!-- Header -->
|
|
927
|
+
<div class="flex items-center justify-between px-2 py-1.5">
|
|
928
|
+
<span class="text-xs font-medium text-(--ui-text-muted) uppercase tracking-wide">Pages</span>
|
|
929
|
+
<div class="flex items-center gap-0.5">
|
|
930
|
+
<UTooltip :text="allExpanded ? 'Collapse all' : 'Expand all'">
|
|
204
931
|
<UButton
|
|
205
|
-
:icon="
|
|
932
|
+
:icon="allExpanded ? 'i-lucide-chevrons-down-up' : 'i-lucide-chevrons-up-down'"
|
|
206
933
|
variant="ghost"
|
|
207
934
|
color="neutral"
|
|
208
935
|
size="xs"
|
|
209
|
-
|
|
210
|
-
@click.stop="toggleExpand(item.id)"
|
|
936
|
+
@click="toggleExpandAll"
|
|
211
937
|
/>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
938
|
+
</UTooltip>
|
|
939
|
+
<UTooltip text="Sort alphabetically">
|
|
940
|
+
<UButton
|
|
941
|
+
icon="i-lucide-arrow-down-a-z"
|
|
942
|
+
variant="ghost"
|
|
943
|
+
color="neutral"
|
|
944
|
+
size="xs"
|
|
945
|
+
:disabled="!isReady"
|
|
946
|
+
@click="sortAlphabetically"
|
|
219
947
|
/>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
948
|
+
</UTooltip>
|
|
949
|
+
<UTooltip text="New page">
|
|
950
|
+
<UButton
|
|
951
|
+
icon="i-lucide-plus"
|
|
952
|
+
variant="ghost"
|
|
953
|
+
color="neutral"
|
|
954
|
+
size="xs"
|
|
955
|
+
:disabled="!isReady"
|
|
956
|
+
@click="createDirectly(null)"
|
|
225
957
|
/>
|
|
958
|
+
</UTooltip>
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<ClientOnly>
|
|
963
|
+
<template v-if="isReady">
|
|
964
|
+
<UEmpty
|
|
965
|
+
v-if="flatItems.length === 0"
|
|
966
|
+
icon="i-lucide-file-text"
|
|
967
|
+
title="No pages"
|
|
968
|
+
description="Create your first page."
|
|
969
|
+
size="sm"
|
|
970
|
+
/>
|
|
226
971
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
972
|
+
<TransitionGroup
|
|
973
|
+
v-else
|
|
974
|
+
name="tree-item"
|
|
975
|
+
tag="div"
|
|
976
|
+
:class="['px-1', { 'is-dragging': dragId }]"
|
|
977
|
+
@dragover.prevent
|
|
978
|
+
@dragleave="onListDragLeave"
|
|
979
|
+
>
|
|
980
|
+
<div
|
|
981
|
+
v-for="item in flatItems"
|
|
982
|
+
:key="item.id"
|
|
983
|
+
class="tree-item relative"
|
|
984
|
+
:class="{ 'opacity-30': dragId === item.id && !item.isTrashRoot }"
|
|
985
|
+
@dragover="onDragOver($event, item)"
|
|
986
|
+
@drop.stop="onDrop"
|
|
233
987
|
>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
988
|
+
<!-- Drop indicator: line BEFORE -->
|
|
989
|
+
<div
|
|
990
|
+
v-if="dropIndicator?.targetId === item.id && dropIndicator.position === 'before' && !item.isTrashRoot"
|
|
991
|
+
class="drop-line drop-line-before"
|
|
992
|
+
:style="{ left: `${item.depth * 16 + 8}px` }"
|
|
993
|
+
>
|
|
994
|
+
<div class="drop-line-dot" />
|
|
995
|
+
</div>
|
|
996
|
+
|
|
997
|
+
<!-- Drop indicator: highlight INSIDE -->
|
|
998
|
+
<div
|
|
999
|
+
v-if="dropIndicator?.targetId === item.id && dropIndicator.position === 'inside'"
|
|
1000
|
+
class="drop-inside"
|
|
1001
|
+
/>
|
|
1002
|
+
|
|
1003
|
+
<!-- Drop indicator: line AFTER -->
|
|
1004
|
+
<div
|
|
1005
|
+
v-if="dropIndicator?.targetId === item.id && dropIndicator.position === 'after' && !item.isTrashRoot"
|
|
1006
|
+
class="drop-line drop-line-after"
|
|
1007
|
+
:style="{ left: `${item.depth * 16 + 8}px` }"
|
|
1008
|
+
>
|
|
1009
|
+
<div class="drop-line-dot" />
|
|
1010
|
+
</div>
|
|
1011
|
+
|
|
1012
|
+
<!-- ── Trash root row ── -->
|
|
1013
|
+
<UContextMenu
|
|
1014
|
+
v-if="item.isTrashRoot"
|
|
1015
|
+
:items="trashRootMenuItems()"
|
|
1016
|
+
>
|
|
1017
|
+
<button
|
|
1018
|
+
type="button"
|
|
1019
|
+
class="flex items-center gap-1.5 w-full px-2 py-1.5 rounded-(--ui-radius) text-sm cursor-pointer select-none text-(--ui-text-muted) hover:bg-(--ui-bg-elevated)"
|
|
1020
|
+
@click="trashExpanded = !trashExpanded"
|
|
243
1021
|
>
|
|
244
|
-
|
|
245
|
-
|
|
1022
|
+
<span class="flex items-center justify-center size-5 shrink-0">
|
|
1023
|
+
<UIcon
|
|
1024
|
+
:name="trashExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
1025
|
+
class="size-3.5"
|
|
1026
|
+
/>
|
|
1027
|
+
</span>
|
|
1028
|
+
<UIcon name="i-lucide-trash-2" class="size-4 shrink-0" />
|
|
1029
|
+
<span class="truncate flex-1 min-w-0 text-left">Trash</span>
|
|
1030
|
+
<UBadge
|
|
1031
|
+
v-if="trashedCount > 0"
|
|
1032
|
+
:label="String(trashedCount)"
|
|
1033
|
+
color="neutral"
|
|
1034
|
+
variant="subtle"
|
|
1035
|
+
size="xs"
|
|
1036
|
+
/>
|
|
1037
|
+
</button>
|
|
1038
|
+
</UContextMenu>
|
|
1039
|
+
|
|
1040
|
+
<!-- ── Trash item row ── -->
|
|
1041
|
+
<UContextMenu
|
|
1042
|
+
v-else-if="item.isTrash"
|
|
1043
|
+
:items="trashNodeMenuItems(item)"
|
|
1044
|
+
>
|
|
1045
|
+
<button
|
|
1046
|
+
type="button"
|
|
1047
|
+
class="flex items-center gap-1.5 w-full py-1.5 pr-2 rounded-(--ui-radius) text-sm cursor-pointer select-none relative opacity-60 hover:bg-(--ui-bg-elevated) text-(--ui-text-dimmed)"
|
|
1048
|
+
:style="{ paddingLeft: `${item.depth * 16 + 8}px` }"
|
|
1049
|
+
draggable="true"
|
|
1050
|
+
@dragstart="onDragStart($event, item)"
|
|
1051
|
+
@dragend="onDragEnd"
|
|
1052
|
+
>
|
|
1053
|
+
<span
|
|
1054
|
+
v-if="item.hasChildren"
|
|
1055
|
+
class="flex items-center justify-center size-5 shrink-0"
|
|
1056
|
+
@click.stop="toggleExpand(item.id)"
|
|
1057
|
+
>
|
|
1058
|
+
<UIcon
|
|
1059
|
+
:name="item.expanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
1060
|
+
class="size-3.5"
|
|
1061
|
+
/>
|
|
1062
|
+
</span>
|
|
1063
|
+
<span v-else class="size-5 shrink-0" />
|
|
1064
|
+
|
|
1065
|
+
<UIcon :name="getItemIcon(item)" class="size-4 shrink-0 text-(--ui-text-muted)" />
|
|
1066
|
+
<span class="truncate flex-1 min-w-0 text-left">{{ item.name }}</span>
|
|
1067
|
+
</button>
|
|
1068
|
+
</UContextMenu>
|
|
1069
|
+
|
|
1070
|
+
<!-- ── Editable row ── -->
|
|
1071
|
+
<UContextMenu
|
|
1072
|
+
v-else-if="editable"
|
|
1073
|
+
:items="treeNodeMenuItems(item)"
|
|
1074
|
+
>
|
|
1075
|
+
<button
|
|
1076
|
+
type="button"
|
|
1077
|
+
class="flex items-center gap-1.5 w-full py-1.5 pr-2 rounded-(--ui-radius) text-sm cursor-pointer select-none relative"
|
|
1078
|
+
:class="[
|
|
1079
|
+
selectedId === item.id ? 'bg-(--ui-primary)/10 text-(--ui-text-highlighted) font-medium ring-1 ring-inset ring-(--ui-primary)/20' : 'hover:bg-(--ui-bg-elevated) text-(--ui-text-dimmed)',
|
|
1080
|
+
multiSelected.has(item.id) && selectedId !== item.id ? 'bg-(--ui-primary)/8 ring-1 ring-inset ring-(--ui-primary)/25' : ''
|
|
1081
|
+
]"
|
|
1082
|
+
:style="{ paddingLeft: `${item.depth * 16 + 8}px` }"
|
|
1083
|
+
:draggable="editable"
|
|
1084
|
+
@dragstart="onDragStart($event, item)"
|
|
1085
|
+
@dragend="onDragEnd"
|
|
1086
|
+
@click="onItemClick($event, item)"
|
|
1087
|
+
>
|
|
1088
|
+
<span
|
|
1089
|
+
v-if="item.hasChildren"
|
|
1090
|
+
class="flex items-center justify-center size-5 shrink-0"
|
|
1091
|
+
@click.stop="toggleExpand(item.id)"
|
|
1092
|
+
>
|
|
1093
|
+
<UIcon
|
|
1094
|
+
:name="item.expanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
1095
|
+
class="size-3.5"
|
|
1096
|
+
/>
|
|
1097
|
+
</span>
|
|
1098
|
+
<span v-else class="size-5 shrink-0" />
|
|
1099
|
+
|
|
1100
|
+
<UIcon
|
|
1101
|
+
:name="getItemIcon(item)"
|
|
1102
|
+
class="size-4 shrink-0"
|
|
1103
|
+
:class="getItemIconColor(item) ? '' : 'text-(--ui-text-muted)'"
|
|
1104
|
+
:style="getItemIconColor(item) ? { color: getItemIconColor(item) } : void 0"
|
|
1105
|
+
/>
|
|
1106
|
+
|
|
1107
|
+
<UInput
|
|
1108
|
+
v-if="renameId === item.id"
|
|
1109
|
+
ref="renameInputRef"
|
|
1110
|
+
v-model="renameValue"
|
|
1111
|
+
size="xs"
|
|
1112
|
+
variant="none"
|
|
1113
|
+
class="flex-1 -my-1"
|
|
1114
|
+
@keydown.enter="commitRename"
|
|
1115
|
+
@keydown.escape="renameId = null"
|
|
1116
|
+
@blur="commitRename"
|
|
1117
|
+
@click.stop
|
|
1118
|
+
/>
|
|
1119
|
+
<span v-else class="truncate flex-1 min-w-0 text-left">{{ item.name }}</span>
|
|
1120
|
+
|
|
1121
|
+
<!-- Unread chat badge -->
|
|
1122
|
+
<UBadge
|
|
1123
|
+
v-if="(chatChannels[`group:${item.id}`]?.unreadCount ?? 0) > 0"
|
|
1124
|
+
class="tree-unread shrink-0"
|
|
1125
|
+
:label="chatChannels[`group:${item.id}`].unreadCount"
|
|
1126
|
+
color="error"
|
|
1127
|
+
variant="solid"
|
|
1128
|
+
size="xs"
|
|
1129
|
+
/>
|
|
1130
|
+
|
|
1131
|
+
<!-- Presence avatars -->
|
|
1132
|
+
<div
|
|
1133
|
+
v-if="docPresence.get(item.id)?.length"
|
|
1134
|
+
class="tree-presence flex items-center gap-px shrink-0 mr-2"
|
|
1135
|
+
>
|
|
1136
|
+
<div
|
|
1137
|
+
v-for="u in docPresence.get(item.id).slice(0, MAX_PRESENCE)"
|
|
1138
|
+
:key="u.clientId"
|
|
1139
|
+
class="size-4 rounded-full flex items-center justify-center text-[8px] font-bold leading-none select-none"
|
|
1140
|
+
:style="avatarBorderStyle(u.color, presenceNeutral)"
|
|
1141
|
+
:title="u.name"
|
|
1142
|
+
>
|
|
1143
|
+
{{ u.name[0]?.toUpperCase() }}
|
|
1144
|
+
</div>
|
|
1145
|
+
<div
|
|
1146
|
+
v-if="docPresence.get(item.id).length > MAX_PRESENCE"
|
|
1147
|
+
class="size-4 rounded-full flex items-center justify-center text-[8px] font-medium leading-none ring-1 ring-(--ui-bg) bg-(--ui-bg-elevated) text-(--ui-text-muted)"
|
|
1148
|
+
>
|
|
1149
|
+
+{{ docPresence.get(item.id).length - MAX_PRESENCE }}
|
|
1150
|
+
</div>
|
|
1151
|
+
</div>
|
|
1152
|
+
|
|
1153
|
+
</button>
|
|
1154
|
+
</UContextMenu>
|
|
1155
|
+
|
|
1156
|
+
<!-- ── Read-only row ── -->
|
|
1157
|
+
<UContextMenu
|
|
246
1158
|
v-else
|
|
247
|
-
|
|
248
|
-
>
|
|
249
|
-
|
|
1159
|
+
:items="treeNodeMenuItems(item)"
|
|
1160
|
+
>
|
|
1161
|
+
<button
|
|
1162
|
+
type="button"
|
|
1163
|
+
class="flex items-center gap-1.5 w-full py-1.5 pr-2 rounded-(--ui-radius) text-sm cursor-pointer select-none"
|
|
1164
|
+
:class="[
|
|
1165
|
+
selectedId === item.id ? 'bg-(--ui-primary)/10 text-(--ui-text-highlighted) font-medium ring-1 ring-inset ring-(--ui-primary)/20' : 'hover:bg-(--ui-bg-elevated) text-(--ui-text-dimmed)',
|
|
1166
|
+
multiSelected.has(item.id) && selectedId !== item.id ? 'bg-(--ui-primary)/8 ring-1 ring-inset ring-(--ui-primary)/25' : ''
|
|
1167
|
+
]"
|
|
1168
|
+
:style="{ paddingLeft: `${item.depth * 16 + 8}px` }"
|
|
1169
|
+
@click="onItemClick($event, item)"
|
|
1170
|
+
>
|
|
1171
|
+
<span
|
|
1172
|
+
v-if="item.hasChildren"
|
|
1173
|
+
class="flex items-center justify-center size-5 shrink-0"
|
|
1174
|
+
@click.stop="toggleExpand(item.id)"
|
|
1175
|
+
>
|
|
1176
|
+
<UIcon
|
|
1177
|
+
:name="item.expanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
1178
|
+
class="size-3.5"
|
|
1179
|
+
/>
|
|
1180
|
+
</span>
|
|
1181
|
+
<span v-else class="size-5 shrink-0" />
|
|
250
1182
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
variant="ghost"
|
|
259
|
-
color="neutral"
|
|
260
|
-
size="xs"
|
|
261
|
-
class="opacity-0 group-hover:opacity-100 flex-shrink-0"
|
|
262
|
-
@click.stop
|
|
263
|
-
/>
|
|
264
|
-
</UDropdownMenu>
|
|
265
|
-
</div>
|
|
266
|
-
</template>
|
|
1183
|
+
<UIcon
|
|
1184
|
+
:name="getItemIcon(item)"
|
|
1185
|
+
class="size-4 shrink-0"
|
|
1186
|
+
:class="getItemIconColor(item) ? '' : 'text-(--ui-text-muted)'"
|
|
1187
|
+
:style="getItemIconColor(item) ? { color: getItemIconColor(item) } : void 0"
|
|
1188
|
+
/>
|
|
1189
|
+
<span class="truncate flex-1 text-left">{{ item.name }}</span>
|
|
267
1190
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
/>
|
|
279
|
-
</div>
|
|
1191
|
+
<!-- Call join/leave button -->
|
|
1192
|
+
<UButton
|
|
1193
|
+
v-if="isCallDoc(item)"
|
|
1194
|
+
:icon="isInThisCall(item) ? 'i-lucide-phone-off' : 'i-lucide-phone'"
|
|
1195
|
+
size="xs"
|
|
1196
|
+
:color="isInThisCall(item) ? 'error' : 'success'"
|
|
1197
|
+
variant="ghost"
|
|
1198
|
+
class="shrink-0"
|
|
1199
|
+
@click.prevent.stop="isInThisCall(item) ? leaveRoom() : joinRoom(item.id)"
|
|
1200
|
+
/>
|
|
280
1201
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
1202
|
+
<!-- Presence avatars -->
|
|
1203
|
+
<div
|
|
1204
|
+
v-if="docPresence.get(item.id)?.length"
|
|
1205
|
+
class="flex items-center gap-px shrink-0"
|
|
1206
|
+
>
|
|
1207
|
+
<div
|
|
1208
|
+
v-for="u in docPresence.get(item.id).slice(0, MAX_PRESENCE)"
|
|
1209
|
+
:key="u.clientId"
|
|
1210
|
+
class="size-4 rounded-full flex items-center justify-center text-[8px] font-bold leading-none select-none"
|
|
1211
|
+
:style="avatarBorderStyle(u.color, presenceNeutral)"
|
|
1212
|
+
:title="u.name"
|
|
1213
|
+
>
|
|
1214
|
+
{{ u.name[0]?.toUpperCase() }}
|
|
1215
|
+
</div>
|
|
1216
|
+
<div
|
|
1217
|
+
v-if="docPresence.get(item.id).length > MAX_PRESENCE"
|
|
1218
|
+
class="size-4 rounded-full flex items-center justify-center text-[8px] font-medium leading-none ring-1 ring-(--ui-bg) bg-(--ui-bg-elevated) text-(--ui-text-muted)"
|
|
1219
|
+
>
|
|
1220
|
+
+{{ docPresence.get(item.id).length - MAX_PRESENCE }}
|
|
1221
|
+
</div>
|
|
1222
|
+
</div>
|
|
1223
|
+
</button>
|
|
1224
|
+
</UContextMenu>
|
|
290
1225
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1226
|
+
<!-- ── Hover actions (positioned relative to .tree-item, outside UContextMenu) ── -->
|
|
1227
|
+
<!-- Trash item actions -->
|
|
1228
|
+
<div
|
|
1229
|
+
v-if="item.isTrash"
|
|
1230
|
+
class="tree-row-actions absolute right-1 top-1/2 -translate-y-1/2 flex items-center bg-(--ui-bg-elevated)"
|
|
1231
|
+
@click.stop
|
|
1232
|
+
>
|
|
1233
|
+
<UTooltip text="Restore">
|
|
1234
|
+
<UButton
|
|
1235
|
+
icon="i-lucide-undo-2"
|
|
1236
|
+
size="xs"
|
|
1237
|
+
variant="ghost"
|
|
1238
|
+
color="neutral"
|
|
1239
|
+
@click="onRestoreItem(item)"
|
|
1240
|
+
/>
|
|
1241
|
+
</UTooltip>
|
|
1242
|
+
<UDropdownMenu :items="trashNodeMenuItems(item)" :content="{ align: 'start' }">
|
|
1243
|
+
<UButton icon="i-lucide-ellipsis" size="xs" variant="ghost" color="neutral" />
|
|
1244
|
+
</UDropdownMenu>
|
|
1245
|
+
</div>
|
|
1246
|
+
<!-- Editable item actions -->
|
|
1247
|
+
<div
|
|
1248
|
+
v-if="editable && !item.isTrash && !item.isTrashRoot && renameId !== item.id"
|
|
1249
|
+
class="tree-row-actions absolute right-1 top-1/2 -translate-y-1/2 flex items-center bg-(--ui-bg-elevated)"
|
|
1250
|
+
:class="{ 'tree-row-actions--call': isCallDoc(item) }"
|
|
1251
|
+
@click.stop
|
|
1252
|
+
>
|
|
1253
|
+
<UButton
|
|
1254
|
+
v-if="isCallDoc(item)"
|
|
1255
|
+
:icon="isInThisCall(item) ? 'i-lucide-phone-off' : 'i-lucide-phone'"
|
|
1256
|
+
size="xs"
|
|
1257
|
+
:color="isInThisCall(item) ? 'error' : 'success'"
|
|
1258
|
+
variant="ghost"
|
|
1259
|
+
@click="isInThisCall(item) ? leaveRoom() : joinRoom(item.id)"
|
|
1260
|
+
/>
|
|
1261
|
+
<UTooltip v-if="!isCallDoc(item)" text="Add child page">
|
|
1262
|
+
<UButton
|
|
1263
|
+
icon="i-lucide-plus"
|
|
1264
|
+
size="xs"
|
|
1265
|
+
variant="ghost"
|
|
1266
|
+
color="neutral"
|
|
1267
|
+
@click="createDirectly(item.id)"
|
|
1268
|
+
/>
|
|
1269
|
+
</UTooltip>
|
|
1270
|
+
<UDropdownMenu :items="treeNodeMenuItems(item)" :content="{ align: 'start' }">
|
|
1271
|
+
<UButton icon="i-lucide-ellipsis" size="xs" variant="ghost" color="neutral" />
|
|
1272
|
+
</UDropdownMenu>
|
|
1273
|
+
</div>
|
|
1274
|
+
</div>
|
|
1275
|
+
</TransitionGroup>
|
|
305
1276
|
|
|
306
|
-
|
|
1277
|
+
<!-- External drag hint bar -->
|
|
1278
|
+
<Transition name="drop-hint">
|
|
307
1279
|
<div
|
|
308
|
-
v-
|
|
309
|
-
|
|
310
|
-
class="group flex items-center gap-1.5 pl-4 pr-2 py-0.5 text-sm text-muted"
|
|
1280
|
+
v-if="externalDragActive"
|
|
1281
|
+
class="mx-2 my-1.5 px-3 py-2 rounded-(--ui-radius) bg-(--ui-bg-elevated) ring-1 ring-(--ui-border) shadow-sm flex items-center gap-2 pointer-events-none"
|
|
311
1282
|
>
|
|
312
|
-
<UIcon
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
icon="i-lucide-rotate-ccw"
|
|
316
|
-
variant="ghost"
|
|
317
|
-
color="neutral"
|
|
318
|
-
size="xs"
|
|
319
|
-
class="opacity-0 group-hover:opacity-100"
|
|
320
|
-
@click="restoreFromTrash(item.id)"
|
|
321
|
-
/>
|
|
322
|
-
<UButton
|
|
323
|
-
icon="i-lucide-trash-2"
|
|
324
|
-
variant="ghost"
|
|
325
|
-
color="error"
|
|
326
|
-
size="xs"
|
|
327
|
-
class="opacity-0 group-hover:opacity-100"
|
|
328
|
-
@click="permanentlyDelete(item.id)"
|
|
329
|
-
/>
|
|
330
|
-
</div>
|
|
331
|
-
|
|
332
|
-
<div class="px-2 py-1">
|
|
333
|
-
<UButton
|
|
334
|
-
icon="i-lucide-trash-2"
|
|
335
|
-
variant="ghost"
|
|
336
|
-
color="error"
|
|
337
|
-
size="xs"
|
|
338
|
-
:label="locale.emptyTrash"
|
|
339
|
-
class="w-full justify-start"
|
|
340
|
-
@click="emptyTrash()"
|
|
1283
|
+
<UIcon
|
|
1284
|
+
:name="dropIndicator && nodeMap.get(dropIndicator.targetId) ? 'i-lucide-folder-input' : 'i-lucide-upload'"
|
|
1285
|
+
class="size-3.5 text-(--ui-primary) shrink-0"
|
|
341
1286
|
/>
|
|
1287
|
+
<span
|
|
1288
|
+
v-if="dropIndicator && nodeMap.get(dropIndicator.targetId)"
|
|
1289
|
+
class="text-xs text-(--ui-text-muted) truncate"
|
|
1290
|
+
>
|
|
1291
|
+
Drop into
|
|
1292
|
+
<span class="font-medium text-(--ui-text-highlighted)">{{ nodeMap.get(dropIndicator.targetId)?.label }}</span>
|
|
1293
|
+
</span>
|
|
1294
|
+
<span v-else class="text-xs text-(--ui-text-muted)">
|
|
1295
|
+
Drop to import — docs, images, or any file
|
|
1296
|
+
</span>
|
|
342
1297
|
</div>
|
|
343
|
-
</
|
|
1298
|
+
</Transition>
|
|
344
1299
|
</template>
|
|
345
|
-
</
|
|
1300
|
+
</ClientOnly>
|
|
346
1301
|
|
|
347
|
-
<!--
|
|
348
|
-
<
|
|
349
|
-
|
|
1302
|
+
<!-- Overlay slideover -->
|
|
1303
|
+
<ANodePanel
|
|
1304
|
+
:node-id="overlayNodeId"
|
|
1305
|
+
:node-label="overlayLabel"
|
|
1306
|
+
:child-provider="overlayProvider"
|
|
1307
|
+
:doc-type="treeMap.get(overlayNodeId ?? '')?.type"
|
|
1308
|
+
@close="closeOverlay"
|
|
1309
|
+
/>
|
|
1310
|
+
</UContextMenu>
|
|
350
1311
|
</template>
|
|
1312
|
+
|
|
1313
|
+
<style scoped>
|
|
1314
|
+
.tree-item-move{transition:transform .15s ease-out}.is-dragging .tree-item-move{transition:none}.tree-item-enter-active{transition:all .15s ease-out}.tree-item-leave-active{position:absolute;transition:all .1s ease-in;width:calc(100% - 8px)}.tree-item-enter-from,.tree-item-leave-to{opacity:0;transform:translateY(-4px)}.tree-row-actions{opacity:0;pointer-events:none;transition:opacity .1s ease}.tree-row-actions--call{opacity:1;pointer-events:auto}@media (hover:hover) and (pointer:fine){.tree-item:hover .tree-row-actions,.tree-row-actions:has([data-state=open]){opacity:1;pointer-events:auto}.tree-item:hover .tree-presence,.tree-item:hover .tree-unread{visibility:hidden}}.tree-row-actions:has([data-state=open]){opacity:1;pointer-events:auto}.drop-line{background:var(--ui-primary);border-radius:1px;height:2px;pointer-events:none;position:absolute;right:4px;z-index:20}.drop-line-before{top:0}.drop-line-after{bottom:0}.drop-line-dot{background:var(--ui-bg);border:2px solid var(--ui-primary);border-radius:50%;height:8px;left:-3px;position:absolute;top:-3px;width:8px}.drop-inside{background:color-mix(in srgb,var(--ui-primary) 8%,transparent);border:2px solid color-mix(in srgb,var(--ui-primary) 50%,transparent);border-radius:var(--ui-radius);inset:1px 4px;pointer-events:none;position:absolute;z-index:10}.drop-hint-enter-active,.drop-hint-leave-active{transition:all .15s ease}.drop-hint-enter-from,.drop-hint-leave-to{opacity:0;transform:translateY(4px)}
|
|
1315
|
+
</style>
|