fix(tree): resolve file drop targets to their containing folders
This commit is contained in:
1 parent
08f4e4aa7d
commit
634ef9fc11
4 files changed
+176
-20
No files matched your search
+32
-7
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename } from './paths'
|
import { basename } from './paths'
|
||||||
import { commandTitle } from './commands'
|
import { commandTitle } from './commands'
|
||||||
import { tree } from './tree'
|
import { dropDirectory, tree } from './tree'
|
||||||
import TreeNode from './TreeNode.vue'
|
import TreeNode from './TreeNode.vue'
|
||||||
import SearchPanel from './SearchPanel.vue'
|
import SearchPanel from './SearchPanel.vue'
|
||||||
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
||||||
@@ -11,7 +11,6 @@ import { sidebarViews } from '../plugins/registry'
|
|||||||
import PluginMount from './PluginMount.vue'
|
import PluginMount from './PluginMount.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
const rootDrop = ref(false)
|
|
||||||
const rootMenu = ref<{ x: number; y: number } | null>(null)
|
const rootMenu = ref<{ x: number; y: number } | null>(null)
|
||||||
const menuElement = ref<HTMLElement>()
|
const menuElement = ref<HTMLElement>()
|
||||||
async function rootContext(event: MouseEvent) {
|
async function rootContext(event: MouseEvent) {
|
||||||
@@ -54,17 +53,43 @@ watch(() => ws.searchOpen, () => { selectedView.value = '' })
|
|||||||
|
|
||||||
function onRootDragOver(e: DragEvent) {
|
function onRootDragOver(e: DragEvent) {
|
||||||
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
|
if (e.target instanceof Element && e.target.closest('.rm-tree-item')) {
|
||||||
|
dropDirectory.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
rootDrop.value = true
|
dropDirectory.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRootDrop(e: DragEvent) {
|
function onRootDrop(e: DragEvent) {
|
||||||
rootDrop.value = false
|
dropDirectory.value = null
|
||||||
|
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
|
if (e.target instanceof Element && e.target.closest('.rm-tree-item')) return
|
||||||
|
e.preventDefault()
|
||||||
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
||||||
if (!src || !src.includes('/')) return
|
if (!src || !src.includes('/')) return
|
||||||
e.preventDefault()
|
|
||||||
void move(src, basename(src))
|
void move(src, basename(src))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearDrop() { dropDirectory.value = null }
|
||||||
|
function onTreeDragLeave(e: DragEvent) {
|
||||||
|
if (e.currentTarget instanceof Element && e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return
|
||||||
|
clearDrop()
|
||||||
|
}
|
||||||
|
function onDragKeydown(e: KeyboardEvent) { if (e.key === 'Escape') clearDrop() }
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('dragend', clearDrop)
|
||||||
|
window.addEventListener('drop', clearDrop)
|
||||||
|
window.addEventListener('blur', clearDrop)
|
||||||
|
window.addEventListener('keydown', onDragKeydown)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('dragend', clearDrop)
|
||||||
|
window.removeEventListener('drop', clearDrop)
|
||||||
|
window.removeEventListener('blur', clearDrop)
|
||||||
|
window.removeEventListener('keydown', onDragKeydown)
|
||||||
|
clearDrop()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -79,7 +104,7 @@ function onRootDrop(e: DragEvent) {
|
|||||||
</nav>
|
</nav>
|
||||||
<PluginMount v-if="currentView" :key="selectedView" :view="currentView" />
|
<PluginMount v-if="currentView" :key="selectedView" :view="currentView" />
|
||||||
<SearchPanel v-else-if="ws.searchOpen" />
|
<SearchPanel v-else-if="ws.searchOpen" />
|
||||||
<div v-else class="rm-tree" @contextmenu="rootContext" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
|
<div v-else class="rm-tree" @contextmenu="rootContext" :class="{ drop: dropDirectory === '' }" @dragover="onRootDragOver" @dragleave="onTreeDragLeave" @drop="onRootDrop">
|
||||||
<ul class="rm-tree-list">
|
<ul class="rm-tree-list">
|
||||||
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+12
-12
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename, inAssetDir, isImage, joinPath } from './paths'
|
import { basename, dirname, inAssetDir, isImage, joinPath } from './paths'
|
||||||
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
||||||
import { batchFiles, exportFiles } from './batch'
|
import { batchFiles, exportFiles } from './batch'
|
||||||
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ const menuOpen = ref(false)
|
|||||||
const row = ref<HTMLElement>()
|
const row = ref<HTMLElement>()
|
||||||
const menuElement = ref<HTMLElement>()
|
const menuElement = ref<HTMLElement>()
|
||||||
const menuPosition = ref({ left: 0, top: 0 })
|
const menuPosition = ref({ left: 0, top: 0 })
|
||||||
const dropTarget = ref(false)
|
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
|
|
||||||
@@ -124,20 +123,22 @@ function onDragStart(e: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(e: DragEvent) {
|
function onDragOver(e: DragEvent) {
|
||||||
if (!props.node.dir || assetDir.value || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
dropTarget.value = true
|
dropDirectory.value = assetDir.value ? null : (props.node.dir ? props.node.path : dirname(props.node.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
dropTarget.value = false
|
dropDirectory.value = null
|
||||||
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||||
if (!props.node.dir || assetDir.value || !src) return
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const to = joinPath(props.node.path, basename(src))
|
const src = e.dataTransfer.getData('application/x-rawmark-path')
|
||||||
if (src !== props.node.path && !props.node.path.startsWith(src + '/') && to !== src) void move(src, to)
|
if (assetDir.value || !src) return
|
||||||
|
const dir = props.node.dir ? props.node.path : dirname(props.node.path)
|
||||||
|
const to = joinPath(dir, basename(src))
|
||||||
|
if (src !== dir && !dir.startsWith(src + '/') && to !== src) void move(src, to)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ function onDrop(e: DragEvent) {
|
|||||||
<div
|
<div
|
||||||
ref="row"
|
ref="row"
|
||||||
class="rm-tree-row"
|
class="rm-tree-row"
|
||||||
:class="{ active: isActive, selected: selectedPaths.has(node.path), drop: dropTarget, 'menu-open': menuOpen }"
|
:class="{ active: isActive, selected: selectedPaths.has(node.path), drop: node.dir && dropDirectory === node.path, 'menu-open': menuOpen }"
|
||||||
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
|
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
|
||||||
:draggable="!isRenaming && !assetDir"
|
:draggable="!isRenaming && !assetDir"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
@@ -159,7 +160,6 @@ function onDrop(e: DragEvent) {
|
|||||||
@touchcancel="cancelPress"
|
@touchcancel="cancelPress"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@dragover="onDragOver"
|
@dragover="onDragOver"
|
||||||
@dragleave="dropTarget = false"
|
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
>
|
>
|
||||||
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
|
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { Entry } from './api'
|
import type { Entry } from './api'
|
||||||
import { basename, dirname, hiddenAssetPath } from './paths'
|
import { basename, dirname, hiddenAssetPath } from './paths'
|
||||||
import { rangePaths } from './selection'
|
import { rangePaths } from './selection'
|
||||||
@@ -32,6 +32,9 @@ function build(entries: Entry[]): TreeNode[] {
|
|||||||
|
|
||||||
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
|
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
|
||||||
|
|
||||||
|
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
|
||||||
|
export const dropDirectory = ref<string | null>(null)
|
||||||
|
|
||||||
const EXPANDED_KEY = 'rawmark.expanded'
|
const EXPANDED_KEY = 'rawmark.expanded'
|
||||||
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
|
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
|
||||||
watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])))
|
watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])))
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { createApp, nextTick, type App } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import Sidebar from './Sidebar.vue'
|
||||||
|
import { expanded } from './tree'
|
||||||
|
import * as workspace from './workspace'
|
||||||
|
|
||||||
|
let app: App
|
||||||
|
let host: HTMLElement
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
|
||||||
|
Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [
|
||||||
|
{ path: 'source', dir: true }, { path: 'source/source-note.md', dir: false },
|
||||||
|
{ path: 'target', dir: true }, { path: 'target/existing-note.md', dir: false },
|
||||||
|
{ path: 'target/nested', dir: true }, { path: 'target/nested/child-note.md', dir: false },
|
||||||
|
{ path: 'root-note.md', dir: false },
|
||||||
|
].map(entry => ({ ...entry, size: 0, mtime: 0 })) })
|
||||||
|
expanded.clear()
|
||||||
|
for (const dir of ['source', 'target', 'target/nested']) expanded.add(dir)
|
||||||
|
host = document.createElement('div')
|
||||||
|
document.body.append(host)
|
||||||
|
app = createApp(Sidebar)
|
||||||
|
app.mount(host)
|
||||||
|
await nextTick()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
app.unmount()
|
||||||
|
host.remove()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function row(name: string): HTMLElement {
|
||||||
|
const node = [...host.querySelectorAll<HTMLElement>('.rm-tree-row')].find(row => row.querySelector('.rm-tree-name')?.textContent === name)
|
||||||
|
if (!node) throw new Error('找不到目录项:' + name)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(target: Element | Window, type: string, source = 'source/source-note.md', relatedTarget: EventTarget | null = null, internal = true) {
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||||
|
Object.defineProperties(event, {
|
||||||
|
dataTransfer: { value: { types: [internal ? 'application/x-rawmark-path' : 'text/plain'], getData: () => source } },
|
||||||
|
relatedTarget: { value: relatedTarget },
|
||||||
|
})
|
||||||
|
target.dispatchEvent(event)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('目录树拖放目标', () => {
|
||||||
|
it.each(['', '.rm-tree-name', '.rm-tree-more'])('文件行落点 %s 接收至所在文件夹,不能冒泡到根目录', selector => {
|
||||||
|
const target = row('existing-note')
|
||||||
|
drag(selector ? target.querySelector(selector)! : target, 'drop')
|
||||||
|
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'target/source-note.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', '.rm-tree-name', 'svg'])('文件夹行落点 %s 接收至该文件夹', selector => {
|
||||||
|
const target = row('target')
|
||||||
|
drag(selector ? target.querySelector(selector)! : target, 'drop')
|
||||||
|
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'target/source-note.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('深层文件使用直接父目录', () => {
|
||||||
|
drag(row('child-note'), 'drop')
|
||||||
|
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'target/nested/source-note.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['root-note', 'blank'])('根目标 %s 只移动一次', target => {
|
||||||
|
drag(target === 'blank' ? host.querySelector('.rm-tree')! : row(target), 'drop')
|
||||||
|
expect(workspace.move).toHaveBeenCalledExactlyOnceWith('source/source-note.md', 'source-note.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['existing-note', 'target/existing-note.md'],
|
||||||
|
['target', 'target/existing-note.md'],
|
||||||
|
['target', 'target'],
|
||||||
|
['nested', 'target'],
|
||||||
|
['child-note', 'target'],
|
||||||
|
])('无效移动 %s / %s 不得冒泡后改成移动到根目录', (target, source) => {
|
||||||
|
const event = drag(row(target), 'drop', source)
|
||||||
|
expect(event.defaultPrevented).toBe(true)
|
||||||
|
expect(workspace.move).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('属于节点的列表留白不能交给根目录接收', () => {
|
||||||
|
drag(row('target').parentElement!.querySelector('ul')!, 'drop')
|
||||||
|
expect(workspace.move).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('悬停文件只高亮直接父目录,进入根空白后切换为根区域提示', async () => {
|
||||||
|
drag(row('child-note').querySelector('.rm-tree-name')!, 'dragover')
|
||||||
|
await nextTick()
|
||||||
|
expect(row('nested').classList.contains('drop')).toBe(true)
|
||||||
|
expect(row('child-note').classList.contains('drop')).toBe(false)
|
||||||
|
expect(host.querySelector('.rm-tree')!.classList.contains('drop')).toBe(false)
|
||||||
|
drag(host.querySelector('.rm-tree')!, 'dragover')
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelectorAll('.rm-tree-row.drop')).toHaveLength(0)
|
||||||
|
expect(host.querySelector('.rm-tree')!.classList.contains('drop')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('离开树之前在行内切换子元素不会清除目标', async () => {
|
||||||
|
const target = row('existing-note')
|
||||||
|
drag(target, 'dragover')
|
||||||
|
drag(target.querySelector('.rm-tree-name')!, 'dragleave', undefined, target.querySelector('.rm-tree-more'))
|
||||||
|
await nextTick()
|
||||||
|
expect(row('target').classList.contains('drop')).toBe(true)
|
||||||
|
drag(target, 'dragleave', undefined, document.body)
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelectorAll('.drop')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['dragend', 'blur', 'Escape', 'drop'])('结束或取消 %s 后清除所有高亮', async type => {
|
||||||
|
drag(row('existing-note'), 'dragover')
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector('.drop')).not.toBeNull()
|
||||||
|
if (type === 'Escape') window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
else if (type === 'drop') drag(row('existing-note'), 'drop')
|
||||||
|
else window.dispatchEvent(new Event(type))
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelectorAll('.drop')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不接管外部文本拖放,空的内部载荷也不得移动文件', () => {
|
||||||
|
expect(drag(row('existing-note'), 'dragover', '', null, false).defaultPrevented).toBe(false)
|
||||||
|
drag(row('existing-note'), 'drop', '', null, false)
|
||||||
|
drag(row('existing-note'), 'drop', '')
|
||||||
|
expect(workspace.move).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user