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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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('/'))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user