@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.
@@ -1,350 +1,1315 @@
1
1
  <script setup>
2
- import { ref, computed, shallowRef } from "vue";
3
- import { useSyncedMap } from "../composables/useYDoc";
4
- import { useDocImport } from "../composables/useDocImport";
5
- import { useTrash } from "../composables/useTrash";
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
- showTrash: { type: Boolean, required: false, default: true },
10
- allowFileDrop: { type: Boolean, required: false, default: true },
11
- draggable: { type: Boolean, required: false, default: true },
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(["select", "create"]);
16
- const locale = computed(() => useAbraLocale("documentTree", props.labels));
17
- const { doc, registry } = useAbracadabra();
18
- const rootDoc = doc;
19
- const treeMap = useSyncedMap(rootDoc, "doc-tree");
20
- const { trashedItems, restoreFromTrash, permanentlyDelete, emptyTrash } = useTrash();
21
- const {
22
- externalDragActive,
23
- onDragEnter,
24
- onDragLeave,
25
- onDragOver,
26
- onFileDrop
27
- } = useDocImport();
28
- const expandedIds = ref(/* @__PURE__ */ new Set());
29
- const renamingId = ref(null);
30
- const renameValue = ref("");
31
- const allEntries = computed(
32
- () => Object.entries(treeMap.data).map(([id, v]) => ({
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 || locale.value.untitled,
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
- function childrenOf(parentId) {
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
- function hasChildren(id) {
45
- return allEntries.value.some((e) => e.parentId === id);
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 buildFlat(parentId, depth) {
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
- for (const entry of childrenOf(parentId)) {
50
- const expanded = expandedIds.value.has(entry.id);
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: entry.id,
53
- label: entry.label,
54
- type: entry.type,
55
- meta: entry.meta,
56
- depth,
57
- parentId: entry.parentId,
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 (expanded) {
63
- result.push(...buildFlat(entry.id, depth + 1));
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
- const flatItems = computed(() => buildFlat(null, 0));
69
- function toggleExpand(id) {
70
- const next = new Set(expandedIds.value);
71
- if (next.has(id)) next.delete(id);
72
- else next.add(id);
73
- expandedIds.value = next;
74
- }
75
- function startRename(id, label) {
76
- renamingId.value = id;
77
- renameValue.value = label;
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 = document.getElementById(`rename-${id}`);
80
- input?.select();
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(id) {
84
- const trimmed = renameValue.value.trim();
85
- if (trimmed && trimmed !== treeMap.get(id)?.label) {
86
- const entry = treeMap.get(id);
87
- if (entry) treeMap.set(id, { ...entry, label: trimmed, updatedAt: Date.now() });
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
- const DOC_DRAG_MIME = "application/x-abracadabra-doc";
92
- const draggingId = ref(null);
93
- const dragOverId = ref(null);
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 onItemDragOver(e, id) {
100
- if (!props.draggable || !draggingId.value) return;
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
- dragOverId.value = id;
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 onItemDrop(e, targetId) {
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
- dragOverId.value = null;
107
- const srcId = e.dataTransfer?.getData(DOC_DRAG_MIME) || draggingId.value;
108
- draggingId.value = null;
109
- if (!srcId || srcId === targetId) return;
110
- const targetEntry = treeMap.get(targetId);
111
- if (!targetEntry) return;
112
- const srcEntry = treeMap.get(srcId);
113
- if (!srcEntry) return;
114
- treeMap.set(srcId, {
115
- ...srcEntry,
116
- parentId: targetEntry.parentId,
117
- order: (targetEntry.order ?? 0) - 0.5,
118
- updatedAt: Date.now()
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
- expandedIds.value = new Set(expandedIds.value);
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 onItemDragEnd() {
123
- draggingId.value = null;
124
- dragOverId.value = null;
625
+ function closeOverlay() {
626
+ overlayNodeId.value = null;
627
+ overlayProvider.value = null;
125
628
  }
126
- function getContextItems(item) {
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: locale.value.rename,
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, item.label)
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: locale.value.newChild,
138
- icon: "i-lucide-file-plus",
139
- onSelect: () => emit("create", item.id)
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: locale.value.moveToTrash,
145
- icon: "i-lucide-trash",
746
+ label: "Move to Trash",
747
+ icon: "i-lucide-trash-2",
146
748
  color: "error",
147
- onSelect: () => {
148
- const entry = treeMap.get(item.id);
149
- if (!entry) return;
150
- const { moveToTrash } = useTrash();
151
- moveToTrash(item.id);
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
- const showTrashExpanded = ref(false);
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
- class="flex flex-col h-full overflow-hidden"
163
- :class="externalDragActive ? 'ring-2 ring-primary ring-inset' : ''"
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
- <!-- Header slot -->
170
- <slot name="header" />
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
- <!-- File drop overlay -->
173
- <div
174
- v-if="externalDragActive"
175
- class="absolute inset-0 z-10 bg-primary/10 flex items-center justify-center pointer-events-none rounded-lg"
176
- >
177
- <div class="text-center">
178
- <UIcon name="i-lucide-upload" class="size-8 text-primary mb-2" />
179
- <p class="text-sm text-primary font-medium">{{ locale.dropFilesHere }}</p>
180
- </div>
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
- <!-- Tree items -->
184
- <div class="flex-1 overflow-y-auto py-1">
185
- <template v-if="flatItems.length">
186
- <div
187
- v-for="item in flatItems"
188
- :key="item.id"
189
- :style="{ paddingLeft: `${item.depth * 16 + 8}px` }"
190
- :draggable="draggable"
191
- :class="[
192
- 'group flex items-center gap-1.5 pr-2 py-0.5 rounded-md cursor-pointer text-sm select-none',
193
- selectedId === item.id ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-default',
194
- dragOverId === item.id ? 'bg-primary/20' : ''
195
- ]"
196
- @click="emit('select', item.id)"
197
- @dblclick="startRename(item.id, item.label)"
198
- @dragstart="onItemDragStart($event, item.id)"
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="item.isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
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
- :class="item.hasChildren ? 'opacity-100' : 'opacity-0 pointer-events-none'"
210
- @click.stop="toggleExpand(item.id)"
936
+ @click="toggleExpandAll"
211
937
  />
212
-
213
- <!-- Doc icon: custom meta.icon > doc type default -->
214
- <UIcon
215
- :name="item.meta?.icon ?? resolveDocType(item.type, registry).icon"
216
- class="size-3.5 flex-shrink-0"
217
- :style="item.meta?.color ? `color: ${item.meta.color}` : ''"
218
- :class="item.meta?.color ? '' : 'text-muted'"
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
- <!-- Color dot (shown only if color set but no custom icon) -->
221
- <span
222
- v-if="item.meta?.color && !item.meta?.icon"
223
- class="size-1.5 rounded-full shrink-0 -ml-1"
224
- :style="`background: ${item.meta.color}`"
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
- <!-- Slot override or default label -->
228
- <slot
229
- name="item"
230
- :entry="item"
231
- :depth="item.depth"
232
- :is-expanded="item.isExpanded"
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
- <template v-if="renamingId === item.id">
235
- <input
236
- :id="`rename-${item.id}`"
237
- v-model="renameValue"
238
- class="flex-1 min-w-0 bg-transparent border-none outline-none text-sm"
239
- @blur="commitRename(item.id)"
240
- @keydown.enter.stop="commitRename(item.id)"
241
- @keydown.escape.stop="renamingId = null"
242
- @click.stop
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
- </template>
245
- <span
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
- class="flex-1 min-w-0 truncate"
248
- >{{ item.label }}</span>
249
- </slot>
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
- <!-- Context menu -->
252
- <UDropdownMenu
253
- :items="getContextItems(item)"
254
- :content="{ align: 'end' }"
255
- >
256
- <UButton
257
- icon="i-lucide-ellipsis"
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
- <!-- New doc button -->
269
- <div class="px-2 py-1">
270
- <UButton
271
- icon="i-lucide-plus"
272
- variant="ghost"
273
- color="neutral"
274
- size="xs"
275
- :label="locale.newDocument"
276
- class="w-full justify-start text-muted"
277
- @click="emit('create', null)"
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
- <!-- Empty state -->
282
- <div
283
- v-if="!flatItems.length"
284
- class="px-4 py-8 text-center"
285
- >
286
- <slot name="empty">
287
- <p class="text-sm text-muted">{{ locale.noDocuments }}</p>
288
- </slot>
289
- </div>
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
- <!-- Trash section -->
292
- <template v-if="showTrash && trashedItems.length">
293
- <USeparator class="my-2" />
294
- <div
295
- class="flex items-center gap-1 px-2 py-1 cursor-pointer text-muted hover:text-default"
296
- @click="showTrashExpanded = !showTrashExpanded"
297
- >
298
- <UIcon name="i-lucide-trash" class="size-4" />
299
- <span class="text-xs font-medium flex-1">{{ locale.trash }} ({{ trashedItems.length }})</span>
300
- <UIcon
301
- :name="showTrashExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
302
- class="size-3"
303
- />
304
- </div>
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
- <template v-if="showTrashExpanded">
1277
+ <!-- External drag hint bar -->
1278
+ <Transition name="drop-hint">
307
1279
  <div
308
- v-for="item in trashedItems"
309
- :key="item.id"
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 name="i-lucide-file-text" class="size-4 flex-shrink-0" />
313
- <span class="flex-1 min-w-0 truncate">{{ item.label }}</span>
314
- <UButton
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
- </template>
1298
+ </Transition>
344
1299
  </template>
345
- </div>
1300
+ </ClientOnly>
346
1301
 
347
- <!-- Footer slot -->
348
- <slot name="footer" />
349
- </div>
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>