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

+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, setUnauthorizedHandler } from './api'
import AuthView from './AuthView.vue'
import Toasts from './Toasts.vue'
import Workspace from './Workspace.vue'
import { stopWorkspace } from './workspace'
const phase = ref<'loading' | 'setup' | 'login' | 'ready' | 'error'>('loading')
async function check() {
try {
const s = await api.authState()
phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login'
} catch {
phase.value = 'error'
}
}
setUnauthorizedHandler(() => {
stopWorkspace()
phase.value = 'login'
})
onMounted(check)
</script>
<template>
<AuthView v-if="phase === 'setup' || phase === 'login'" :mode="phase" @done="phase = 'ready'" />
<Workspace v-else-if="phase === 'ready'" @logout="phase = 'login'" />
<div v-else class="rm-splash">
<template v-if="phase === 'error'">
<p>无法连接到服务器</p>
<button class="rm-btn" @click="check">重试</button>
</template>
</div>
<Toasts />
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { api, ApiError } from './api'
const props = defineProps<{ mode: 'setup' | 'login' }>()
const emit = defineEmits<{ done: [] }>()
const code = ref('')
const password = ref('')
const confirm = ref('')
const error = ref('')
const busy = ref(false)
const isSetup = computed(() => props.mode === 'setup')
async function submit() {
error.value = ''
if (isSetup.value && password.value !== confirm.value) {
error.value = '两次输入的密码不一致'
return
}
busy.value = true
try {
if (isSetup.value) await api.setup(code.value, password.value)
else await api.login(password.value)
emit('done')
} catch (err) {
error.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
} finally {
busy.value = false
}
}
</script>
<template>
<div class="rm-auth">
<form class="rm-auth-card" @submit.prevent="submit">
<h1>RawMark</h1>
<p v-if="isSetup" class="rm-auth-hint">
首次使用,请设置登录密码。启动码位于服务器数据目录的 <code>setup-code.txt</code> 中。
</p>
<label v-if="isSetup">
<span>启动码</span>
<input v-model="code" autocomplete="one-time-code" autocapitalize="characters" placeholder="XXXX-XXXX" required />
</label>
<label>
<span>密码</span>
<input
v-model="password"
type="password"
:autocomplete="isSetup ? 'new-password' : 'current-password'"
:minlength="isSetup ? 8 : undefined"
required
autofocus
/>
</label>
<label v-if="isSetup">
<span>确认密码</span>
<input v-model="confirm" type="password" autocomplete="new-password" required />
</label>
<p v-if="error" class="rm-auth-error">{{ error }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">{{ isSetup ? '完成设置' : '登录' }}</button>
</form>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { EditorState } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { createEditorView } from '../editor'
import { activeTab, attachView, editorStateOf, noteRefOf, storeViewState, ws } from './workspace'
const host = ref<HTMLElement>()
const tab = computed(activeTab)
let view: EditorView
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置
let shown: { path: string } | undefined
const scrollTops = new WeakMap<object, number>()
function show() {
const path = ws.active
const ref = path ? noteRefOf(path) : undefined
const state = path ? editorStateOf(path) : undefined
if (shown && shown !== ref) {
storeViewState(shown.path, view.state)
scrollTops.set(shown, view.scrollDOM.scrollTop)
}
if (!ref || !state) {
shown = undefined
return
}
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
if (shown !== ref || state.doc !== view.state.doc) {
view.setState(state)
const top = scrollTops.get(ref) ?? 0
requestAnimationFrame(() => (view.scrollDOM.scrollTop = top))
}
shown = ref
}
onMounted(() => {
view = createEditorView(host.value!, EditorState.create())
attachView(view)
show()
})
onBeforeUnmount(() => {
if (shown) storeViewState(shown.path, view.state)
attachView(null)
view.destroy()
})
watch(() => [ws.active, tab.value?.status], show, { flush: 'post' })
</script>
<template>
<div class="rm-editor-pane">
<div v-show="tab && tab.status !== 'loading' && tab.status !== 'error'" ref="host" class="rm-editor-host" />
<div v-if="!tab" class="rm-empty">
<p>选择或新建一篇笔记</p>
</div>
<div v-else-if="tab.status === 'error'" class="rm-empty"><p>笔记打开失败</p></div>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
// 线性图标(24×24 视框,描边绘制)
const paths = {
menu: 'M4 6h16M4 12h16M4 18h16',
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
chevron: 'm9 6 6 6-6 6',
more: 'M12 6h.01M12 12h.01M12 18h.01',
close: 'M18 6 6 18M6 6l12 12',
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
}
defineProps<{ name: keyof typeof paths; size?: number }>()
</script>
<template>
<svg
:width="size ?? 16"
:height="size ?? 16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[name]" />
</svg>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { activeTab, keepLocalVersion, retrySave, useServerVersion } from './workspace'
const tab = computed(activeTab)
</script>
<template>
<div v-if="tab?.status === 'conflict'" class="rm-banner warn">
<span>这篇笔记已在其他地方被修改,与你的修改冲突。</span>
<button class="rm-btn" @click="useServerVersion(tab.path)">载入服务器版本</button>
<button class="rm-btn" @click="keepLocalVersion(tab.path)">保留我的修改</button>
</div>
<div v-else-if="tab?.status === 'deleted'" class="rm-banner warn">
<span>这篇笔记已在其他地方被删除或移动。</span>
<button class="rm-btn" @click="keepLocalVersion(tab.path)">重新保存</button>
</div>
<div v-else-if="tab?.saveError" class="rm-banner error">
<span>{{ tab.saveError }}</span>
<button class="rm-btn" @click="retrySave(tab.path)">立即重试</button>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import TrashDialog from './TrashDialog.vue'
import { tree } from './tree'
import TreeNode from './TreeNode.vue'
import { createFolder, createNote, move } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const trashOpen = ref(false)
const rootDrop = ref(false)
function onRootDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-rawmark-path')) return
e.preventDefault()
rootDrop.value = true
}
function onRootDrop(e: DragEvent) {
rootDrop.value = false
const src = e.dataTransfer?.getData('application/x-rawmark-path')
if (!src || !src.includes('/')) return
e.preventDefault()
void move(src, basename(src))
}
</script>
<template>
<div class="rm-sidebar-inner">
<div class="rm-sidebar-head">
<span class="rm-brand">RawMark</span>
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
</div>
<div class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
<ul class="rm-tree-list">
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
</ul>
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
</div>
<div class="rm-sidebar-foot">
<button class="rm-foot-btn" @click="trashOpen = true"><Icon name="trash" />回收站</button>
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
</div>
<TrashDialog v-if="trashOpen" @close="trashOpen = false" />
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import Icon from './Icon.vue'
import { noteTitle } from './paths'
import { closeTab, ws } from './workspace'
</script>
<template>
<nav class="rm-tabs" role="tablist">
<div
v-for="tab in ws.tabs"
:key="tab.path"
class="rm-tab"
:class="{ active: ws.active === tab.path }"
role="tab"
:aria-selected="ws.active === tab.path"
:title="tab.path"
@click="ws.active = tab.path"
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
>
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
<span class="rm-dirty-dot" />
<Icon name="close" :size="12" />
</button>
</div>
</nav>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { dismissToast, ws, type Toast } from './workspace'
function act(t: Toast) {
t.action?.run()
dismissToast(t.id)
}
</script>
<template>
<div class="rm-toasts" aria-live="polite">
<div v-for="t in ws.toasts" :key="t.id" class="rm-toast">
<span>{{ t.text }}</span>
<button v-if="t.action" class="rm-toast-action" @click="act(t)">{{ t.action.label }}</button>
</div>
</div>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type TrashItem } from './api'
import Icon from './Icon.vue'
import { restore, toast } from './workspace'
const emit = defineEmits<{ close: [] }>()
const items = ref<TrashItem[] | null>(null)
async function load() {
try {
items.value = await api.trash()
} catch {
toast('读取回收站失败')
emit('close')
}
}
async function doRestore(item: TrashItem) {
items.value = items.value!.filter((i) => i.id !== item.id)
await restore(item.id)
}
async function purge(item: TrashItem) {
if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return
items.value = items.value!.filter((i) => i.id !== item.id)
try {
await api.purge(item.id)
} catch {
toast('删除失败')
void load()
}
}
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
onMounted(load)
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog" role="dialog" aria-label="回收站">
<div class="rm-dialog-head">
<h2>回收站</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div>
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
<ul v-else class="rm-trash-list">
<li v-for="item in items ?? []" :key="item.id">
<Icon :name="item.dir ? 'folder' : 'note'" />
<div class="rm-trash-info">
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
</div>
<button class="rm-btn" @click="doRestore(item)">恢复</button>
<button class="rm-btn rm-btn-danger" @click="purge(item)">彻底删除</button>
</li>
</ul>
</div>
</div>
</template>
+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>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { api } from './api'
import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue'
import NoteBanner from './NoteBanner.vue'
import { noteTitle } from './paths'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const saving = computed(() => activeTab()?.dirty)
async function logout() {
await flushAll()
try {
await api.logout()
} catch {
toast('退出失败')
return
}
stopWorkspace()
emit('logout')
}
// 移动端:从屏幕左边缘右滑打开侧栏
let touchX: number | null = null
function onTouchStart(e: TouchEvent) {
touchX = e.touches[0].clientX < 24 ? e.touches[0].clientX : null
}
function onTouchMove(e: TouchEvent) {
if (touchX !== null && e.touches[0].clientX - touchX > 60) {
ws.sidebarOpen = true
touchX = null
}
}
onMounted(() => void startWorkspace())
onBeforeUnmount(stopWorkspace)
</script>
<template>
<div class="rm-shell" :class="{ 'sidebar-open': ws.sidebarOpen }" @touchstart.passive="onTouchStart" @touchmove.passive="onTouchMove">
<aside class="rm-sidebar"><Sidebar @logout="logout" /></aside>
<div class="rm-backdrop" @click="ws.sidebarOpen = false" />
<main class="rm-main">
<header class="rm-mobile-bar">
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button>
<span class="rm-mobile-title">{{ title }}</span>
<span class="rm-save-state" :class="{ on: saving }" />
</header>
<TabBar />
<NoteBanner />
<EditorPane />
</main>
</div>
</template>
+104
View File
@@ -0,0 +1,104 @@
// REST API 客户端,与服务端 internal/api 对应
export interface Entry {
path: string
dir: boolean
size: number
mtime: number
}
export interface TrashItem {
id: string
path: string
dir: boolean
deletedAt: number
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly data: Record<string, unknown> = {},
) {
super(message)
}
}
// 401 时通知应用回到登录页
let onUnauthorized: () => void = () => {}
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn
}
export function encodePath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/')
}
async function request(
method: string,
url: string,
init: { json?: unknown; body?: BodyInit; headers?: Record<string, string>; keepalive?: boolean } = {},
) {
const headers: Record<string, string> = { ...init.headers }
let body = init.body
if (init.json !== undefined) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(init.json)
}
const res = await fetch(url, { method, headers, body, credentials: 'same-origin', keepalive: init.keepalive })
if (!res.ok) {
let data: Record<string, unknown> = {}
try {
data = await res.json()
} catch {
// 非 JSON 错误体(如反向代理返回的页面)
}
if (res.status === 401 && !url.startsWith('/api/v1/auth/')) onUnauthorized()
throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `请求失败(${res.status})`, data)
}
return res
}
const json = async <T>(p: Promise<Response>): Promise<T> => (await p).json()
export const api = {
authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')),
setup: (code: string, password: string) => request('POST', '/api/v1/auth/setup', { json: { code, password } }),
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
logout: () => request('POST', '/api/v1/auth/logout'),
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
async readFile(path: string): Promise<{ content: string; rev: string }> {
const res = await request('GET', '/api/v1/files/' + encodePath(path))
return { content: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
},
// ifMatch 为空字符串表示新建(文件必须不存在),undefined 表示无条件覆盖
// keepalive 让请求在页面关闭后仍能发出(浏览器限制请求体不超过 64 KB,超出时退回普通请求)
async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
if (ifMatch === '') headers['If-None-Match'] = '*'
else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"`
const res = request('PUT', '/api/v1/files/' + encodePath(path), {
body: content,
headers,
keepalive: keepalive && new Blob([content]).size < 60_000,
})
return (await json<{ rev: string }>(res)).rev
},
deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))),
move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }),
mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }),
async upload(note: string, file: File): Promise<string> {
const form = new FormData()
form.append('file', file, file.name || 'image')
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
},
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
}
+348
View File
@@ -0,0 +1,348 @@
/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */
:root {
--rm-sidebar-bg: #f9f9f9;
--rm-sidebar-width: 260px;
--rm-hover: rgba(0, 0, 0, 0.045);
--rm-active: rgba(66, 184, 131, 0.14);
--rm-danger: #e5484d;
--rm-warn-bg: #fff8e6;
--rm-warn-text: #8a5a00;
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--rm-sidebar-bg: #161618;
--rm-hover: rgba(255, 255, 255, 0.06);
--rm-active: rgba(66, 211, 146, 0.16);
--rm-warn-bg: #2f2716;
--rm-warn-text: #f0c060;
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
}
html,
body,
#app {
height: 100%;
}
body {
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
button {
font: inherit;
color: inherit;
}
/* ---- 通用控件 ---- */
.rm-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.rm-btn:hover { border-color: var(--rm-accent); color: var(--rm-accent-strong); }
.rm-btn:disabled { opacity: 0.6; cursor: default; }
.rm-btn-primary { background: var(--rm-accent); border-color: var(--rm-accent); color: #fff; }
.rm-btn-primary:hover { background: var(--rm-accent-strong); color: #fff; }
.rm-btn-danger:hover { border-color: var(--rm-danger); color: var(--rm-danger); }
.rm-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
cursor: pointer;
flex: none;
}
.rm-icon-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
.rm-splash,
.rm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
color: var(--rm-text-muted);
}
/* ---- 登录 ---- */
.rm-auth { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 24px; box-sizing: border-box; }
.rm-auth-card { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 14px; }
.rm-auth-card h1 { margin: 0 0 8px; font-size: 26px; color: var(--rm-accent-strong); }
.rm-auth-hint { margin: 0; font-size: 13px; color: var(--rm-text-muted); line-height: 1.6; }
.rm-auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--rm-text-muted); }
.rm-auth-card input {
height: 40px;
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: 15px;
outline: none;
}
.rm-auth-card input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
.rm-auth-card .rm-btn { height: 40px; justify-content: center; font-size: 15px; }
.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: 13px; }
/* ---- 布局 ---- */
.rm-shell { display: flex; height: 100%; }
.rm-sidebar {
width: var(--rm-sidebar-width);
flex: none;
background: var(--rm-sidebar-bg);
border-right: 1px solid var(--rm-divider);
}
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-backdrop,
.rm-mobile-bar { display: none; }
/* ---- 侧栏 ---- */
.rm-sidebar-inner { display: flex; flex-direction: column; height: 100%; }
.rm-sidebar-head { display: flex; align-items: center; gap: 2px; padding: 12px 10px 8px 16px; }
.rm-brand { flex: 1; font-weight: 600; font-size: 15px; color: var(--rm-accent-strong); }
.rm-tree { flex: 1; overflow: auto; padding: 0 6px 12px; }
.rm-tree.drop { background: var(--rm-active); }
.rm-tree-list { list-style: none; margin: 0; padding: 0; }
.rm-tree-row {
position: relative;
display: flex;
align-items: center;
gap: 4px;
height: 30px;
padding-right: 4px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.rm-tree-row:hover { background: var(--rm-hover); }
.rm-tree-row.active { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; width: 14px; color: var(--rm-text-muted); transition: transform 0.15s; }
.rm-tree-chevron.open { transform: rotate(90deg); }
.rm-tree-chevron.hidden { visibility: hidden; }
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tree-input {
flex: 1;
min-width: 0;
height: 24px;
padding: 0 6px;
border: 1px solid var(--rm-accent);
border-radius: 4px;
background: var(--rm-bg);
color: var(--rm-text);
font: inherit;
outline: none;
}
.rm-tree-more { width: 24px; height: 24px; opacity: 0; }
.rm-tree-row:hover .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; }
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: 13px; text-align: center; }
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
.rm-foot-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
font-size: 13px;
cursor: pointer;
}
.rm-foot-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
/* ---- 菜单 ---- */
.rm-menu {
position: absolute;
top: 100%;
right: 4px;
z-index: 20;
display: flex;
flex-direction: column;
min-width: 150px;
padding: 4px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
box-shadow: var(--rm-shadow);
cursor: default;
}
.rm-menu button {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.rm-menu button:hover { background: var(--rm-hover); }
.rm-menu button.danger:hover { color: var(--rm-danger); background: rgba(229, 72, 77, 0.1); }
.rm-menu-backdrop { position: fixed; inset: 0; z-index: 19; cursor: default; }
/* ---- 标签页 ---- */
.rm-tabs {
display: flex;
gap: 2px;
height: 38px;
padding: 4px 8px 0;
overflow-x: auto;
border-bottom: 1px solid var(--rm-divider);
scrollbar-width: none;
flex: none;
}
.rm-tabs:empty { display: none; }
.rm-tab {
display: flex;
align-items: center;
gap: 4px;
max-width: 200px;
height: 34px;
padding: 0 4px 0 12px;
border-radius: 8px 8px 0 0;
font-size: 13px;
color: var(--rm-text-muted);
cursor: pointer;
flex: none;
}
.rm-tab:hover { background: var(--rm-hover); }
.rm-tab.active { background: var(--rm-active); color: var(--rm-text); }
.rm-tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tab-close {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--rm-text-muted);
cursor: pointer;
}
.rm-tab-close:hover { background: var(--rm-hover); }
.rm-tab-close svg { opacity: 0; }
.rm-tab:hover .rm-tab-close svg,
.rm-tab.active .rm-tab-close svg { opacity: 1; }
.rm-dirty-dot { display: none; position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--rm-accent); }
/* 有未保存修改时显示圆点,悬停时换回关闭图标 */
.rm-tab-close.dirty .rm-dirty-dot { display: block; }
.rm-tab-close.dirty svg { opacity: 0 !important; }
.rm-tab-close.dirty:hover .rm-dirty-dot { display: none; }
.rm-tab-close.dirty:hover svg { opacity: 1 !important; }
/* ---- 提示条 ---- */
.rm-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
flex: none;
}
.rm-banner span { flex: 1; min-width: 200px; }
.rm-banner.warn,
.rm-banner.error { background: var(--rm-warn-bg); color: var(--rm-warn-text); }
/* ---- 编辑区 ---- */
.rm-editor-pane { flex: 1; min-height: 0; position: relative; }
.rm-editor-host { height: 100%; }
.rm-editor-host .cm-editor { height: 100%; }
/* ---- 对话框 ---- */
.rm-dialog-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); padding: 16px; }
.rm-dialog {
width: 100%;
max-width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
border-radius: 12px;
background: var(--rm-bg);
box-shadow: var(--rm-shadow);
}
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; }
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
.rm-trash-list li:hover { background: var(--rm-hover); }
.rm-trash-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-trash-path { color: var(--rm-text); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-trash-time { font-size: 12px; }
/* ---- 通知 ---- */
.rm-toasts { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
.rm-toast {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 8px;
background: #1f2328;
color: #fff;
font-size: 13px;
box-shadow: var(--rm-shadow);
pointer-events: auto;
}
.rm-toast-action { border: 0; background: none; color: #42d392; font-weight: 600; cursor: pointer; padding: 0; }
/* ---- 移动端 ---- */
@media (max-width: 768px) {
.rm-sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 40;
width: min(84vw, 320px);
transform: translateX(-100%);
transition: transform 0.22s ease;
padding-top: env(safe-area-inset-top);
}
.sidebar-open .rm-sidebar { transform: none; box-shadow: var(--rm-shadow); }
.rm-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.3); opacity: 0; pointer-events: none; transition: opacity 0.22s; display: block; }
.sidebar-open .rm-backdrop { opacity: 1; pointer-events: auto; }
.rm-tabs { display: none; }
.rm-mobile-bar {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
padding: env(safe-area-inset-top) 8px 0;
border-bottom: 1px solid var(--rm-divider);
flex: none;
}
.rm-mobile-bar .rm-icon-btn { width: 40px; height: 40px; }
.rm-mobile-title { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-save-state { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: transparent; }
.rm-save-state.on { background: var(--rm-accent); }
.rm-tree-row { height: 40px; font-size: 15px; }
.rm-tree-more { opacity: 1; width: 36px; height: 36px; }
.rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
.rm-trash-list li { flex-wrap: wrap; }
}
+36
View File
@@ -0,0 +1,36 @@
// 未成功保存到服务器的内容暂存在 IndexedDB,刷新页面或断网都不会丢失
export interface Draft {
path: string
content: string
// 本地修改所基于的服务器版本;新建文件时为空字符串
baseRev: string
updatedAt: number
}
let dbPromise: Promise<IDBDatabase> | undefined
function db(): Promise<IDBDatabase> {
dbPromise ??= new Promise((resolve, reject) => {
const req = indexedDB.open('rawmark', 1)
req.onupgradeneeded = () => req.result.createObjectStore('drafts', { keyPath: 'path' })
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
return dbPromise
}
async function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const store = (await db()).transaction('drafts', mode).objectStore('drafts')
return new Promise((resolve, reject) => {
const req = fn(store)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export const drafts = {
put: (d: Draft) => tx('readwrite', (s) => s.put(d)),
delete: (path: string) => tx('readwrite', (s) => s.delete(path)),
all: () => tx<Draft[]>('readonly', (s) => s.getAll()),
}
+48
View File
@@ -0,0 +1,48 @@
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
export interface VaultEvent {
type: 'tree' | 'changed' | 'deleted' | 'moved'
path?: string
from?: string
rev?: string
}
export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void; onResync: () => void }): () => void {
let ws: WebSocket | undefined
let delay = 1000
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
let connectedBefore = false
const connect = () => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/api/v1/events`)
ws.onopen = () => {
delay = 1000
// 首次连接前的状态由初始化加载,之后每次重连都可能错过事件
if (connectedBefore) handlers.onResync()
connectedBefore = true
}
ws.onmessage = (e) => handlers.onEvents(JSON.parse(e.data))
ws.onclose = () => {
if (stopped) return
timer = setTimeout(connect, delay)
delay = Math.min(delay * 2, 30000)
}
}
connect()
// 页面从后台恢复时立即重连,不等退避计时
const onVisible = () => {
if (document.visibilityState === 'visible' && ws?.readyState === WebSocket.CLOSED) {
clearTimeout(timer)
connect()
}
}
document.addEventListener('visibilitychange', onVisible)
return () => {
stopped = true
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisible)
ws?.close()
}
}
+40
View File
@@ -0,0 +1,40 @@
import { encodePath } from './api'
export const NOTE_EXT = '.md'
export function dirname(path: string): string {
const i = path.lastIndexOf('/')
return i < 0 ? '' : path.slice(0, i)
}
export function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1)
}
export function joinPath(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name
}
export function noteTitle(path: string): string {
return basename(path).replace(/\.md$/, '')
}
// 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw
export function resolveAssetUrl(notePath: string, src: string): string {
if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return src
const parts = dirname(notePath) ? dirname(notePath).split('/') : []
for (const seg of src.split(/[?#]/)[0].split('/')) {
if (seg === '' || seg === '.') continue
if (seg === '..') parts.pop()
else {
let decoded = seg
try {
decoded = decodeURIComponent(seg)
} catch {
// 非法的百分号序列按原样使用
}
parts.push(decoded)
}
}
return '/api/v1/raw/' + encodePath(parts.join('/'))
}
+46
View File
@@ -0,0 +1,46 @@
import { computed, reactive, watch } from 'vue'
import type { Entry } from './api'
import { basename, dirname } from './paths'
import { ws } from './workspace'
export interface TreeNode {
name: string
path: string
dir: boolean
children: TreeNode[]
}
const collator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' })
function build(entries: Entry[]): TreeNode[] {
const root: TreeNode[] = []
const dirs = new Map<string, TreeNode>()
// 先按路径排序,保证父目录先于子项出现
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, children: [] }
if (e.dir) dirs.set(e.path, node)
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
}
const sort = (nodes: TreeNode[]) => {
nodes.sort((a, b) => (a.dir !== b.dir ? (a.dir ? -1 : 1) : collator.compare(a.name, b.name)))
nodes.forEach((n) => sort(n.children))
}
sort(root)
return root
}
export const tree = computed(() => build(ws.entries))
const EXPANDED_KEY = 'rawmark.expanded'
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])))
// 打开或新建笔记时展开其所在的各级目录
watch(
() => [ws.active, ws.renaming],
(paths) => {
for (const p of paths) {
for (let d = p ? dirname(p) : ''; d; d = dirname(d)) expanded.add(d)
}
},
)
+614
View File
@@ -0,0 +1,614 @@
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { reactive, watch } from 'vue'
import { createEditorState, pasteImages } from '../editor'
import { api, ApiError, type Entry } from './api'
import { drafts } from './drafts'
import { connectEvents, type VaultEvent } from './events'
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
export interface Tab {
path: string
// 最近一次与服务器一致的版本
rev: string
status: TabStatus
// 有尚未保存成功的修改
dirty: boolean
saveError?: string
// 冲突时服务器上的版本;为空字符串表示服务器上已不存在
conflictRev?: string
}
export interface Toast {
id: number
text: string
action?: { label: string; run: () => void }
}
export const ws = reactive({
entries: [] as Entry[],
tabs: [] as Tab[],
active: null as string | null,
// 目录树中正在行内重命名的路径
renaming: null as string | null,
// 移动端侧栏抽屉
sidebarOpen: false,
toasts: [] as Toast[],
})
const SAVE_DELAY = 500
const RETRY_DELAY = 5000
const MAX_RETRIES = 12
// 以下状态不放进 reactive:EditorState 体积大且不可变,交给 Vue 代理没有意义
const editorStates = new Map<string, EditorState>()
// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新)
const noteRefs = new Map<string, { path: string }>()
interface SaveSlot {
timer?: ReturnType<typeof setTimeout>
inflight?: Promise<void>
// 修改序号与已保存序号,二者相等即没有待保存的修改
seq: number
savedSeq: number
uploads: number
paused: number
retries: number
}
const slots = new Map<string, SaveSlot>()
let view: EditorView | null = null
// 进行中的本地文件操作数;期间收到的目录事件不用于判断标签页是否被删除
let opsInFlight = 0
const externalChange = Annotation.define<boolean>()
export function tabOf(path: string | null): Tab | undefined {
return path === null ? undefined : ws.tabs.find((t) => t.path === path)
}
export function activeTab(): Tab | undefined {
return tabOf(ws.active)
}
// ---- 提示 ----
let toastId = 0
export function toast(text: string, action?: Toast['action']) {
const t: Toast = { id: ++toastId, text, action }
ws.toasts.push(t)
setTimeout(() => dismissToast(t.id), action ? 6000 : 3500)
}
export function dismissToast(id: number) {
const i = ws.toasts.findIndex((t) => t.id === id)
if (i >= 0) ws.toasts.splice(i, 1)
}
function errorText(err: unknown): string {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
}
// ---- 编辑器状态 ----
export function attachView(v: EditorView | null) {
view = v
}
export function editorStateOf(path: string): EditorState | undefined {
return editorStates.get(path)
}
// 笔记的身份对象:重命名/移动后保持不变,用于判断编辑器中显示的是否仍是同一篇笔记
export function noteRefOf(path: string): { path: string } | undefined {
return noteRefs.get(path)
}
// 切换标签页前由编辑器面板调用,保存当前视图状态(含光标、滚动无关的选区与撤销历史)
export function storeViewState(path: string, state: EditorState) {
if (editorStates.has(path)) editorStates.set(path, state)
}
function buildState(path: string, doc: string): EditorState {
const ref = { path }
noteRefs.set(path, ref)
return createEditorState({
doc,
resolveImage: (src) => resolveAssetUrl(ref.path, src),
onChange: (u) => {
editorStates.set(ref.path, u.state)
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
},
extensions: [
pasteImages({
upload: (file) => api.upload(ref.path, file),
onError: (err) => toast('图片上传失败:' + errorText(err)),
onPending: (d) => {
slot(ref.path).uploads += d
},
}),
],
})
}
// 用服务器内容替换文档:只替换首尾相同部分之间的差异,尽量保留光标位置;不进入撤销历史
function replaceDoc(path: string, content: string) {
const state = path === ws.active && view ? view.state : editorStates.get(path)
if (!state) return
const old = state.doc.toString()
if (old === content) return
let start = 0
while (start < old.length && start < content.length && old[start] === content[start]) start++
let endOld = old.length
let endNew = content.length
while (endOld > start && endNew > start && old[endOld - 1] === content[endNew - 1]) {
endOld--
endNew--
}
const spec = {
changes: { from: start, to: endOld, insert: content.slice(start, endNew) },
annotations: [externalChange.of(true), Transaction.addToHistory.of(false)],
}
if (path === ws.active && view) view.dispatch(spec)
else editorStates.set(path, state.update(spec).state)
}
function docOf(path: string): string | undefined {
if (path === ws.active && view && editorStates.has(path)) return view.state.doc.toString()
return editorStates.get(path)?.doc.toString()
}
// ---- 保存队列 ----
function slot(path: string): SaveSlot {
let s = slots.get(path)
if (!s) slots.set(path, (s = { seq: 0, savedSeq: 0, uploads: 0, paused: 0, retries: 0 }))
return s
}
function scheduleSave(path: string, delay = SAVE_DELAY) {
const s = slot(path)
s.seq++
const tab = tabOf(path)
if (tab) tab.dirty = true
clearTimeout(s.timer)
s.timer = setTimeout(() => void flush(path), delay)
}
// 立即保存一篇笔记的待保存修改;keepalive 用于页面隐藏/关闭时尽力发出请求
export async function flush(path: string, keepalive = false): Promise<void> {
const s = slots.get(path)
if (!s) return
clearTimeout(s.timer)
while (s.inflight) await s.inflight
const tab = tabOf(path)
if (!tab || tab.status !== 'ready' || s.seq === s.savedSeq) return
if (s.paused || s.uploads) {
// 图片上传中或文件正在移动,稍后再存,避免写入临时 blob: 地址或写到旧路径
s.timer = setTimeout(() => void flush(path), SAVE_DELAY)
return
}
const content = docOf(path)
if (content === undefined) return
const seq = s.seq
const baseRev = tab.rev
s.inflight = (async () => {
try {
await drafts.put({ path, content, baseRev, updatedAt: Date.now() })
const rev = await api.writeFile(path, content, baseRev, keepalive)
tab.rev = rev
tab.saveError = undefined
s.savedSeq = seq
s.retries = 0
if (s.seq === seq) {
tab.dirty = false
await drafts.delete(path)
}
} catch (err) {
if (err instanceof ApiError && err.status === 412) {
const serverRev = typeof err.data.rev === 'string' ? err.data.rev : ''
tab.status = serverRev ? 'conflict' : 'deleted'
tab.conflictRev = serverRev
} else if (!(err instanceof ApiError) || err.status >= 500) {
// 网络错误与服务器错误可重试,草稿已在本地,不会丢
tab.saveError = s.retries < MAX_RETRIES ? '保存失败,正在重试…' : '保存失败,请检查网络后重试'
if (s.retries++ < MAX_RETRIES) s.timer = setTimeout(() => void flush(path), RETRY_DELAY)
} else {
tab.saveError = '保存失败:' + err.message
}
}
})()
await s.inflight
s.inflight = undefined
if (s.seq !== s.savedSeq && tab.status === 'ready' && !s.timer) scheduleSave(path)
}
export function retrySave(path: string) {
slot(path).retries = 0
void flush(path)
}
export async function flushAll(keepalive = false) {
await Promise.all([...slots.keys()].map((p) => flush(p, keepalive)))
}
// ---- 冲突处理 ----
// 放弃本地修改,载入服务器版本
export async function useServerVersion(path: string) {
const tab = tabOf(path)
if (!tab) return
try {
const { content, rev } = await api.readFile(path)
replaceDoc(path, content)
const s = slot(path)
s.savedSeq = s.seq
Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined })
await drafts.delete(path)
} catch (err) {
toast('载入失败:' + errorText(err))
}
}
// 保留本地修改,覆盖服务器版本(服务器上已删除时重新创建)
export async function keepLocalVersion(path: string) {
const tab = tabOf(path)
const content = docOf(path)
if (!tab || content === undefined) return
try {
const rev = await api.writeFile(path, content, tab.conflictRev ?? '')
const s = slot(path)
s.savedSeq = s.seq
Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined })
await drafts.delete(path)
if (!ws.entries.some((e) => e.path === path)) void refreshTree()
} catch (err) {
if (err instanceof ApiError && err.status === 412) {
tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : ''
toast('服务器上的版本又变化了,请再次选择')
} else toast('保存失败:' + errorText(err))
}
}
// ---- 标签页 ----
export async function openNote(path: string) {
ws.sidebarOpen = false
if (tabOf(path)) {
ws.active = path
return
}
const tab: Tab = { path, rev: '', status: 'loading', dirty: false }
ws.tabs.push(tab)
ws.active = path
try {
const { content, rev } = await api.readFile(path)
editorStates.set(path, buildState(path, content))
const t = tabOf(path)
if (t) Object.assign(t, { rev, status: 'ready' })
} catch (err) {
const t = tabOf(path)
if (t) t.status = 'error'
toast('打开失败:' + errorText(err))
}
}
export async function closeTab(path: string) {
await flush(path)
const i = ws.tabs.findIndex((t) => t.path === path)
if (i < 0) return
const tab = ws.tabs[i]
if (tab.dirty && tab.status !== 'ready') {
// 冲突或已删除且有未保存修改:草稿保留在本地,下次打开时可恢复
toast('未保存的修改已保留为本地草稿')
}
ws.tabs.splice(i, 1)
forget(path)
if (ws.active === path) ws.active = ws.tabs[Math.min(i, ws.tabs.length - 1)]?.path ?? null
}
function forget(path: string) {
editorStates.delete(path)
noteRefs.delete(path)
const s = slots.get(path)
if (s) clearTimeout(s.timer)
slots.delete(path)
}
// 路径前缀替换(移动文件夹时其下所有路径一起变化)
function remapPath(p: string, from: string, to: string): string | null {
if (p === from) return to
if (p.startsWith(from + '/')) return to + p.slice(from.length)
return null
}
function remapOpenNotes(from: string, to: string) {
for (const tab of ws.tabs) {
const next = remapPath(tab.path, from, to)
if (next === null) continue
const old = tab.path
tab.path = next
if (ws.active === old) ws.active = next
for (const map of [editorStates, noteRefs, slots] as Map<string, unknown>[]) {
if (map.has(old)) {
map.set(next, map.get(old))
map.delete(old)
}
}
const ref = noteRefs.get(next)
if (ref) ref.path = next
}
}
function affected(path: string): string[] {
return ws.tabs.map((t) => t.path).filter((p) => p === path || p.startsWith(path + '/'))
}
// ---- 目录树 ----
let treeTimer: ReturnType<typeof setTimeout> | undefined
export function refreshTree(): Promise<void> {
clearTimeout(treeTimer)
return new Promise((resolve) => {
treeTimer = setTimeout(async () => {
try {
ws.entries = await api.tree()
if (opsInFlight === 0) markMissingTabsDeleted()
} catch (err) {
toast('刷新目录失败:' + errorText(err))
}
resolve()
}, 50)
})
}
function markMissingTabsDeleted() {
const exists = new Set(ws.entries.map((e) => e.path))
for (const tab of ws.tabs) {
if (tab.status === 'ready' && !exists.has(tab.path)) {
tab.status = 'deleted'
tab.conflictRev = ''
}
}
}
function uniqueName(dir: string, base: string, ext: string): string {
const taken = new Set(ws.entries.map((e) => e.path))
for (let i = 0; ; i++) {
const name = base + (i ? ` ${i}` : '') + ext
if (!taken.has(joinPath(dir, name))) return joinPath(dir, name)
}
}
async function withOp<T>(fn: () => Promise<T>): Promise<T> {
opsInFlight++
try {
return await fn()
} finally {
opsInFlight--
}
}
export async function createNote(dir: string) {
const path = uniqueName(dir, '未命名', NOTE_EXT)
ws.entries.push({ path, dir: false, size: 0, mtime: Date.now() })
ws.renaming = path
await withOp(async () => {
try {
const rev = await api.writeFile(path, '', '')
ws.tabs.push({ path, rev, status: 'ready', dirty: false })
editorStates.set(path, buildState(path, ''))
ws.active = path
} catch (err) {
ws.entries = ws.entries.filter((e) => e.path !== path)
if (ws.renaming === path) ws.renaming = null
toast('新建笔记失败:' + errorText(err))
}
})
}
export async function createFolder(dir: string) {
const path = uniqueName(dir, '新建文件夹', '')
ws.entries.push({ path, dir: true, size: 0, mtime: Date.now() })
ws.renaming = path
await withOp(async () => {
try {
await api.mkdir(path)
} catch (err) {
ws.entries = ws.entries.filter((e) => e.path !== path)
if (ws.renaming === path) ws.renaming = null
toast('新建文件夹失败:' + errorText(err))
}
})
}
export function renameTarget(path: string, name: string): string | null {
name = name.trim().replace(/\//g, '')
if (!name || name.startsWith('.')) return null
const entry = ws.entries.find((e) => e.path === path)
if (entry && !entry.dir && !name.endsWith(NOTE_EXT)) name += NOTE_EXT
return joinPath(dirname(path), name)
}
export async function move(from: string, to: string) {
if (from === to) return
if (ws.entries.some((e) => e.path === to)) {
toast(`“${basename(to)}”已存在`)
return
}
await withOp(async () => {
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(affected(from).map((p) => flush(p)))
const paused = affected(from).map(slot)
paused.forEach((s) => s.paused++)
const snapshot = ws.entries
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
remapOpenNotes(from, to)
try {
await api.move(from, to)
} catch (err) {
ws.entries = snapshot
remapOpenNotes(to, from)
toast('移动失败:' + errorText(err))
return
} finally {
paused.forEach((s) => s.paused--)
}
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
await Promise.all(affected(to).map(async (p) => {
const tab = tabOf(p)
if (!tab) return
if (tab.dirty) {
// 移动期间又有输入:本地内容仍含旧链接,交给用户选择
const { rev } = await api.readFile(p)
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
} else await syncOpenTab(tab)
}))
})
void refreshTree()
}
export async function remove(path: string) {
const entry = ws.entries.find((e) => e.path === path)
if (!entry) return
await withOp(async () => {
const paths = affected(path)
await Promise.all(paths.map((p) => flush(p)))
const snapshot = ws.entries
ws.entries = ws.entries.filter((e) => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目
for (const p of paths) await closeTab(p)
try {
const item = await api.deleteFile(path)
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
label: '撤销',
run: () => void restore(item.id),
})
} catch (err) {
ws.entries = snapshot
toast('删除失败:' + errorText(err))
}
})
}
export async function restore(id: string) {
try {
const item = await api.restore(id)
await refreshTree()
if (!item.dir) void openNote(item.path)
} catch (err) {
toast('恢复失败:' + errorText(err))
}
}
// ---- 实时同步 ----
async function syncOpenTab(tab: Tab) {
const s = slots.get(tab.path)
if (tab.status !== 'ready' || tab.dirty || s?.inflight) return
try {
const { content, rev } = await api.readFile(tab.path)
if (rev === tab.rev || tab.dirty) return
replaceDoc(tab.path, content)
tab.rev = rev
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
tab.status = 'deleted'
tab.conflictRev = ''
}
}
}
function handleEvents(evs: VaultEvent[]) {
for (const ev of evs) {
if (ev.type === 'tree') void refreshTree()
else if (ev.type === 'changed') {
const tab = tabOf(ev.path!)
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) void syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
} else if (ev.type === 'deleted') {
const tab = tabOf(ev.path!)
if (tab && tab.status === 'ready' && opsInFlight === 0) {
tab.status = 'deleted'
tab.conflictRev = ''
}
}
}
}
async function resync() {
await refreshTree()
await Promise.all(ws.tabs.map(syncOpenTab))
}
// ---- 启动 ----
const TABS_KEY = 'rawmark.tabs'
// 恢复未保存成功的草稿:服务器版本未变则继续保存,已变则进入冲突状态由用户选择
async function recoverDrafts() {
for (const d of await drafts.all()) {
try {
const { content, rev } = await api.readFile(d.path)
if (content === d.content) {
await drafts.delete(d.path)
continue
}
if (!tabOf(d.path)) ws.tabs.push({ path: d.path, rev, status: 'ready', dirty: false })
const tab = tabOf(d.path)!
editorStates.set(d.path, buildState(d.path, d.content))
tab.rev = rev
if (rev === d.baseRev) {
tab.status = 'ready'
scheduleSave(d.path)
} else Object.assign(tab, { status: 'conflict', conflictRev: rev, dirty: true })
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 404) throw err
ws.tabs.push({ path: d.path, rev: '', status: 'deleted', dirty: true, conflictRev: '' })
editorStates.set(d.path, buildState(d.path, d.content))
}
}
}
let disconnect: (() => void) | undefined
let listenersInstalled = false
export async function startWorkspace() {
await refreshTree()
const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[]; active: string | null }
const exists = new Set(ws.entries.map((e) => e.path))
await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote))
try {
await recoverDrafts()
} catch (err) {
toast('恢复本地草稿失败:' + errorText(err))
}
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() })
// 重新登录后工作区会再次启动,以下全局监听只注册一次
if (listenersInstalled) return
listenersInstalled = true
watch(
() => [ws.tabs.map((t) => t.path), ws.active] as const,
([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })),
)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') void flushAll(true)
})
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
if (ws.active) void flush(ws.active)
}
})
}
export function stopWorkspace() {
disconnect?.()
disconnect = undefined
}
+11
View File
@@ -0,0 +1,11 @@
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
import { createEditorState, createEditorView } from '../editor'
import sample from './sample.md?raw'
import '../themes/base.css'
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1)
const doc = Array.from({ length: Math.max(1, repeat) }, () => sample).join('\n')
const view = createEditorView(document.getElementById('app')!, createEditorState({ doc }))
;(window as unknown as { rawmarkView: typeof view }).rawmarkView = view
+14 -7
View File
@@ -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 })
}
+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: '' } })
}
+4 -9
View File
@@ -1,11 +1,6 @@
// M0 原型入口:只挂载编辑器,M1 替换为 Vue 外壳
import { createEditor } from './editor'
import sample from './demo/sample.md?raw'
import { createApp } from 'vue'
import App from './app/App.vue'
import './themes/base.css'
import './app/app.css'
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
const repeat = Number(new URLSearchParams(location.search).get('repeat') ?? 1)
const doc = Array.from({ length: Math.max(1, repeat) }, () => sample).join('\n')
const view = createEditor({ parent: document.getElementById('app')!, doc })
;(window as unknown as { rawmarkView: typeof view }).rawmarkView = view
createApp(App).mount('#app')