collabmd 0.1.31 → 0.1.32

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.
Files changed (93) hide show
  1. package/dist/client/assets/{drawio-editor-B4sHHwWJ.js → drawio-editor-Br4qyh3r.js} +1 -1
  2. package/dist/client/assets/drawioEditor-ClmnTx5J.js +2 -0
  3. package/dist/client/assets/{editor-session-BKHEYkbY.js → editor-session-Cf-iV5-3.js} +2 -2
  4. package/dist/client/assets/embedded-editor-base-CgEkjNYn.css +1 -0
  5. package/dist/client/assets/{excalidraw-editor-CHbeNZu6.js → excalidraw-editor-CXAxVhlw.js} +2 -2
  6. package/dist/client/assets/{excalidrawEditor-BhaTX2Ko.js → excalidrawEditor-DoH_kMdu.js} +2 -2
  7. package/dist/client/assets/exportDocument-9wTrBZNS.css +1 -0
  8. package/dist/client/assets/{index-kPgEYvEX.js → index-C8QZYHvV.js} +2 -2
  9. package/dist/client/assets/index-CxSbUTs2.css +1 -0
  10. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  18. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  19. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  20. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  21. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  22. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  23. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  24. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  25. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  26. package/dist/client/assets/main-BjHHMlv0.js +1249 -0
  27. package/dist/client/assets/vault-api-client-Bf9tB_GW.js +1 -0
  28. package/dist/client/assets/vault-api-client-D7Ck0CvR.css +1 -0
  29. package/dist/client/drawio-editor.html +1 -1
  30. package/dist/client/excalidraw-editor.html +1 -1
  31. package/dist/client/export-document.html +2 -2
  32. package/dist/client/index.html +2 -2
  33. package/package.json +4 -1
  34. package/src/client/app/main-entry.js +2 -0
  35. package/src/client/application/app-shell/git-feature.js +28 -36
  36. package/src/client/application/app-shell/presence-feature.js +3 -1
  37. package/src/client/application/diagram-preview-hydrator.js +24 -15
  38. package/src/client/application/mermaid-preview-hydrator.js +121 -4
  39. package/src/client/application/plantuml-preview-hydrator.js +6 -16
  40. package/src/client/application/preview-renderer.js +11 -2
  41. package/src/client/bootstrap/collabmd-app-shell.js +176 -50
  42. package/src/client/domain/excalidraw-document-switch.js +3 -0
  43. package/src/client/excalidraw-editor.js +187 -84
  44. package/src/client/infrastructure/auth-client.js +4 -4
  45. package/src/client/infrastructure/backlinks-api-client.js +18 -0
  46. package/src/client/infrastructure/comment-thread-store.js +4 -0
  47. package/src/client/infrastructure/editor-session.js +2 -0
  48. package/src/client/infrastructure/git-api-client.js +134 -0
  49. package/src/client/infrastructure/plantuml-api-client.js +21 -0
  50. package/src/client/infrastructure/vault-api-client.js +67 -0
  51. package/src/client/presentation/backlinks-panel.js +6 -9
  52. package/src/client/presentation/bases-preview-controller.js +26 -7
  53. package/src/client/presentation/excalidraw-embed-controller.js +251 -15
  54. package/src/client/presentation/excalidraw-embed-reconciler.js +0 -1
  55. package/src/client/presentation/file-action-controller.js +97 -0
  56. package/src/client/presentation/file-explorer-controller.js +2 -0
  57. package/src/client/presentation/file-explorer-view.js +368 -0
  58. package/src/client/presentation/file-history-view-controller.js +41 -40
  59. package/src/client/presentation/git-diff-view-controller.js +70 -53
  60. package/src/client/presentation/git-panel-controller.js +12 -30
  61. package/src/client/styles/export-document.css +45 -2
  62. package/src/client/styles/features/auth-gate.css +13 -24
  63. package/src/client/styles/features/comment-card.css +6 -0
  64. package/src/client/styles/features/comments-drawer.css +38 -18
  65. package/src/client/styles/features/diagram-preview.css +33 -0
  66. package/src/client/styles/features/embedded-preview.css +37 -11
  67. package/src/client/styles/features/preview-markdown.css +103 -28
  68. package/src/client/styles/foundation/themes.css +4 -0
  69. package/src/client/styles/foundation/tokens.css +4 -3
  70. package/src/client/styles/layout/sidebar.css +72 -0
  71. package/src/server/auth/auth-common.js +224 -0
  72. package/src/server/auth/auth-constants.js +30 -0
  73. package/src/server/auth/auth-strategies.js +320 -0
  74. package/src/server/auth/create-auth-service.js +24 -545
  75. package/src/server/domain/bases/base-definition.js +255 -0
  76. package/src/server/domain/bases/base-expression-runtime.js +1159 -0
  77. package/src/server/domain/bases/base-index-snapshot-store.js +418 -0
  78. package/src/server/domain/bases/base-query-results.js +205 -0
  79. package/src/server/domain/bases/base-query-service.js +68 -1965
  80. package/src/server/domain/collaboration/collaboration-room.js +36 -1
  81. package/src/server/domain/collaboration/room-client-state-store.js +1 -0
  82. package/src/server/domain/collaboration/room-persistence-controller.js +1 -1
  83. package/src/server/infrastructure/http/create-vault-api-query-handler.js +109 -1
  84. package/src/server/infrastructure/http/http-response.js +56 -0
  85. package/src/server/infrastructure/persistence/vault-file-store.js +110 -0
  86. package/dist/client/assets/drawioEditor-DGpo2hcx.js +0 -2
  87. package/dist/client/assets/embedded-editor-base-DgmRcsR8.css +0 -1
  88. package/dist/client/assets/exportDocument-DmV7Gngb.css +0 -1
  89. package/dist/client/assets/index-D69HOZHr.css +0 -1
  90. package/dist/client/assets/main-DSxNTtD-.js +0 -1243
  91. package/dist/client/assets/vault-api-client-BzHQseMN.js +0 -1
  92. package/dist/client/assets/vault-api-client-De7kMvrE.css +0 -1
  93. /package/dist/client/assets/{exportDocument-CtZKWE2c.js → exportDocument-Bia2hI25.js} +0 -0
