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

+70
View File
@@ -0,0 +1,70 @@
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
// 上传文件,返回写入 Markdown 的链接
upload: (file: File) => Promise<string>
onError: (err: unknown) => void
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
onPending: (delta: 1 | -1) => void
}
// 粘贴或拖入图片:先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接
export function pasteImages(opts: PasteImagesOptions): Extension {
const insert = (view: EditorView, files: File[], pos?: number) => {
const images = files.filter((f) => f.type.startsWith('image/'))
if (!images.length) return false
const urls = images.map((f) => URL.createObjectURL(f))
const text = urls.map((u) => `![](${u})`).join('\n')
const range = view.state.selection.main
const from = pos ?? range.from
const to = pos ?? range.to
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
userEvent: 'input.paste',
})
images.forEach((file, i) => {
opts.onPending(1)
opts
.upload(file)
.then((link) => replaceUrl(view, urls[i], link))
.catch((err) => {
removePlaceholder(view, urls[i])
opts.onError(err)
})
.finally(() => {
opts.onPending(-1)
URL.revokeObjectURL(urls[i])
})
})
return true
}
return EditorView.domEventHandlers({
paste(e, view) {
const files = [...(e.clipboardData?.files ?? [])]
if (!insert(view, files)) return false
e.preventDefault()
return true
},
drop(e, view) {
const files = [...(e.dataTransfer?.files ?? [])]
const pos = view.posAtCoords(e)
if (!files.length || !insert(view, files, pos ?? undefined)) return false
e.preventDefault()
return true
},
})
}
// 占位可能已被用户编辑移动,按 blob 地址在当前文档中查找
function replaceUrl(view: EditorView, url: string, link: string) {
const at = view.state.doc.toString().indexOf(url)
if (at >= 0) view.dispatch({ changes: { from: at, to: at + url.length, insert: link } })
}
function removePlaceholder(view: EditorView, url: string) {
const placeholder = `![](${url})`
const at = view.state.doc.toString().indexOf(placeholder)
if (at >= 0) view.dispatch({ changes: { from: at, to: at + placeholder.length, insert: '' } })
}