Files
RawMark/web/src/app/TreeNode.vue
T

226 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename, dirname, inAssetDir, isImage, joinPath } from './paths'
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget, type TreeNode } from './tree'
import { batchFiles, exportFiles } from './batch'
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
const props = defineProps<{ node: TreeNode; depth: number }>()
const image = computed(() => !props.node.dir && isImage(props.node.path))
const assetDir = computed(() => props.node.dir && inAssetDir(props.node.path))
const isOpen = computed(() => expanded.has(props.node.path))
const isActive = computed(() => (image.value ? ws.image : ws.active) === props.node.path)
const isRenaming = computed(() => ws.renaming === props.node.path)
const menuOpen = ref(false)
const row = ref<HTMLElement>()
const menuElement = ref<HTMLElement>()
const menuPosition = ref({ left: 0, top: 0 })
const input = ref<HTMLInputElement>()
const draft = ref('')
// 长按打开菜单:Android 会触发 contextmenu,iOS Safari 不会,统一用触摸计时
const LONG_PRESS_MS = 500
let pressTimer: ReturnType<typeof setTimeout> | undefined
let pressStart: { x: number; y: number } | null = null
let longPressed = false
function onTouchStart(e: TouchEvent) {
longPressed = false
pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
pressTimer = setTimeout(() => {
longPressed = true
showMenu()
}, LONG_PRESS_MS)
}
function onTouchMove(e: TouchEvent) {
if (pressStart && Math.hypot(e.touches[0].clientX - pressStart.x, e.touches[0].clientY - pressStart.y) > 10) cancelPress()
}
function cancelPress() {
clearTimeout(pressTimer)
pressStart = null
}
function activate(event: MouseEvent) {
// 长按后松手产生的点击不打开笔记
if (longPressed) {
longPressed = false
return
}
selectPath(props.node.path, event)
if (event.ctrlKey || event.metaKey || event.shiftKey) return
if (props.node.dir) {
if (isOpen.value) expanded.delete(props.node.path)
else expanded.add(props.node.path)
} else if (image.value) ws.image = props.node.path
else void openNote(props.node.path)
}
watch(
isRenaming,
async (on) => {
if (!on) return
draft.value = props.node.dir ? props.node.name : props.node.name.replace(/\.md$/, '')
await nextTick()
input.value?.focus()
input.value?.select()
},
{ immediate: true },
)
function commitRename() {
if (!isRenaming.value) return
ws.renaming = null
const target = renameTarget(props.node.path, draft.value)
if (target && target !== props.node.path) void move(props.node.path, target)
}
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'icon' | 'rename' | 'move' | 'delete' | 'export') {
menuOpen.value = false
const path = props.node.path
if (action === 'note' || action === 'folder') {
expanded.add(path)
void (action === 'note' ? createNote(path) : createFolder(path))
} else if (action === 'import') ws.importing = path
else if (action === 'images') { ws.imagesAll = false; ws.images = path }
else if (action === 'icon') editIcon()
else if (action === 'rename') ws.renaming = path
else if (action === 'move') ws.moving = [...selectedPaths]
else if (action === 'export') void exportFiles([...selectedPaths])
else void batchFiles('delete', [...selectedPaths])
}
function editIcon(event?: MouseEvent) {
const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget : row.value?.querySelector<HTMLElement>('.rm-tree-icon')
if (anchor) treeIconTarget.value = { path: props.node.path, anchor }
}
function showMenu() {
contextSelection(props.node.path)
menuOpen.value = true
}
watch(menuOpen, async (open, _previous, onCleanup) => {
if (!open) return
const close = () => { menuOpen.value = false }
window.addEventListener('resize', close)
onCleanup(() => window.removeEventListener('resize', close))
await nextTick()
if (!menuOpen.value || !row.value || !menuElement.value) return
const anchor = row.value.getBoundingClientRect()
const { width, height } = menuElement.value.getBoundingClientRect()
menuPosition.value = {
left: Math.max(4, Math.min(anchor.right - width, innerWidth - width - 4)),
top: Math.max(4, Math.min(anchor.bottom, innerHeight - height - 4)),
}
})
function cancelRename() {
ws.renaming = null
}
// ---- 拖放移动(桌面端) ----
function onDragStart(e: DragEvent) {
e.dataTransfer?.setData('application/x-rawmark-path', props.node.path)
e.dataTransfer!.effectAllowed = 'move'
}
function onDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
e.preventDefault()
e.stopPropagation()
dropDirectory.value = assetDir.value ? null : (props.node.dir ? props.node.path : dirname(props.node.path))
}
function onDrop(e: DragEvent) {
dropDirectory.value = null
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
e.preventDefault()
e.stopPropagation()
const src = e.dataTransfer.getData('application/x-rawmark-path')
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>
<template>
<li class="rm-tree-item">
<div
ref="row"
class="rm-tree-row"
:class="{ active: isActive, selected: selectedPaths.has(node.path), drop: node.dir && dropDirectory === node.path, 'menu-open': menuOpen }"
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
:draggable="!isRenaming && !assetDir"
role="treeitem"
:aria-selected="selectedPaths.has(node.path)"
:aria-expanded="node.dir ? isOpen : undefined"
@click="activate"
@contextmenu.prevent.stop="showMenu"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="cancelPress"
@touchcancel="cancelPress"
@dragstart="onDragStart"
@dragover="onDragOver"
@drop="onDrop"
>
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
<button v-if="!isRenaming" type="button" class="rm-tree-icon" :class="{ 'rm-emoji': node.icon?.kind === 'emoji' || (!node.icon && !image) }" :aria-label="'修改图标:' + node.name" title="修改图标" @click.stop="editIcon" @touchstart.stop @contextmenu.prevent.stop="editIcon">
<Icon v-if="node.icon?.kind === 'builtin'" :name="node.icon.value" :size="18" />
<template v-else-if="node.icon?.kind === 'emoji'">{{ node.icon.value }}</template>
<Icon v-else-if="image" name="image" :size="18" />
<template v-else>{{ node.dir ? '📂' : '📄' }}</template>
</button>
<input
v-if="isRenaming"
ref="input"
v-model="draft"
class="rm-tree-input"
@click.stop
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<Icon name="more" />
</button>
</div>
<Teleport to="body">
<div v-if="menuOpen" class="rm-menu-backdrop rm-tree-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
<div v-if="menuOpen" ref="menuElement" class="rm-menu rm-tree-menu" :style="{ left: menuPosition.left + 'px', top: menuPosition.top + 'px' }" @click.stop>
<template v-if="node.dir && !assetDir && selectedPaths.size === 1">
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
<button @click="runMenu('images')"><Icon name="image" />图片管理</button>
<button @click="runMenu('import')"><Icon name="import" />导入</button>
</template>
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
<button v-if="selectedPaths.size === 1" @click="runMenu('icon')"><Icon name="palette" />修改图标</button>
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
</Teleport>
<Transition name="rm-tree-expand" @before-leave="($el: Element) => $el.setAttribute('inert', '')" @leave-cancelled="($el: Element) => $el.removeAttribute('inert')">
<div v-if="node.dir && isOpen" class="rm-tree-children">
<ul class="rm-tree-list">
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
</ul>
</div>
</Transition>
</li>
</template>
<style scoped>
.rm-tree-menu { position: fixed; right: auto; z-index: 45; width: max-content; min-width: 0; max-width: calc(100vw - 8px); max-height: calc(100dvh - 8px); box-sizing: border-box; overflow: auto; }
.rm-tree-menu button { flex: none; white-space: nowrap; }
.rm-tree-menu-backdrop { z-index: 44; }
</style>