@@ -23,6 +23,12 @@ function getPathLeaf(path) {
23
23
  .pop() || '';
24
24
  }
25
25
 
26
+ function getParentPath(pathValue) {
27
+ const normalized = String(pathValue ?? '').replace(/\/+$/u, '');
28
+ const separatorIndex = normalized.lastIndexOf('/');
29
+ return separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : '';
30
+ }
31
+
26
32
  function replacePathPrefix(pathValue, oldPrefix, newPrefix) {
27
33
  if (pathValue === oldPrefix) {
28
34
  return newPrefix;
@@ -201,6 +207,10 @@ export class FileActionController {
201
207
  label: 'Rename / move',
202
208
  onSelect: () => this.handleRenameDirectory(directoryPath),
203
209
  },
210
+ {
211
+ label: 'Download',
212
+ onSelect: () => this.handleDownloadDirectory(directoryPath),
213
+ },
204
214
  {
205
215
  label: 'Delete',
206
216
  danger: true,
@@ -215,6 +225,10 @@ export class FileActionController {
215
225
  label: 'Rename / move',
216
226
  onSelect: () => this.handleRenameFile(filePath),
217
227
  },
228
+ {
229
+ label: 'Download',
230
+ onSelect: () => this.handleDownloadFile(filePath),
231
+ },
218
232
  {
219
233
  label: 'Delete',
220
234
  danger: true,
@@ -627,6 +641,81 @@ export class FileActionController {
627
641
  }
628
642
  }
629
643
 
644
+ planMoveEntryByDrop({ destinationDirectory = '', sourcePath = '', sourceType = 'file' } = {}) {
645
+ const normalizedSourcePath = normalizeVaultPathInput(sourcePath);
646
+ const normalizedDestinationDirectory = normalizeVaultPathInput(destinationDirectory);
647
+ const currentParentPath = getParentPath(normalizedSourcePath);
648
+ const entryName = getPathLeaf(normalizedSourcePath);
649
+
650
+ if (!normalizedSourcePath || !entryName) {
651
+ return { ok: false, reason: 'Invalid item to move' };
652
+ }
653
+
654
+ if (currentParentPath === normalizedDestinationDirectory) {
655
+ return { ok: false, reason: 'Item is already in that folder' };
656
+ }
657
+
658
+ if (sourceType === 'directory') {
659
+ if (normalizedDestinationDirectory === normalizedSourcePath) {
660
+ return { ok: false, reason: 'A folder cannot be moved into itself' };
661
+ }
662
+
663
+ if (normalizedDestinationDirectory.startsWith(`${normalizedSourcePath}/`)) {
664
+ return { ok: false, reason: 'A folder cannot be moved into one of its descendants' };
665
+ }
666
+
667
+ return {
668
+ nextPath: composeVaultChildPath(normalizedDestinationDirectory, entryName),
669
+ ok: true,
670
+ };
671
+ }
672
+
673
+ const extension = getVaultFileExtension(normalizedSourcePath) || '.md';
674
+ return {
675
+ extension,
676
+ nextPath: composeVaultChildPath(normalizedDestinationDirectory, entryName),
677
+ ok: true,
678
+ };
679
+ }
680
+
681
+ canMoveEntryByDrop(payload = {}) {
682
+ return this.planMoveEntryByDrop(payload).ok;
683
+ }
684
+
685
+ async moveEntryByDrop(payload = {}) {
686
+ const plan = this.planMoveEntryByDrop(payload);
687
+ if (!plan.ok) {
688
+ this.showToast(plan.reason);
689
+ return false;
690
+ }
691
+
692
+ if (payload.sourceType === 'directory') {
693
+ return this.renameDirectory(payload.sourcePath, plan.nextPath);
694
+ }
695
+
696
+ return this.renameVaultFile(payload.sourcePath, plan.nextPath, plan.extension);
697
+ }
698
+
699
+ async downloadFileEntry(filePath) {
700
+ try {
701
+ await this.vaultClient.downloadFile(filePath);
702
+ return true;
703
+ } catch (error) {
704
+ this.showToast(error?.message || 'Failed to download file');
705
+ return false;
706
+ }
707
+ }
708
+
709
+ async downloadDirectoryEntry(directoryPath) {
710
+ try {
711
+ await this.vaultClient.downloadDirectory(directoryPath);
712
+ return true;
713
+ } catch (error) {
714
+ this.showToast(error?.message || 'Failed to download folder');
715
+ return false;
716
+ }
717
+ }
718
+
630
719
  getCreateContext(parentDir = '') {
631
720
  const normalizedParentDir = normalizeVaultPathInput(parentDir);
632
721
 
@@ -908,4 +997,12 @@ export class FileActionController {
908
997
  onSubmit: () => this.deleteDirectory(directoryPath, { recursive: hasDescendants }),
909
998
  });
910
999
  }
1000
+
1001
+ handleDownloadFile(filePath) {
1002
+ void this.downloadFileEntry(filePath);
1003
+ }
1004
+
1005
+ handleDownloadDirectory(directoryPath) {
1006
+ void this.downloadDirectoryEntry(directoryPath);
1007
+ }
911
1008
  }
@@ -29,6 +29,7 @@ export class FileExplorerController {
29
29
  this.state.toggleDirectory(pathValue);
30
30
  this.renderTree();
31
31
  },
32
+ onEntryDrop: (payload) => this.actionController.moveEntryByDrop(payload),
32
33
  onFileContextMenu: (event, payload) => {
33
34
  if (payload.type === 'directory') {
34
35
  this.view.showContextMenu(event, this.actionController.getDirectoryContextMenuItems(payload.directoryPath));
@@ -40,6 +41,7 @@ export class FileExplorerController {
40
41
  onFileSelect: (filePath) => {
41
42
  this.onFileSelect?.(filePath);
42
43
  },
44
+ onValidateDrop: (payload) => this.actionController.canMoveEntryByDrop(payload),
43
45
  onSearchChange: (value) => {
44
46
  this.state.setSearchQuery(value);
45
47
  this.renderTree();
@@ -38,23 +38,28 @@ function findNodeByPath(nodes = [], pathValue = '') {
38
38
 
39
39
  const MOBILE_LONG_PRESS_DELAY_MS = 420;
40
40
  const MOBILE_LONG_PRESS_MOVE_TOLERANCE_PX = 10;
41
+ const DRAG_AUTO_EXPAND_DELAY_MS = 700;
41
42
 
42
43
  export class FileExplorerView {
43
44
  constructor({
44
45
  mobileBreakpointQuery = window.matchMedia('(max-width: 768px)'),
46
+ onEntryDrop,
45
47
  onDirectorySelect,
46
48
  onDirectoryToggle,
47
49
  onFileContextMenu,
48
50
  onFileSelect,
49
51
  onSearchChange,
50
52
  onTreeContextMenu,
53
+ onValidateDrop,
51
54
  }) {
55
+ this.onEntryDrop = onEntryDrop;
52
56
  this.onDirectorySelect = onDirectorySelect;
53
57
  this.onDirectoryToggle = onDirectoryToggle;
54
58
  this.onFileContextMenu = onFileContextMenu;
55
59
  this.onFileSelect = onFileSelect;
56
60
  this.onSearchChange = onSearchChange;
57
61
  this.onTreeContextMenu = onTreeContextMenu;
62
+ this.onValidateDrop = onValidateDrop;
58
63
  this.mobileBreakpointQuery = mobileBreakpointQuery;
59
64
  this.treeContainer = document.getElementById('fileTree');
60
65
  this.searchInput = document.getElementById('fileSearchInput');
@@ -66,6 +71,13 @@ export class FileExplorerView {
66
71
  this.suppressedActivationTarget = null;
67
72
  this.contextMenuCloseHandler = null;
68
73
  this.actionSheetCloseHandler = null;
74
+ this.dragSource = null;
75
+ this.currentSearchQuery = '';
76
+ this.activeDropTarget = null;
77
+ this.invalidDropAttempt = null;
78
+ this.autoExpandTimer = 0;
79
+ this.autoExpandTargetPath = '';
80
+ this.rootDropZone = null;
69
81
  }
70
82
 
71
83
  initialize() {
@@ -108,6 +120,15 @@ export class FileExplorerView {
108
120
  this.treeContainer?.addEventListener('scroll', () => {
109
121
  this.cancelLongPress();
110
122
  }, { passive: true });
123
+ this.treeContainer?.addEventListener('dragover', (event) => {
124
+ this.handleTreeDragOver(event);
125
+ });
126
+ this.treeContainer?.addEventListener('drop', (event) => {
127
+ this.handleTreeDrop(event);
128
+ });
129
+ this.treeContainer?.addEventListener('dragleave', (event) => {
130
+ this.handleTreeDragLeave(event);
131
+ });
111
132
  }
112
133
 
113
134
  render({ activeFilePath, changedPaths = null, expandedDirs, reset = false, searchMatches, searchQuery, tree }) {
@@ -115,6 +136,11 @@ export class FileExplorerView {
115
136
  return;
116
137
  }
117
138
 
139
+ this.currentSearchQuery = String(searchQuery ?? '');
140
+ if (this.currentSearchQuery) {
141
+ this.clearDragFeedback();
142
+ }
143
+
118
144
  if (this.searchInput && this.searchInput.value !== searchQuery) {
119
145
  this.searchInput.value = searchQuery;
120
146
  }
@@ -179,6 +205,7 @@ export class FileExplorerView {
179
205
 
180
206
  this.resetTreeIndexes();
181
207
  this.treeContainer.innerHTML = '';
208
+ this.rootDropZone = null;
182
209
 
183
210
  if (matches.length === 0) {
184
211
  this.treeContainer.innerHTML = '<div class="file-tree-empty">No matches</div>';
@@ -206,6 +233,7 @@ export class FileExplorerView {
206
233
  renderFullTree(tree, { activeFilePath, expandedDirs }) {
207
234
  this.resetTreeIndexes();
208
235
  this.treeContainer.innerHTML = '';
236
+ this.rootDropZone = null;
209
237
 
210
238
  if (tree.length === 0) {
211
239
  this.treeContainer.innerHTML = '<div class="file-tree-empty">No vault files found</div>';
@@ -213,6 +241,7 @@ export class FileExplorerView {
213
241
  }
214
242
 
215
243
  const fragment = document.createDocumentFragment();
244
+ fragment.appendChild(this.createRootDropZone());
216
245
  this.renderNodes(tree, fragment, {
217
246
  activeFilePath,
218
247
  depth: 0,
@@ -258,11 +287,15 @@ export class FileExplorerView {
258
287
 
259
288
  const isExpanded = expandedDirs.has(node.path);
260
289
  button.setAttribute('aria-expanded', String(isExpanded));
290
+ button.dataset.path = node.path;
291
+ button.dataset.entryType = 'directory';
261
292
  button.innerHTML = `
262
293
  <svg class="file-tree-chevron${isExpanded ? ' expanded' : ''}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
263
294
  <svg class="file-tree-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
264
295
  <span class="file-tree-name">${escapeHtml(node.name)}</span>
265
296
  `;
297
+ this.configureDragSource(button, { path: node.path, type: 'directory' });
298
+ this.bindDirectoryDropTarget(button, node.path);
266
299
 
267
300
  button.addEventListener('click', (event) => {
268
301
  if (this.consumeSuppressedActivation(button, event)) {
@@ -303,6 +336,8 @@ export class FileExplorerView {
303
336
  button.className = 'file-tree-item file-tree-dir';
304
337
  button.style.setProperty('--depth', 0);
305
338
  button.dataset.depth = 0;
339
+ button.dataset.path = node.path;
340
+ button.dataset.entryType = 'directory';
306
341
  button.innerHTML = `
307
342
  <svg class="file-tree-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
308
343
  <span class="file-tree-name">${escapeHtml(node.name || getPathLeaf(node.path))}</span>
@@ -415,10 +450,12 @@ export class FileExplorerView {
415
450
  button.style.setProperty('--depth', depth);
416
451
  button.dataset.depth = depth;
417
452
  button.dataset.path = filePath;
453
+ button.dataset.entryType = 'file';
418
454
  button.innerHTML = `
419
455
  ${this.getFileIconSvg({ isBase, isDrawio, isExcalidraw, isImage, isMermaid, isPlantUml })}
420
456
  <span class="file-tree-name">${escapeHtml(stripVaultFileExtension(name))}</span>
421
457
  `;
458
+ this.configureDragSource(button, { path: filePath, type: 'file' });
422
459
 
423
460
  button.addEventListener('click', (event) => {
424
461
  if (this.consumeSuppressedActivation(button, event)) {
@@ -441,6 +478,336 @@ export class FileExplorerView {
441
478
  return Boolean(this.mobileBreakpointQuery?.matches);
442
479
  }
443
480
 
481
+ isDragAndDropEnabled() {
482
+ return !this.isMobileViewport() && !this.currentSearchQuery;
483
+ }
484
+
485
+ configureDragSource(element, payload) {
486
+ if (!(element instanceof HTMLElement)) {
487
+ return;
488
+ }
489
+
490
+ const isEnabled = this.isDragAndDropEnabled();
491
+ element.draggable = isEnabled;
492
+ if (!isEnabled) {
493
+ return;
494
+ }
495
+
496
+ element.addEventListener('dragstart', (event) => {
497
+ this.dragSource = {
498
+ path: payload.path,
499
+ type: payload.type,
500
+ };
501
+ document.body?.classList.add('is-file-tree-dragging');
502
+ if (this.treeContainer) {
503
+ this.treeContainer.dataset.dragActive = 'true';
504
+ }
505
+ element.classList.add('is-dragging');
506
+ if (event.dataTransfer) {
507
+ event.dataTransfer.effectAllowed = 'move';
508
+ event.dataTransfer.setData('text/plain', payload.path);
509
+ }
510
+ });
511
+ element.addEventListener('dragend', () => {
512
+ element.classList.remove('is-dragging');
513
+ const dragSource = this.dragSource;
514
+ if (this.invalidDropAttempt && dragSource) {
515
+ void this.onEntryDrop?.(this.invalidDropAttempt);
516
+ }
517
+ this.dragSource = null;
518
+ this.invalidDropAttempt = null;
519
+ this.clearDragFeedback();
520
+ });
521
+
522
+ if (payload.type === 'file') {
523
+ element.addEventListener('dragenter', (event) => {
524
+ this.handleNonDropTargetDragOver(event);
525
+ });
526
+ element.addEventListener('dragover', (event) => {
527
+ this.handleNonDropTargetDragOver(event);
528
+ });
529
+ }
530
+ }
531
+
532
+ bindDirectoryDropTarget(element, directoryPath) {
533
+ if (!(element instanceof HTMLElement) || this.isMobileViewport()) {
534
+ return;
535
+ }
536
+
537
+ element.addEventListener('dragenter', (event) => {
538
+ this.handleDirectoryDragEnter(event, element, directoryPath);
539
+ });
540
+ element.addEventListener('dragover', (event) => {
541
+ this.handleDirectoryDragOver(event, element, directoryPath);
542
+ });
543
+ element.addEventListener('drop', (event) => {
544
+ this.handleDirectoryDrop(event, directoryPath);
545
+ });
546
+ }
547
+
548
+ createRootDropZone() {
549
+ const zone = document.createElement('div');
550
+ zone.className = 'file-tree-root-drop-zone';
551
+ zone.textContent = 'Drop here to move to vault root';
552
+ zone.addEventListener('dragenter', (event) => {
553
+ this.handleRootZoneDragEnter(event);
554
+ });
555
+ zone.addEventListener('dragover', (event) => {
556
+ this.handleRootZoneDragOver(event);
557
+ });
558
+ zone.addEventListener('drop', (event) => {
559
+ this.handleRootZoneDrop(event);
560
+ });
561
+ this.rootDropZone = zone;
562
+ return zone;
563
+ }
564
+
565
+ validateDrop(destinationDirectory) {
566
+ if (!this.dragSource || this.currentSearchQuery) {
567
+ return false;
568
+ }
569
+
570
+ return this.onValidateDrop?.({
571
+ destinationDirectory,
572
+ sourcePath: this.dragSource.path,
573
+ sourceType: this.dragSource.type,
574
+ }) === true;
575
+ }
576
+
577
+ setDropTarget(target) {
578
+ if (this.activeDropTarget?.element && this.activeDropTarget.element !== target?.element) {
579
+ this.activeDropTarget.element.classList.remove('is-drop-target', 'is-drop-invalid');
580
+ }
581
+
582
+ if (this.activeDropTarget?.root && !target?.root) {
583
+ this.treeContainer?.classList.remove('is-drop-target-root', 'is-drop-invalid');
584
+ }
585
+ if (this.activeDropTarget?.rootZone && !target?.rootZone) {
586
+ this.rootDropZone?.classList.remove('is-drop-target', 'is-drop-invalid');
587
+ }
588
+
589
+ this.activeDropTarget = target;
590
+ if (!target || target.isValid) {
591
+ this.invalidDropAttempt = null;
592
+ } else if (this.dragSource) {
593
+ this.invalidDropAttempt = {
594
+ destinationDirectory: target.destinationDirectory || '',
595
+ sourcePath: this.dragSource.path,
596
+ sourceType: this.dragSource.type,
597
+ };
598
+ }
599
+
600
+ if (!target) {
601
+ return;
602
+ }
603
+
604
+ if (target.root) {
605
+ this.treeContainer?.classList.toggle('is-drop-target-root', target.isValid);
606
+ this.treeContainer?.classList.toggle('is-drop-invalid', !target.isValid);
607
+ return;
608
+ }
609
+
610
+ if (target.rootZone) {
611
+ this.rootDropZone?.classList.toggle('is-drop-target', target.isValid);
612
+ this.rootDropZone?.classList.toggle('is-drop-invalid', !target.isValid);
613
+ return;
614
+ }
615
+
616
+ target.element.classList.toggle('is-drop-target', target.isValid);
617
+ target.element.classList.toggle('is-drop-invalid', !target.isValid);
618
+ }
619
+
620
+ scheduleAutoExpand(element, directoryPath) {
621
+ const isExpanded = element.getAttribute('aria-expanded') === 'true';
622
+ if (isExpanded || this.autoExpandTargetPath === directoryPath) {
623
+ return;
624
+ }
625
+
626
+ this.cancelAutoExpand();
627
+ this.autoExpandTargetPath = directoryPath;
628
+ this.autoExpandTimer = window.setTimeout(() => {
629
+ this.autoExpandTimer = 0;
630
+ this.autoExpandTargetPath = '';
631
+ if (element.isConnected && element.getAttribute('aria-expanded') !== 'true') {
632
+ this.onDirectoryToggle?.(directoryPath);
633
+ }
634
+ }, DRAG_AUTO_EXPAND_DELAY_MS);
635
+ }
636
+
637
+ cancelAutoExpand() {
638
+ if (this.autoExpandTimer) {
639
+ window.clearTimeout(this.autoExpandTimer);
640
+ this.autoExpandTimer = 0;
641
+ }
642
+ this.autoExpandTargetPath = '';
643
+ }
644
+
645
+ clearDragFeedback() {
646
+ this.cancelAutoExpand();
647
+ this.setDropTarget(null);
648
+ document.body?.classList.remove('is-file-tree-dragging');
649
+ if (this.treeContainer) {
650
+ delete this.treeContainer.dataset.dragActive;
651
+ }
652
+ }
653
+
654
+ handleDirectoryDragEnter(event, element, directoryPath) {
655
+ if (!this.dragSource) {
656
+ return;
657
+ }
658
+
659
+ event.preventDefault();
660
+ event.stopPropagation();
661
+ const isValid = this.validateDrop(directoryPath);
662
+ this.setDropTarget({ destinationDirectory: directoryPath, element, isValid, root: false });
663
+ if (isValid) {
664
+ this.scheduleAutoExpand(element, directoryPath);
665
+ } else {
666
+ this.cancelAutoExpand();
667
+ }
668
+ }
669
+
670
+ handleDirectoryDragOver(event, element, directoryPath) {
671
+ if (!this.dragSource) {
672
+ return;
673
+ }
674
+
675
+ event.preventDefault();
676
+ event.stopPropagation();
677
+ const isValid = this.validateDrop(directoryPath);
678
+ if (event.dataTransfer) {
679
+ event.dataTransfer.dropEffect = isValid ? 'move' : 'none';
680
+ }
681
+ this.setDropTarget({ destinationDirectory: directoryPath, element, isValid, root: false });
682
+ if (isValid) {
683
+ this.scheduleAutoExpand(element, directoryPath);
684
+ } else {
685
+ this.cancelAutoExpand();
686
+ }
687
+ }
688
+
689
+ handleDirectoryDrop(event, directoryPath) {
690
+ if (!this.dragSource) {
691
+ return;
692
+ }
693
+
694
+ event.preventDefault();
695
+ event.stopPropagation();
696
+ const payload = {
697
+ destinationDirectory: directoryPath,
698
+ sourcePath: this.dragSource.path,
699
+ sourceType: this.dragSource.type,
700
+ };
701
+ this.clearDragFeedback();
702
+ void this.onEntryDrop?.(payload);
703
+ }
704
+
705
+ handleTreeDragOver(event) {
706
+ if (!this.dragSource || !this.treeContainer) {
707
+ return;
708
+ }
709
+
710
+ if (event.target.closest('.file-tree-dir') || event.target.closest('.file-tree-item')) {
711
+ return;
712
+ }
713
+
714
+ event.preventDefault();
715
+ this.cancelAutoExpand();
716
+ const isValid = this.validateDrop('');
717
+ if (event.dataTransfer) {
718
+ event.dataTransfer.dropEffect = isValid ? 'move' : 'none';
719
+ }
720
+ this.setDropTarget({ destinationDirectory: '', element: this.treeContainer, isValid, root: true });
721
+ }
722
+
723
+ handleTreeDrop(event) {
724
+ if (!this.dragSource || !this.treeContainer) {
725
+ return;
726
+ }
727
+
728
+ if (event.target.closest('.file-tree-dir') || event.target.closest('.file-tree-item')) {
729
+ return;
730
+ }
731
+
732
+ event.preventDefault();
733
+ const payload = {
734
+ destinationDirectory: '',
735
+ sourcePath: this.dragSource.path,
736
+ sourceType: this.dragSource.type,
737
+ };
738
+ this.clearDragFeedback();
739
+ void this.onEntryDrop?.(payload);
740
+ }
741
+
742
+ handleTreeDragLeave(event) {
743
+ if (!this.dragSource || !this.treeContainer) {
744
+ return;
745
+ }
746
+
747
+ const nextTarget = event.relatedTarget;
748
+ if (nextTarget && this.treeContainer.contains(nextTarget)) {
749
+ return;
750
+ }
751
+
752
+ this.clearDragFeedback();
753
+ }
754
+
755
+ handleRootZoneDragEnter(event) {
756
+ if (!this.dragSource) {
757
+ return;
758
+ }
759
+
760
+ event.preventDefault();
761
+ event.stopPropagation();
762
+ const isValid = this.validateDrop('');
763
+ this.cancelAutoExpand();
764
+ this.setDropTarget({ destinationDirectory: '', element: this.rootDropZone, isValid, rootZone: true });
765
+ }
766
+
767
+ handleRootZoneDragOver(event) {
768
+ if (!this.dragSource) {
769
+ return;
770
+ }
771
+
772
+ event.preventDefault();
773
+ event.stopPropagation();
774
+ const isValid = this.validateDrop('');
775
+ if (event.dataTransfer) {
776
+ event.dataTransfer.dropEffect = isValid ? 'move' : 'none';
777
+ }
778
+ this.cancelAutoExpand();
779
+ this.setDropTarget({ destinationDirectory: '', element: this.rootDropZone, isValid, rootZone: true });
780
+ }
781
+
782
+ handleRootZoneDrop(event) {
783
+ if (!this.dragSource) {
784
+ return;
785
+ }
786
+
787
+ event.preventDefault();
788
+ event.stopPropagation();
789
+ const payload = {
790
+ destinationDirectory: '',
791
+ sourcePath: this.dragSource.path,
792
+ sourceType: this.dragSource.type,
793
+ };
794
+ this.clearDragFeedback();
795
+ void this.onEntryDrop?.(payload);
796
+ }
797
+
798
+ handleNonDropTargetDragOver(event) {
799
+ if (!this.dragSource) {
800
+ return;
801
+ }
802
+
803
+ event.preventDefault();
804
+ event.stopPropagation();
805
+ if (event.dataTransfer) {
806
+ event.dataTransfer.dropEffect = 'none';
807
+ }
808
+ this.clearDragFeedback();
809
+ }
810
+
444
811
  bindLongPress(element, callback) {
445
812
  if (!(element instanceof HTMLElement) || typeof callback !== 'function') {
446
813
  return;
@@ -659,6 +1026,7 @@ export class FileExplorerView {
659
1026
 
660
1027
  removeContextMenu() {
661
1028
  this.cancelLongPress();
1029
+ this.clearDragFeedback();
662
1030
  document.querySelectorAll('.file-context-menu').forEach((menu) => menu.remove());
663
1031
  if (this.contextMenuCloseHandler) {
664
1032
  document.removeEventListener('click', this.contextMenuCloseHandler);