qctsu_obsidian-file-shift/main.js
Qctsu d2de55b789 fix: allow drag-and-drop into folders (3-zone detection)
Previously the plugin blocked all drag events in file explorer,
preventing native move-into-folder behavior. Now uses 3-zone
detection on folder targets:
- Top 25%: reorder above
- Middle 50%: move into folder (Obsidian native)
- Bottom 25%: reorder below

Also adds visual highlight when targeting folder center zone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:01:20 +02:00

725 lines
20 KiB
JavaScript

'use strict';
const obsidian = require('obsidian');
const DEFAULT_DATA = {
version: 1,
active: false,
order: {}
};
class FileShiftPlugin extends obsidian.Plugin {
async onload() {
const saved = await this.loadData();
this.data = Object.assign({}, DEFAULT_DATA, saved || {});
this.isActive = false;
this.dragState = null;
this.abortController = null;
this.mutationObserver = null;
this.refreshTimer = null;
this.originalSort = null;
// Ribbon icon (toggle)
this.ribbonEl = this.addRibbonIcon(
'arrow-up-down',
'FileShift: OFF',
() => this.toggle()
);
// Commands
this.addCommand({
id: 'toggle',
name: 'Toggle custom ordering on/off',
callback: () => this.toggle()
});
this.addCommand({
id: 'reset-order',
name: 'Reset all custom ordering',
callback: () => {
this.data.order = {};
this.save();
if (this.isActive) this.restoreDefaultSort();
new obsidian.Notice('FileShift: order reset');
}
});
// Auto-activate on layout ready if was active before
this.app.workspace.onLayoutReady(() => {
if (this.data.active) {
setTimeout(() => this.activate(), 400);
}
});
// Re-apply order on layout changes
this.registerEvent(
this.app.workspace.on('layout-change', () => {
if (this.isActive) this.scheduleRefresh();
})
);
// Handle vault changes
this.registerEvent(
this.app.vault.on('create', () => {
if (this.isActive) this.scheduleRefresh();
})
);
this.registerEvent(
this.app.vault.on('delete', (file) => {
if (this.isActive) {
this.removeFromOrder(file.path);
this.scheduleRefresh();
}
})
);
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => {
if (this.isActive) {
this.handleRename(oldPath, file.path);
this.scheduleRefresh();
}
})
);
}
onunload() {
this.cleanup();
}
// ─── Toggle & Lifecycle ────────────────────────────────────────
toggle() {
if (this.isActive) {
this.deactivate();
} else {
this.activate();
}
}
activate() {
if (this.isActive) return;
const explorer = this.getExplorer();
if (!explorer) {
new obsidian.Notice('File Explorer not found');
return;
}
this.isActive = true;
this.data.active = true;
this.save();
this.ribbonEl.setAttribute('aria-label', 'FileShift: ON');
this.ribbonEl.addClass('is-active');
this.ribbonEl.addClass('fs-active');
this.patchSort(explorer);
this.setupDragHandlers();
this.applyAllOrders();
new obsidian.Notice('FileShift: ON');
}
deactivate() {
if (!this.isActive) return;
this.isActive = false;
this.data.active = false;
this.save();
this.ribbonEl.setAttribute('aria-label', 'FileShift: OFF');
this.ribbonEl.removeClass('is-active');
this.ribbonEl.removeClass('fs-active');
this.cleanup();
this.restoreDefaultSort();
new obsidian.Notice('FileShift: OFF');
}
cleanup() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.unpatchSort();
this.clearDraggable();
this.clearIndicators();
}
// ─── Explorer Access ───────────────────────────────────────────
getExplorer() {
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
return leaf ? leaf.view : null;
}
getNavContainer() {
const explorer = this.getExplorer();
if (!explorer) return null;
return explorer.containerEl.querySelector('.nav-files-container');
}
// Resolve parentPath to the DOM element that holds its children (fallback only)
getChildrenEl(parentPath) {
const container = this.getNavContainer();
if (!container) return null;
if (parentPath === '/') {
const modRoot = container.querySelector('.mod-root');
if (modRoot) {
const el = modRoot.querySelector('.nav-folder-children');
if (el) return el;
}
return container.querySelector('.nav-folder-children') || container;
}
const explorer = this.getExplorer();
if (explorer && explorer.fileItems) {
const item = explorer.fileItems[parentPath];
if (item) {
const el = item.childrenEl || (item.el ? item.el.querySelector('.nav-folder-children') : null);
if (el) return el;
}
}
const folderTitle = container.querySelector('.nav-folder-title[data-path="' + CSS.escape(parentPath) + '"]');
if (folderTitle) {
const folder = folderTitle.closest('.nav-folder');
if (folder) return folder.querySelector('.nav-folder-children');
}
return null;
}
// ─── Sort Patching ─────────────────────────────────────────────
patchSort(explorer) {
if (this.originalSort) return;
const orig = explorer.sort.bind(explorer);
this.originalSort = orig;
const self = this;
explorer.sort = function () {
orig();
if (self.isActive) {
requestAnimationFrame(() => {
self.applyAllOrders();
self.refreshDraggable();
});
}
};
}
unpatchSort() {
if (!this.originalSort) return;
const explorer = this.getExplorer();
if (explorer) {
explorer.sort = this.originalSort;
}
this.originalSort = null;
}
restoreDefaultSort() {
const explorer = this.getExplorer();
if (explorer && explorer.sort) {
explorer.sort();
}
}
// ─── Drag & Drop ──────────────────────────────────────────────
setupDragHandlers() {
const container = this.getNavContainer();
if (!container) return;
this.abortController = new AbortController();
const signal = this.abortController.signal;
// Record drag source before Obsidian sees it
container.addEventListener('dragstart', (e) => this.onDragStart(e), { capture: true, signal });
// Document-level capture phase — fires before ANY Obsidian handler
// We block dragenter/dragover/drop inside file explorer when our drag is active
document.addEventListener('dragenter', (e) => this.onDragEnter(e), { capture: true, signal });
document.addEventListener('dragover', (e) => this.onDragOver(e), { capture: true, signal });
document.addEventListener('drop', (e) => this.onDrop(e), { capture: true, signal });
container.addEventListener('dragend', (e) => this.onDragEnd(e), { signal });
this.refreshDraggable();
// Watch for dynamically added items
this.mutationObserver = new MutationObserver(() => {
this.refreshDraggable();
});
this.mutationObserver.observe(container, { childList: true, subtree: true });
}
refreshDraggable() {
const container = this.getNavContainer();
if (!container) return;
const titles = container.querySelectorAll('.nav-file-title, .nav-folder-title');
titles.forEach(el => {
if (!el.hasAttribute('data-fs')) {
el.setAttribute('draggable', 'true');
el.setAttribute('data-fs', '1');
}
});
}
clearDraggable() {
const container = this.getNavContainer();
if (!container) return;
container.querySelectorAll('[data-fs]').forEach(el => {
el.removeAttribute('data-fs');
});
}
clearIndicators() {
const container = this.getNavContainer();
if (!container) return;
container.querySelectorAll('.fs-drop-above, .fs-drop-below, .fs-drop-into').forEach(el => {
el.classList.remove('fs-drop-above', 'fs-drop-below', 'fs-drop-into');
});
container.querySelectorAll('.fs-dragging').forEach(el => {
el.classList.remove('fs-dragging');
});
}
// ─── Event Handlers ────────────────────────────────────────────
findTitle(e) {
return e.target && e.target.closest
? e.target.closest('.nav-file-title, .nav-folder-title')
: null;
}
getParentPath(filePath) {
const idx = filePath.lastIndexOf('/');
return idx === -1 ? '/' : filePath.substring(0, idx);
}
getFileName(filePath) {
return filePath.split('/').pop();
}
onDragStart(e) {
const title = this.findTitle(e);
if (!title) return;
const path = title.getAttribute('data-path');
if (!path) return;
this.dragState = {
path: path,
name: this.getFileName(path),
parentPath: this.getParentPath(path),
pendingDrop: null
};
const item = title.closest('.nav-file, .nav-folder');
if (item) item.classList.add('fs-dragging');
}
// Helper: is event target inside file explorer?
isInsideExplorer(e) {
const container = this.getNavContainer();
return container && e.target && container.contains(e.target);
}
onDragEnter(e) {
if (!this.dragState) return;
if (!this.isInsideExplorer(e)) return;
// Allow dragenter for folder targets (enables drop-into-folder)
const title = this.findTitle(e);
if (title && title.classList.contains('nav-folder-title')) return;
// Block for non-folder items during reorder
e.preventDefault();
e.stopPropagation();
}
onDragOver(e) {
if (!this.dragState) return;
if (!this.isInsideExplorer(e)) {
this.clearDropIndicators();
if (this.dragState) this.dragState.pendingDrop = null;
return;
}
const title = this.findTitle(e);
if (!title) {
e.preventDefault();
e.stopPropagation();
this.clearDropIndicators();
if (this.dragState) this.dragState.pendingDrop = null;
return;
}
const path = title.getAttribute('data-path');
if (!path || path === this.dragState.path) {
e.preventDefault();
e.stopPropagation();
this.clearDropIndicators();
if (this.dragState) this.dragState.pendingDrop = null;
return;
}
const isFolder = title.classList.contains('nav-folder-title');
const rect = title.getBoundingClientRect();
const ratio = (e.clientY - rect.top) / rect.height;
// Folder center zone (middle 50%) → drop INTO folder, let Obsidian handle
if (isFolder && ratio > 0.25 && ratio < 0.75) {
this.clearDropIndicators();
title.classList.add('fs-drop-into');
if (this.dragState) this.dragState.pendingDrop = null;
return;
}
// Reorder zone — only within same parent
const parentPath = this.getParentPath(path);
if (parentPath !== this.dragState.parentPath) {
this.clearDropIndicators();
if (this.dragState) this.dragState.pendingDrop = null;
return;
}
// ── Same parent sibling — reorder ──
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const position = isFolder
? (ratio < 0.25 ? 'above' : 'below')
: (ratio < 0.5 ? 'above' : 'below');
this.clearDropIndicators();
title.classList.add(position === 'above' ? 'fs-drop-above' : 'fs-drop-below');
// Keep source item visually dragged
const container = this.getNavContainer();
const dragItem = container.querySelector(
`.nav-file-title[data-path="${CSS.escape(this.dragState.path)}"], .nav-folder-title[data-path="${CSS.escape(this.dragState.path)}"]`
);
if (dragItem) {
const item = dragItem.closest('.nav-file, .nav-folder');
if (item) item.classList.add('fs-dragging');
}
this.dragState.pendingDrop = {
targetName: this.getFileName(path),
position: position === 'above' ? 'before' : 'after'
};
}
// Only clears drop lines, NOT the fs-dragging class on source
clearDropIndicators() {
const container = this.getNavContainer();
if (!container) return;
container.querySelectorAll('.fs-drop-above, .fs-drop-below, .fs-drop-into').forEach(el => {
el.classList.remove('fs-drop-above', 'fs-drop-below', 'fs-drop-into');
});
}
onDrop(e) {
if (!this.dragState) return;
if (!this.isInsideExplorer(e)) return;
// No pending reorder → let Obsidian handle (e.g., move into folder)
if (!this.dragState.pendingDrop) {
this.clearIndicators();
this.finishDrag();
if (this.isActive) this.scheduleRefresh();
return;
}
// Block Obsidian's drop and do our reorder
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const { targetName, position } = this.dragState.pendingDrop;
this.reorderItem(
this.dragState.parentPath,
this.dragState.name,
targetName,
position
);
this.clearIndicators();
this.finishDrag();
if (this.isActive) this.applyAllOrders();
}
onDragEnd() {
// Fallback: drop didn't fire (e.g. released outside explorer)
if (this.dragState && this.dragState.pendingDrop) {
const { targetName, position } = this.dragState.pendingDrop;
this.reorderItem(
this.dragState.parentPath,
this.dragState.name,
targetName,
position
);
if (this.isActive) this.applyAllOrders();
}
this.clearIndicators();
this.finishDrag();
}
finishDrag() {
const container = this.getNavContainer();
if (container) {
container.querySelectorAll('.fs-dragging').forEach(el => {
el.classList.remove('fs-dragging');
});
}
this.dragState = null;
}
// ─── Order Management ─────────────────────────────────────────
captureCurrentOrder(parentPath) {
const parentEl = this.getChildrenEl(parentPath);
if (!parentEl) return [];
const order = [];
for (const child of parentEl.children) {
const title = child.querySelector(':scope > .nav-file-title[data-path], :scope > .nav-folder-title[data-path]');
if (title) {
const path = title.getAttribute('data-path');
if (path) order.push(this.getFileName(path));
}
}
return order;
}
reorderItem(parentPath, sourceName, targetName, position) {
let order = this.data.order[parentPath];
if (!order || order.length === 0) {
order = this.captureCurrentOrder(parentPath);
}
// Remove source from current position
order = order.filter(n => n !== sourceName);
// Find target and insert
const targetIdx = order.indexOf(targetName);
if (targetIdx === -1) {
order.push(sourceName);
} else {
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
order.splice(insertIdx, 0, sourceName);
}
this.data.order[parentPath] = order;
this.save();
}
applyAllOrders() {
const explorer = this.getExplorer();
if (!explorer) return;
const container = this.getNavContainer();
if (!container) return;
for (const [parentPath, order] of Object.entries(this.data.order)) {
if (!order || order.length === 0) continue;
this.applyFolderOrder(explorer, container, parentPath, order);
}
}
// Convert our parentPath ("/") to Obsidian's internal path ("")
toObsidianPath(parentPath) {
return parentPath === '/' ? '' : parentPath;
}
applyFolderOrder(explorer, navContainer, parentPath, order) {
const obsPath = this.toObsidianPath(parentPath);
if (explorer.fileItems) {
// For root: no fileItems[""] — root is the .parent of any top-level item
let folderItem;
if (parentPath === '/') {
const firstKey = Object.keys(explorer.fileItems)[0];
if (firstKey) {
const fi = explorer.fileItems[firstKey];
if (fi && fi.parent && fi.parent.vChildren && typeof fi.parent.vChildren.setChildren === 'function') {
folderItem = fi.parent;
}
}
} else {
folderItem = explorer.fileItems[obsPath];
}
if (folderItem && folderItem.vChildren && typeof folderItem.vChildren.setChildren === 'function') {
// Get the vault folder to enumerate children
let folder;
if (parentPath === '/') {
folder = this.app.vault.root;
if (!folder) {
try { folder = this.app.vault.getFolderByPath('/'); } catch(e) {}
}
} else {
folder = this.app.vault.getFolderByPath(obsPath);
}
if (folder && folder.children) {
const pathToItem = new Map();
for (const child of folder.children) {
const item = explorer.fileItems[child.path];
if (item) pathToItem.set(this.getFileName(child.path), item);
}
const sortedItems = [];
const used = new Set();
for (const name of order) {
if (pathToItem.has(name)) {
sortedItems.push(pathToItem.get(name));
used.add(name);
}
}
for (const [name, item] of pathToItem) {
if (!used.has(name)) {
sortedItems.push(item);
}
}
folderItem.vChildren.setChildren(sortedItems);
// Force virtual scroller to re-render DOM
if (explorer.tree && explorer.tree.infinityScroll) {
explorer.tree.infinityScroll.invalidateAll();
explorer.tree.infinityScroll.compute();
}
return;
}
}
}
// ── DOM fallback ──
const parentEl = this.getChildrenEl(parentPath);
if (!parentEl) return;
const nameToEl = new Map();
const children = Array.from(parentEl.children);
for (const child of children) {
const title = child.querySelector(':scope > .nav-file-title[data-path], :scope > .nav-folder-title[data-path]');
if (title) {
const path = title.getAttribute('data-path');
if (path) {
nameToEl.set(this.getFileName(path), child);
}
}
}
const ordered = [];
const used = new Set();
for (const name of order) {
if (nameToEl.has(name)) {
ordered.push(nameToEl.get(name));
used.add(name);
}
}
for (const [name, el] of nameToEl) {
if (!used.has(name)) {
ordered.push(el);
}
}
for (const el of ordered) {
parentEl.appendChild(el);
}
}
// ─── Vault Event Handlers ─────────────────────────────────────
removeFromOrder(filePath) {
const parentPath = this.getParentPath(filePath);
const name = this.getFileName(filePath);
if (this.data.order[parentPath]) {
this.data.order[parentPath] = this.data.order[parentPath].filter(n => n !== name);
if (this.data.order[parentPath].length === 0) {
delete this.data.order[parentPath];
}
this.save();
}
// Remove folder's own order if it was deleted
if (this.data.order[filePath]) {
delete this.data.order[filePath];
this.save();
}
}
handleRename(oldPath, newPath) {
const oldParent = this.getParentPath(oldPath);
const newParent = this.getParentPath(newPath);
const oldName = this.getFileName(oldPath);
const newName = this.getFileName(newPath);
// Update in parent's order
const order = this.data.order[oldParent];
if (order) {
const idx = order.indexOf(oldName);
if (idx !== -1) {
if (oldParent === newParent) {
order[idx] = newName;
} else {
order.splice(idx, 1);
if (!this.data.order[newParent]) {
this.data.order[newParent] = this.captureCurrentOrder(newParent);
}
this.data.order[newParent].push(newName);
}
this.save();
}
}
// Update folder's own order key
if (this.data.order[oldPath]) {
this.data.order[newPath] = this.data.order[oldPath];
delete this.data.order[oldPath];
this.save();
}
}
scheduleRefresh() {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => {
this.refreshDraggable();
this.applyAllOrders();
}, 200);
}
// ─── Persistence ──────────────────────────────────────────────
save() {
this.saveData(this.data);
}
}
module.exports = FileShiftPlugin;