feat(web): add Vue workspace with file tree, tabs, autosave and live sync
- Login and first-run setup screens - File tree with inline create/rename, context menu, drag-and-drop move and trash with undo; all operations update the UI first and roll back on failure - Tabs keep a CodeMirror state each; edits autosave after 500 ms with If-Match revisions, unsent edits persist in IndexedDB, and conflicts or remote deletions surface as banners with explicit choices - WebSocket events sync external edits, moves and deletions into open tabs; reconnects trigger a full resync - Pasted or dropped images show instantly and upload to note.assets/ - Responsive layout with a swipe-in sidebar drawer on phones - Switch to TypeScript 6: vue-tsc does not support TypeScript 7 yet
This commit is contained in:
1 parent
c4d2bd4636
commit
02d904056c
28 files changed
+2231
-398
No files matched your search
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { basename, joinPath } from './paths'
|
||||
import { expanded, type TreeNode } from './tree'
|
||||
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
|
||||
|
||||
const props = defineProps<{ node: TreeNode; depth: number }>()
|
||||
|
||||
const isOpen = computed(() => expanded.has(props.node.path))
|
||||
const isActive = computed(() => ws.active === props.node.path)
|
||||
const isRenaming = computed(() => ws.renaming === props.node.path)
|
||||
const menuOpen = ref(false)
|
||||
const dropTarget = ref(false)
|
||||
const input = ref<HTMLInputElement>()
|
||||
const draft = ref('')
|
||||
|
||||
function activate() {
|
||||
if (props.node.dir) {
|
||||
if (isOpen.value) expanded.delete(props.node.path)
|
||||
else expanded.add(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' | 'rename' | 'delete') {
|
||||
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 === 'rename') ws.renaming = path
|
||||
else void remove(path)
|
||||
}
|
||||
|
||||
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 (!props.node.dir || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dropTarget.value = true
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dropTarget.value = false
|
||||
const src = e.dataTransfer?.getData('application/x-rawmark-path')
|
||||
if (!props.node.dir || !src) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const to = joinPath(props.node.path, basename(src))
|
||||
if (src !== props.node.path && !props.node.path.startsWith(src + '/') && to !== src) void move(src, to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="rm-tree-item">
|
||||
<div
|
||||
class="rm-tree-row"
|
||||
:class="{ active: isActive, drop: dropTarget, 'menu-open': menuOpen }"
|
||||
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
|
||||
:draggable="!isRenaming"
|
||||
@click="activate"
|
||||
@contextmenu.prevent="menuOpen = true"
|
||||
@dragstart="onDragStart"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="dropTarget = false"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="12" /></span>
|
||||
<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-else class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
||||
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
<div v-if="menuOpen" class="rm-menu" @click.stop>
|
||||
<template v-if="node.dir">
|
||||
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
|
||||
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||
</template>
|
||||
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||
</div>
|
||||
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||
</div>
|
||||
<ul v-if="node.dir && isOpen" class="rm-tree-list">
|
||||
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
Reference in new issue
Block a user