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:
eezd committed 2026-10-16 18:37:14 +08:00
1 parent c4d2bd4636
commit 02d904056c
28 files changed
+2231 -398

No files matched your search

+125
View File
@@ -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>