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
+14
-7
@@ -1,21 +1,24 @@
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
||||
import { drawSelection, EditorView, keymap } from '@codemirror/view'
|
||||
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
||||
import { codeBlockHighlight } from './codeblocks'
|
||||
import { livePreview, resolveImageUrl } from './livepreview'
|
||||
import { tableEditing } from './table'
|
||||
import { markdownTheme } from './theme'
|
||||
|
||||
export interface EditorOptions {
|
||||
parent: HTMLElement
|
||||
export { pasteImages } from './paste'
|
||||
|
||||
export interface EditorStateOptions {
|
||||
doc: string
|
||||
resolveImage?: (src: string) => string
|
||||
onChange?: (state: EditorState) => void
|
||||
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
||||
onChange?: (update: ViewUpdate) => void
|
||||
extensions?: Extension[]
|
||||
}
|
||||
|
||||
export function createEditor(opts: EditorOptions): EditorView {
|
||||
// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换
|
||||
export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
const extensions: Extension[] = [
|
||||
history(),
|
||||
drawSelection(),
|
||||
@@ -34,8 +37,12 @@ export function createEditor(opts: EditorOptions): EditorView {
|
||||
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
||||
if (opts.onChange) {
|
||||
const onChange = opts.onChange
|
||||
extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u.state)))
|
||||
extensions.push(EditorView.updateListener.of((u) => u.docChanged && onChange(u)))
|
||||
}
|
||||
if (opts.extensions) extensions.push(...opts.extensions)
|
||||
return new EditorView({ parent: opts.parent, state: EditorState.create({ doc: opts.doc, extensions }) })
|
||||
return EditorState.create({ doc: opts.doc, extensions })
|
||||
}
|
||||
|
||||
export function createEditorView(parent: HTMLElement, state: EditorState): EditorView {
|
||||
return new EditorView({ parent, state })
|
||||
}
|
||||
@@ -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) => ``).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 = ``
|
||||
const at = view.state.doc.toString().indexOf(placeholder)
|
||||
if (at >= 0) view.dispatch({ changes: { from: at, to: at + placeholder.length, insert: '' } })
|
||||
}
|
||||
Reference in new issue
Block a user