feat(ui): streamline workspace settings and remove sharing

Unify compact workspace controls and panel transitions, move directory actions into the context menu, and remove backlink UI.

Reorganize settings with searchable sections, per-setting resets, shortcut search, and accurate save feedback.

Remove sharing UI, APIs, authentication helpers, and storage code; provide a transactional cleanup script with retirement tests. Reject directories during orphan image cleanup and cover mixed-batch preservation.
This commit is contained in:
eezd committed 2026-10-24 12:32:23 +08:00
1 parent a716794e41
commit 19aab631ec
63 files changed
+1077 -3343

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<title>RawMark</title>
</head>
<body>
<main id="share" class="rm-share"></main>
<script type="module" src="/src/share/main.ts"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
<script setup lang="ts">
import Icon from './Icon.vue'
import { createNote, ws } from './workspace'
</script>
<template>
<header class="rm-global-header">
<button class="rm-wordmark" @click="ws.settings = null; ws.images = null; ws.view = 'notes'"><Icon name="edit" :size="22" /><span>RawMark</span></button>
<span class="rm-edition">个人知识库</span>
<nav class="rm-primary-nav" aria-label="工作区"><button :class="{ on: ws.view === 'notes' && ws.images === null }" @click="ws.view = 'notes'; ws.images = null"><Icon name="folder" />笔记</button><button :class="{ on: ws.view === 'graph' && ws.images === null }" @click="ws.view = 'graph'; ws.images = null"><Icon name="graph" />图谱</button><button :class="{ on: ws.images !== null }" @click="ws.imagesAll = true; ws.images = ''"><Icon name="image" />图片库</button></nav>
<button class="rm-global-search" @click="ws.switcher = ''"><Icon name="search" /><span>全局检索知识库</span><kbd>⌘ / Ctrl P</kbd></button>
<div class="rm-header-actions">
<button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button>
<button class="rm-btn" :disabled="!ws.active" @click="ws.sharing = ws.active"><Icon name="share" />分享</button>
<button class="rm-btn rm-btn-primary" @click="createNote('')"><Icon name="plus" />新建笔记</button>
<span class="rm-avatar" aria-label="个人工作区">RM</span>
</div>
</header>
</template>
+27 -29
View File
@@ -3,7 +3,8 @@ import { computed, onMounted, ref } from 'vue'
import { api, ApiError, type Theme } from './api'
import { DISPLAY_DEFAULTS } from '../themes/typography'
import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance'
import { loadSettings, reloadTheme, settings, updateSettings, type Settings } from './settings'
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import SettingRow from './SettingRow.vue'
import { toast } from './workspace'
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
@@ -30,6 +31,7 @@ async function change(patch: Partial<Settings>): Promise<boolean> {
await updateSettings(patch)
return true
} catch (err) {
if (err instanceof SettingsAppearanceError) { toast(err.message); return true }
toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误')))
return false
@@ -68,43 +70,39 @@ async function remove(snippet: CssSnippet) {
async function reset() {
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS,
cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return
toast('已恢复默认外观,CSS 片段已停用并保留')
toast('已恢复默认外观与排版,CSS 片段已停用并保留')
}
</script>
<template>
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
<div class="rm-theme-cards"><button v-for="mode in [{ id: 'light', title: '纯净浅色', subtitle: 'Serene · 日间写作' }, { id: 'dark', title: '沉静深色', subtitle: '沿用你的深色主题' }, { id: 'system', title: '跟随系统', subtitle: '随系统明暗切换' }]" :key="mode.id" :class="[mode.id, { on: (fixedMode ?? settings.colorMode) === mode.id }]" :disabled="busy" @click="change({ theme: 'default', colorMode: mode.id as Settings['colorMode'] })"><span class="rm-theme-sample"><i /><i /><i /></span><strong>{{ mode.title }}</strong><small>{{ mode.subtitle }}</small></button></div>
<details class="rm-appearance-advanced"><summary>自定义主题与 CSS 片段</summary>
<div class="rm-setting">
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label>
<select id="rm-color-mode" :value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
<option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option>
</select>
</div>
<div class="rm-setting">
<label for="rm-theme-select" class="rm-setting-label">主题</label>
<SettingRow id="theme-select" control="rm-theme-select">
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
<option v-for="theme in themes" :key="theme.id" :value="theme.id">{{ theme.name }}</option>
<option v-if="missingTheme" :value="settings.theme">{{ settings.theme }}(不可用)</option>
</select>
<button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button>
</div>
<p v-if="error" class="rm-appearance-note rm-auth-error">{{ error }}</p>
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后使用原来的明暗设置。</p>
<p class="rm-appearance-note">可选择内置主题,或从服务器数据目录的 themes 文件夹添加主题;主题和已启用的 CSS 也会应用到分享页。</p>
<div class="rm-setting"><span class="rm-setting-label">CSS 片段</span><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></div>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
</div>
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
</form>
<div class="rm-setting"><span class="rm-setting-label">默认外观</span><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观</button></div>
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
</SettingRow>
<SettingRow id="color-mode">
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
<button v-for="mode in [{ id: 'light', title: '浅色' }, { id: 'dark', title: '深色' }, { id: 'system', title: '跟随系统' }]" :key="mode.id" role="radio" :aria-checked="(fixedMode ?? settings.colorMode) === mode.id" :class="{ on: (fixedMode ?? settings.colorMode) === mode.id }" :disabled="busy || !!fixedMode" @click="change({ colorMode: mode.id as Settings['colorMode'] })">{{ mode.title }}</button>
</div>
</SettingRow>
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">{{ error }}</p>
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。</p>
<details class="rm-settings-details rm-appearance-advanced"><summary>自定义 CSS 与恢复</summary>
<SettingRow id="css-snippets"><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></SettingRow>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
</div>
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
</form>
<SettingRow id="reset-appearance"><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观与排版</button></SettingRow>
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
</details>
</template>
+6 -27
View File
@@ -1,34 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import { readingStats } from './reading'
import { activeTab, editorView, onEditorUpdate, toggleSourceMode, ws } from './workspace'
const stats = ref(readingStats(''))
const tab = computed(activeTab)
const parts = computed(() => ws.active?.split('/') ?? [])
const modified = computed(() => {
const value = ws.entries.find(e => e.path === ws.active)?.mtime
return value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(value) : ''
})
function refresh() { stats.value = readingStats(editorView()?.state.doc.toString() ?? '') }
let unsubscribe: (() => void) | undefined
onMounted(() => { unsubscribe = onEditorUpdate(refresh); requestAnimationFrame(refresh) })
onBeforeUnmount(() => unsubscribe?.())
watch(() => [ws.active, tab.value?.status], () => requestAnimationFrame(refresh))
import { toggleSourceMode, ws } from './workspace'
</script>
<template>
<section v-if="ws.active" class="rm-editor-chrome">
<div class="rm-breadcrumb"><span>知识库</span><template v-for="(part, i) in parts" :key="i"><span class="rm-crumb-separator">/</span><span :class="{ current: i === parts.length - 1 }">{{ part }}</span></template></div>
<div class="rm-document-meta"><span>字数统计 <strong>{{ stats.words.toLocaleString('zh-CN') }}</strong> 字</span><span>预计阅读 {{ stats.minutes }} 分钟</span><span v-if="modified">更新于 {{ modified }}</span><span :class="{ 'rm-share-bad': tab?.saveError }">{{ tab?.saveError ? '保存失败' : tab?.status === 'conflict' ? '存在冲突' : tab?.dirty ? '正在保存…' : '已保存' }}</span></div>
<div class="rm-document-actions">
<div class="rm-mode-switch"><button :class="{ on: !ws.sourceMode }" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()"><Icon name="eye" />即时预览</button><button :class="{ on: ws.sourceMode }" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()"><Icon name="source" />源码模式</button></div>
<button class="rm-icon-btn" :aria-pressed="ws.outlineOpen" title="显示 / 隐藏大纲" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" /></button>
<button class="rm-icon-btn" :aria-pressed="ws.focusMode" title="专注写作" @click="ws.focusMode = !ws.focusMode"><Icon name="expand" /></button>
<button class="rm-btn" @click="ws.sharing = ws.active"><Icon name="share" />分享</button>
<span class="rm-document-filename" :title="ws.active">{{ basename(ws.active) }}</span>
</div>
<section v-if="ws.active" class="rm-editor-chrome" aria-label="笔记操作">
<button class="rm-icon-btn" title="即时预览" aria-label="即时预览" :aria-pressed="!ws.sourceMode" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()"><Icon name="eye" /></button>
<button class="rm-icon-btn" title="源码模式" aria-label="源码模式" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()"><Icon name="source" /></button>
<button class="rm-icon-btn" title="显示 / 隐藏大纲" aria-label="显示 / 隐藏大纲" :aria-pressed="ws.outlineOpen" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" /></button>
<button class="rm-icon-btn" title="专注写作" aria-label="专注写作" :aria-pressed="ws.focusMode" @click="ws.focusMode = !ws.focusMode"><Icon name="expand" /></button>
</section>
</template>
+4 -1
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
// 线性图标(24×24 视框,描边绘制)
const paths = {
share: 'M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 10.5l6.8-4M8.6 13.5l6.8 4',
plus: 'M12 5v14M5 12h14',
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
@@ -9,6 +8,10 @@ const paths = {
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
+18 -29
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, type Entry, type Share } from './api'
import { type Entry } from './api'
import Icon from './Icon.vue'
import { metadata, refreshMetadata } from './metadata'
import { basename, hiddenAssetPath, imageInFolder, isImage } from './paths'
@@ -20,9 +20,6 @@ const busy = ref(false)
const report = ref<string[]>([])
const cleanupCandidates = ref<Entry[] | null>(null)
const cleanupSelected = ref<string[]>([])
const shares = ref<Share[]>([])
const shareError = ref('')
const sharesReady = ref(false)
const uploadDir = ref(props.dir)
const directories = computed(() => ws.entries.filter(entry => entry.dir && !hiddenAssetPath(entry.path)))
const allImages = computed(() => ws.entries.filter(entry => !entry.dir && isImage(entry.path)))
@@ -37,9 +34,6 @@ const references = computed(() => {
return result
})
const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length))
function shared(path: string) {
return shares.value.some(share => (!share.expiresAt || share.expiresAt > Date.now()) && share.items.some(item => item.exists && (path === item.path || path.startsWith(item.path + '/') || item.path.endsWith('.md') && path.startsWith(item.path.slice(0,-3) + '.assets/'))))
}
const images = computed(() => scopeImages.value.filter(entry => {
const count = references.value.get(entry.path)?.length ?? 0
return entry.path.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()) && (!format.value || entry.path.toLowerCase().endsWith('.' + format.value)) && (usage.value === 'all' || certain.value && (usage.value === 'used' ? count > 0 : count === 0))
@@ -48,16 +42,11 @@ const types = computed(() => [...new Set(scopeImages.value.map(entry => entry.pa
const totalSize = computed(() => scopeImages.value.reduce((sum, entry) => sum + entry.size, 0))
const current = computed(() => scopeImages.value.find(entry => entry.path === selected.value))
const currentRefs = computed(() => references.value.get(selected.value) ?? [])
const orphans = computed(() => certain.value && sharesReady.value ? scopeImages.value.filter(entry => !references.value.has(entry.path) && !shared(entry.path)) : [])
const orphans = computed(() => certain.value ? scopeImages.value.filter(entry => !references.value.has(entry.path)) : [])
function size(bytes: number) { return bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB' }
function select(path: string) { selected.value = path; dimensions.value = ''; broken.value = false }
function loaded(event: Event) { const image = event.target as HTMLImageElement; dimensions.value = image.naturalWidth + ' × ' + image.naturalHeight }
async function loadShares() {
shareError.value = ''
try { shares.value = await api.shares(); sharesReady.value = true }
catch (error) { sharesReady.value = false; shareError.value = error instanceof Error ? error.message : '分享范围读取失败' }
}
onMounted(() => { void refreshMetadata(); void loadShares() })
onMounted(() => { void refreshMetadata() })
async function upload(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]; input.value = ''
@@ -71,8 +60,8 @@ async function upload(event: Event) {
finally { busy.value = false }
}
async function proposeCleanup() {
await refreshMetadata(); await loadShares()
if (!certain.value || !sharesReady.value) { toast('引用或分享状态尚未确认,不能列出清理候选'); return }
await refreshMetadata()
if (!certain.value) { toast('引用状态尚未确认,不能列出清理候选'); return }
cleanupCandidates.value = [...orphans.value]
cleanupSelected.value = orphans.value.map(entry => entry.path)
}
@@ -89,37 +78,37 @@ async function clean() {
<template>
<section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)">
<header class="rm-media-header"><div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : (dir || '根目录') + ' · 当前目录及直属附件' }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">返回笔记</button></header>
<div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain || !sharesReady" @click="proposeCleanup">清理孤立图片</button></div>
<div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">清理孤立图片</button></div>
<div class="rm-media-workspace">
<aside class="rm-media-filters"><span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false"><Icon name="folder" />{{ dir || '根目录' }}</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true"><Icon name="image" />整个知识库</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">{{ item.label }}</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain || !sharesReady" @click="proposeCleanup"><Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small></button><p>清理前核对全库引用和有效分享,图片移入回收站,可恢复。</p></aside>
<aside class="rm-media-filters"><span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false"><Icon name="folder" />{{ dir || '根目录' }}</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true"><Icon name="image" />整个知识库</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">{{ item.label }}</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain" @click="proposeCleanup"><Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small></button><p>清理前核对全库引用,图片移入回收站,可恢复。</p></aside>
<div class="rm-media-content">
<div class="rm-media-toolbar"><label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片" /></label><select v-model="format" aria-label="图片格式"><option value="">所有格式</option><option v-for="type in types" :key="type" :value="type">{{ type.toUpperCase() }}</option></select><select v-model="sort" aria-label="图片排序"><option value="modified">最近修改</option><option value="name">名称</option><option value="size">文件大小</option></select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list"><Icon :name="list ? 'grid' : 'outline'" /></button></div>
<div class="rm-media-upload"><label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload" /><button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()"><Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}</button></div>
<p v-if="metadata.error || shareError || !certain" class="rm-media-warning" role="status">{{ metadata.error || shareError || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <button class="rm-btn" @click="refreshMetadata(); loadShares()">刷新状态</button></p>
<p v-if="metadata.error || !certain" class="rm-media-warning" role="status">{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <button class="rm-btn" @click="refreshMetadata()">刷新状态</button></p>
<div v-if="report.length" class="rm-media-report" role="status"><p v-for="(line,i) in report" :key="i">{{ line }}</p></div>
<div class="rm-media-grid" :class="{ list }">
<button v-for="image in images" :key="image.path" class="rm-media-card" :class="{ selected: selected === image.path }" :title="image.path" @click="select(image.path)" @dblclick="ws.image = image.path" @contextmenu.prevent="ws.image = image.path"><span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy" /></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? `${references.get(image.path)?.length ?? 0} 篇引用` : '引用未确认' }}{{ shared(image.path) ? ' · 已分享' : '' }}</small></button>
<button v-for="image in images" :key="image.path" class="rm-media-card" :class="{ selected: selected === image.path }" :title="image.path" @click="select(image.path)" @dblclick="ws.image = image.path" @contextmenu.prevent="ws.image = image.path"><span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy" /></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? `${references.get(image.path)?.length ?? 0} 篇引用` : '引用未确认' }}</small></button>
</div>
<p v-if="!images.length" class="rm-tree-empty">没有符合条件的图片</p>
</div>
<aside v-if="current" class="rm-media-inspector"><button class="rm-icon-btn rm-media-deselect" title="关闭详情" @click="selected = ''"><Icon name="close" /></button><div class="rm-media-detail-image"><img v-if="!broken" :src="imageUrl(current.path)" :alt="basename(current.path)" @load="loaded" @error="broken = true" /><p v-else>无法预览此图片</p></div><h2>{{ basename(current.path) }}</h2><p class="rm-media-path">{{ current.path }}</p><dl><dt>尺寸</dt><dd>{{ dimensions || (broken ? '无法读取' : '加载中…') }}</dd><dt>大小</dt><dd>{{ size(current.size) }}</dd><dt>修改时间</dt><dd>{{ new Date(current.mtime).toLocaleDateString('zh-CN') }}</dd><dt>引用</dt><dd>{{ certain ? currentRefs.length + ' 篇笔记' : '尚未确认' }}</dd></dl><button class="rm-btn rm-btn-primary" @click="ws.image = current.path"><Icon name="edit" />预览与编辑</button><h3>引用此图片的笔记</h3><button v-for="ref in currentRefs" :key="ref.path" class="rm-media-reference" @click="ws.images = null; openReference(ref.path, ref.line)"><Icon name="note" /><span>{{ ref.title }}<small>{{ ref.path }}</small></span></button><p v-if="certain && !currentRefs.length">没有笔记引用</p></aside>
</div>
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"><section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"><div class="rm-dialog-head"><h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"><Icon name="close" /></button></div><p class="rm-share-note">服务端会再次读取最新笔记和分享范围;仍有引用的图片将跳过。确认后移入回收站。</p><ul><li v-for="image in cleanupCandidates" :key="image.path"><label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy" />{{ image.path }} <small>{{ size(image.size) }}</small></label></li></ul><p v-if="!cleanupCandidates.length" class="rm-share-note">当前范围没有可清理的孤立图片。</p><div class="rm-cleanup-actions"><button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">取消</button><button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">移入回收站({{ cleanupSelected.length }})</button></div></section></div>
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"><section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"><div class="rm-dialog-head"><h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"><Icon name="close" /></button></div><p class="rm-share-note">服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。</p><ul><li v-for="image in cleanupCandidates" :key="image.path"><label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy" />{{ image.path }} <small>{{ size(image.size) }}</small></label></li></ul><p v-if="!cleanupCandidates.length" class="rm-share-note">当前范围没有可清理的孤立图片。</p><div class="rm-cleanup-actions"><button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">取消</button><button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">移入回收站({{ cleanupSelected.length }})</button></div></section></div>
</section>
</template>
<style scoped>
.rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: 28px; overflow: hidden; }
.rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); margin: 10px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
.rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: var(--rm-space-6); overflow: hidden; }
.rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title)/var(--rm-ui-line-height) var(--rm-font-text); margin: var(--rm-space-2) 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
.rm-media-workspace { display: flex; flex: 1; min-height: 0; border: 1px solid var(--rm-divider); border-radius: 16px; overflow: hidden; background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); }
.rm-media-filters { flex: none; width: 190px; padding: 20px 12px; border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; }
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: 20px; } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); }
.rm-media-filters { flex: none; width: 190px; padding: var(--rm-space-4) var(--rm-space-2); border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: var(--rm-space-2); min-height: var(--rm-control-size); margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; }
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: var(--rm-space-4); } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); }
.rm-media-upload { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--rm-ui-font-size); } .rm-media-upload label select { min-height: var(--rm-control-size); max-width: 100%; min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: var(--rm-ui-font-size); }
.rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { max-width: 100%; font-size: var(--rm-ui-font-size); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; }
.rm-media-inspector { flex: none; width: 230px; padding: 20px; border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; }
.rm-media-inspector { flex: none; width: 230px; padding: var(--rm-space-4); border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; }
.rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); } .rm-media-warning .rm-btn { font-size: var(--rm-ui-font-size); margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; }
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: 20px; }
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: var(--rm-space-4); }
.rm-media-mobile-filters { display: none; }
@media(max-width:1199px) { .rm-media-library { padding: 20px; } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: 14px; } }
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: 18px 12px; } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: 20px; } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } }
@media(max-width:1199px) { .rm-media-library { padding: var(--rm-space-4); } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: var(--rm-space-4); } }
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: var(--rm-space-4); } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+5 -5
View File
@@ -74,12 +74,12 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' })
</section>
</template>
<style scoped>
.rm-knowledge-panel { flex: 1; overflow: auto; padding: 36px 40px; }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 28px 0 20px; } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); }
.rm-knowledge-panel { flex: 1; overflow: auto; padding: var(--rm-space-6); }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--rm-space-4); } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-text); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: var(--rm-space-6) 0 var(--rm-space-4); } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); }
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: var(--rm-ui-font-caption); } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.rm-missing-links { margin-top: 20px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: 18px 12px; border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: 24px 16px; } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: var(--rm-space-3); border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+17 -25
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { indentLess, indentMore, redo, undo } from '@codemirror/commands'
import { redo, undo } from '@codemirror/commands'
import type { EditorView } from '@codemirror/view'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleInline } from '../editor/format'
import Icon from './Icon.vue'
import Icon, { type IconName } from './Icon.vue'
import { settings } from './settings'
import { activeTab, onEditorUpdate, ws, editorView, insertImagesFromPicker } from './workspace'
// 手机键盘上方的 Markdown 工具条:只在触屏设备上、编辑器获得焦点(软键盘弹出)时显示。
// 底部格式栏与选区工具条;触屏设备仅在编辑器获得焦点时显示底栏。
// 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起
// (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click)
const visible = ref(false)
@@ -16,23 +16,23 @@ const floating = ref<{ left: number; top: number } | null>(null)
let unsubscribe: (() => void) | undefined
const coarse = matchMedia('(pointer: coarse)')
const BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown; cls?: string }[] = [
const FORMAT_BUTTONS: { icon: IconName; title: string; run: (v: EditorView) => unknown }[] = [
{ icon: 'task', title: '任务列表', run: toggleBlock('task') },
{ icon: 'quote', title: '引用', run: toggleBlock('quote') },
{ icon: 'link', title: '链接', run: insertLink },
{ icon: 'image', title: '插入图片', run: () => insertImagesFromPicker() },
{ icon: 'undo', title: '撤销', run: undo },
{ icon: 'redo', title: '重做', run: redo },
]
const SELECTION_BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown; cls?: string }[] = [
{ label: 'H', title: '标题', run: cycleHeading, cls: 'bold' },
{ label: 'B', title: '粗体', run: toggleBold, cls: 'bold' },
{ label: 'I', title: '斜体', run: toggleItalic, cls: 'italic' },
{ label: 'S', title: '删除线', run: toggleStrike, cls: 'strike' },
{ label: 'M', title: '高亮', run: toggleInline('==') },
{ label: '•', title: '无序列表', run: toggleBlock('bullet') },
{ label: '1.', title: '有序列表', run: toggleBlock('ordered') },
{ label: '☐', title: '任务列表', run: toggleBlock('task') },
{ label: '❝', title: '引用', run: toggleBlock('quote') },
{ label: '`', title: '行内代码', run: toggleCode, cls: 'mono' },
{ label: '↗', title: '链接', run: insertLink },
{ label: '▧', title: '插入图片', run: () => insertImagesFromPicker() },
{ label: '⇥', title: '缩进', run: indentMore },
{ label: '⇤', title: '取消缩进', run: indentLess },
{ label: '↶', title: '撤销', run: undo },
{ label: '↷', title: '重做', run: redo },
]
function run(fn: (v: EditorView) => unknown) {
@@ -40,10 +40,6 @@ function run(fn: (v: EditorView) => unknown) {
if (v) fn(v)
}
function hideKeyboard() {
editorView()?.contentDOM.blur()
}
// 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断
function sync() {
requestAnimationFrame(async () => {
@@ -89,24 +85,20 @@ watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, settings
<div v-if="visible" class="rm-format-bar" role="toolbar" aria-label="格式">
<div class="rm-format-scroll">
<button
v-for="b in BUTTONS"
v-for="b in FORMAT_BUTTONS"
:key="b.title"
type="button"
tabindex="-1"
:class="b.cls"
:title="b.title"
:aria-label="b.title"
@mousedown.prevent
@click="run(b.run)"
>
{{ b.label }}
<Icon :name="b.icon" class="rm-format-icon" />
<span class="rm-format-label" aria-hidden="true">{{ b.title }}</span>
</button>
</div>
<button type="button" tabindex="-1" v-if="coarse.matches" class="rm-format-hide" title="收起键盘" aria-label="收起键盘" @mousedown.prevent @click="hideKeyboard">
<Icon name="chevron" class="down" />
</button>
</div>
<div ref="floatingToolbar" v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }">
<button v-for="b in BUTTONS.slice(0, 5).concat(BUTTONS.filter(button => button.title === '行内代码' || button.title === '链接'))" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button>
<button v-for="b in SELECTION_BUTTONS" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button>
</div>
</template>
-26
View File
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { metadata } from './metadata'
import { openReference, ws } from './workspace'
const incoming = computed(() => metadata.notes.flatMap(note => note.links.filter(link => link.target === ws.active).map(link => ({ ...link, path: note.path, title: note.title }))))
const note = computed(() => metadata.notes.find(note => note.path === ws.active))
</script>
<template>
<section class="rm-note-relations" aria-label="笔记关联">
<div v-if="note?.tags.length" class="rm-note-tags"><span v-for="tag in note.tags" :key="tag">#{{ tag }}</span></div>
<h3>反向链接 <small>{{ incoming.length }}</small></h3>
<p v-if="metadata.error" role="alert">{{ metadata.error }}</p>
<p v-else-if="!metadata.ready">关系索引准备中…</p>
<button v-for="link in incoming" :key="link.path + ':' + link.line" @click="openReference(link.path, link.line)"><strong>{{ link.title }}</strong><small>{{ link.path }} · 第 {{ link.line }} 行</small></button>
<p v-if="metadata.ready && !incoming.length">暂无其他笔记链接到这里</p>
<p v-for="warning in note?.warnings" :key="warning" role="alert">{{ warning }}</p>
</section>
</template>
<style scoped>
.rm-note-relations { padding: 20px 16px; border-top: 1px solid var(--rm-divider); overflow: auto; max-height: 40%; }
h3 { font-size: var(--rm-ui-font-size); margin: 0 0 12px; } h3 small { color: var(--rm-text-muted); margin-left: 6px; }
p { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); line-height: 1.8; }
button { display: flex; flex-direction: column; gap: 5px; width: 100%; text-align: left; padding: 9px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
button:hover { background: var(--rm-hover); } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-note-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .rm-note-tags span { font-size: var(--rm-ui-font-caption); padding: 4px 7px; border-radius: 5px; background: var(--rm-selection); color: var(--rm-accent); }
</style>
-2
View File
@@ -4,7 +4,6 @@ import type { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import NoteRelations from './NoteRelations.vue'
import { activeTab, editorView, onEditorUpdate, ws } from './workspace'
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
@@ -103,7 +102,6 @@ const minLevel = () => Math.min(...headings.value.map((h) => h.level))
</li>
</ul>
<p v-else class="rm-tree-empty">没有标题</p>
<NoteRelations />
</aside>
</div>
</template>
+1 -1
View File
@@ -47,7 +47,7 @@ function toggle(plugin: InstalledPlugin) {
<button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button>
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" />
</div>
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。分享页不加载外部插件。</p>
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。</p>
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">{{ error || pluginState.error }}</p>
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">尚未安装插件。</p>
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from './Icon.vue'
import { SETTING_ITEMS, type SettingId } from './settingsCatalog'
const props = defineProps<{ id: SettingId; control?: string; resettable?: boolean }>()
defineEmits<{ reset: [] }>()
const item = computed(() => SETTING_ITEMS[props.id])
</script>
<template>
<div :id="'rm-setting-' + id" class="rm-setting" tabindex="-1">
<div class="rm-setting-info">
<component :is="control ? 'label' : 'span'" :for="control" class="rm-setting-label">{{ item.title }}</component>
<p>{{ item.description }}</p>
</div>
<div class="rm-setting-control">
<slot />
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="'重置' + item.title" :aria-label="'重置' + item.title" @click="$emit('reset')"><Icon name="restore" /></button>
</div>
</div>
</template>
+122 -129
View File
@@ -1,31 +1,20 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api'
import { runCommand } from './commands'
import Icon, { type IconName } from './Icon.vue'
import Icon from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography'
import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, TAB_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import SettingRow from './SettingRow.vue'
import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './settingsCatalog'
import ShortcutSettings from './ShortcutSettings.vue'
import SharePanel from './SharePanel.vue'
import TokensPanel from './TokensPanel.vue'
import TrashPanel from './TrashPanel.vue'
import { toast, ws, type SettingsSection } from './workspace'
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
{ id: 'appearance', title: '常规与外观', icon: 'palette' },
{ id: 'editor', title: '编辑器与模板', icon: 'edit' },
{ id: 'plugins', title: '插件', icon: 'command' },
{ id: 'account', title: '账户', icon: 'user' },
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
{ id: 'shares', title: '分享链接', icon: 'link' },
{ id: 'tokens', title: 'API 令牌', icon: 'key' },
{ id: 'trash', title: '回收站', icon: 'trash' },
{ id: 'about', title: '关于', icon: 'info' },
]
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
if (!buildVersion) throw new Error('缺少应用构建版本信息')
@@ -34,11 +23,44 @@ const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.sett
const dailyTemplates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.endsWith('.md')))
const missingDailyTemplate = computed(() => settings.dailyTemplate && !dailyTemplates.value.some(entry => entry.path === settings.dailyTemplate))
const showList = ref(ws.settings === 'index')
const current = computed(() => SECTIONS.find((s) => s.id === section.value)!)
const current = computed(() => SETTINGS_SECTIONS.find((s) => s.id === section.value)!)
const query = ref('')
const searching = computed(() => !!query.value.trim())
const results = computed(() => searchSettings(query.value))
const dialog = ref<HTMLElement>()
const content = ref<HTMLElement>()
const heading = ref<HTMLElement>()
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const preferenceSection = computed(() => ['appearance', 'editor', 'templates', 'shortcuts'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
function open(id: SettingsSection) {
async function open(id: SettingsSection) {
query.value = ''
ws.settings = id
showList.value = false
await nextTick()
content.value!.scrollTop = 0
heading.value!.focus({ preventScroll: true })
}
async function locate(result: SettingSearchResult) {
await open(result.section)
if (!result.id) return
const target = document.getElementById('rm-setting-' + result.id)!
// 设置可能位于 CSS 或排版预览的折叠区内。
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
if (parent instanceof HTMLDetailsElement) parent.open = true
}
if (target instanceof HTMLDetailsElement) target.open = true
await nextTick()
target.focus({ preventScroll: true })
target.scrollIntoView({ block: 'center' })
}
function back() {
query.value = ''
showList.value = true
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
}
function close() {
@@ -46,15 +68,25 @@ function close() {
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !e.isComposing) close()
if (e.isComposing) return
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close() }
if (e.key !== 'Tab') return
const controls = [...dialog.value!.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], summary, [tabindex="0"]')].filter(el => el.getClientRects().length > 0 && !el.closest('[hidden]'))
const first = controls[0], last = controls.at(-1)
if (e.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); last?.focus()
} else if (!e.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); first?.focus()
}
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
onMounted(() => dialog.value!.focus({ preventScroll: true }))
onBeforeUnmount(() => { if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }) })
async function change(patch: Partial<Settings>) {
try {
await updateSettings(patch)
} catch (err) {
if (err instanceof SettingsAppearanceError) { toast(err.message); return }
toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch(error => toast('重新读取设置失败:' + (error instanceof Error ? error.message : '网络错误')))
}
@@ -114,125 +146,86 @@ async function changePassword() {
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="close">
<div class="rm-dialog rm-settings" :class="{ 'show-list': showList }" role="dialog" aria-label="设置">
<nav class="rm-settings-nav">
<div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close">
<div ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey">
<nav class="rm-settings-nav" aria-label="设置分类">
<div class="rm-dialog-head">
<h2>偏好设置中心<small>Personal Preferences Vault</small></h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭" @click="close"><Icon name="close" /></button>
<h2>设置</h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭设置" @click="close"><Icon name="close" /></button>
</div>
<button v-for="s in SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section }" @click="open(s.id)">
<Icon :name="s.icon" />{{ s.title }}
</button>
<div class="rm-settings-nav-foot"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装到桌面</button><small>RawMark · {{ buildVersion }}</small></div>
<label class="rm-settings-search"><Icon name="search" /><input v-model="query" type="search" aria-label="搜索设置" placeholder="搜索设置" /><button v-if="query" class="rm-icon-btn" aria-label="清空设置搜索" @click="query = ''"><Icon name="close" /></button></label>
<div class="rm-settings-categories">
<button v-for="s in SETTINGS_SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section && !searching }" :aria-current="s.id === section && !searching ? 'page' : undefined" @click="open(s.id)"><Icon :name="s.icon" />{{ s.title }}</button>
</div>
<div class="rm-settings-nav-foot"><span>RawMark</span><small>{{ buildVersion }}</small></div>
</nav>
<section class="rm-settings-body">
<div class="rm-dialog-head">
<button class="rm-icon-btn rm-settings-back" title="返回" @click="showList = true"><Icon name="chevron" class="flip" /></button>
<div class="rm-settings-heading"><small>首选项 / {{ current.title }}</small><h2>{{ current.title }}</h2><p>让工作区适合你的写作习惯,修改即时保存。</p></div>
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button>
<button class="rm-icon-btn rm-settings-back" title="返回设置分类" @click="back"><Icon name="chevron" class="flip" /></button>
<h2 ref="heading" tabindex="-1">{{ searching ? '搜索结果' : current.title }}</h2>
<span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || '偏好设置保存在服务器上,所有设备共用'" role="status" aria-live="polite">{{ saveLabel }}</span>
<button class="rm-icon-btn" title="关闭设置" @click="close"><Icon name="close" /></button>
</div>
<div class="rm-settings-content">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<h3 class="rm-settings-group-title">界面显示</h3>
<div class="rm-setting">
<label for="rm-ui-font-size" class="rm-setting-label">界面字号</label>
<div class="rm-ui-font-control">
<div ref="content" class="rm-settings-content">
<div v-if="searching" class="rm-settings-results">
<p class="rm-settings-result-count" role="status">{{ results.length ? `找到 ${results.length} 项设置` : '没有匹配的设置,试试其他关键词。' }}</p>
<button v-for="result in results" :key="result.id || result.section" class="rm-settings-result" @click="locate(result)"><span><strong>{{ result.title }}</strong><small>{{ result.description }}</small></span><small>{{ SETTINGS_SECTIONS.find(s => s.id === result.section)!.title }}</small><Icon name="chevron" /></button>
</div>
<div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
<button class="rm-btn" aria-label="重置界面字号" @click="change({ uiFontSize: UI_FONT_SIZE.default })">重置</button>
</div>
</div>
<p class="rm-share-note">调整菜单、侧栏与按钮文字,不影响正文和代码字号。</p>
<AppearanceSettings />
<div class="rm-setting"><label for="rm-glass-opacity" class="rm-setting-label">磨砂玻璃通透度</label><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><span>{{ Math.round(settings.glassOpacity * 100) }}%</span></div>
<h3 class="rm-settings-group-title">编辑与阅读排版</h3>
<div class="rm-setting"><label for="rm-text-font" class="rm-setting-label">正文主要字体</label><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></div>
<div class="rm-setting"><label for="rm-code-font" class="rm-setting-label">代码字体</label><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></div>
<div class="rm-setting">
<span class="rm-setting-label">编辑器字号</span>
<label class="rm-font-input"><input type="number" step="any" :value="settings.fontSize" aria-label="编辑器字号" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
</div>
<div class="rm-setting">
<span class="rm-setting-label">正文宽度</span>
<div class="rm-segmented" role="radiogroup" aria-label="正文宽度">
<button
v-for="w in CONTENT_WIDTHS"
:key="w.value"
role="radio"
:aria-checked="settings.contentWidth === w.value"
:class="{ on: settings.contentWidth === w.value }"
@click="change({ contentWidth: w.value })"
>
{{ w.label }}
</button>
</div>
</div>
<div class="rm-setting"><label for="rm-line-height" class="rm-setting-label">正文行高</label><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><span>{{ settings.lineHeight.toFixed(2) }}</span></div>
<div class="rm-setting"><label for="rm-paragraph-spacing" class="rm-setting-label">段落间距</label><input id="rm-paragraph-spacing" type="range" min="0" max="40" step="2" :value="settings.paragraphSpacing" @change="change({ paragraphSpacing: Number(($event.target as HTMLInputElement).value) })" /><span>{{ settings.paragraphSpacing }}px</span></div>
<div class="rm-setting"><label for="rm-selection-toolbar" class="rm-setting-label">选中文本浮动工具栏</label><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></div>
<article class="rm-typography-preview"><small>LIVE TYPOGRAPHY PREVIEW</small><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article>
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
</template>
</SettingRow>
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })"><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output></SettingRow>
<AppearanceSettings />
</template>
<template v-else-if="section === 'editor'">
<div class="rm-setting">
<label for="rm-spellcheck" class="rm-setting-label">拼写检查</label>
<input id="rm-spellcheck" type="checkbox" :checked="settings.spellcheck" @change="change({ spellcheck: ($event.target as HTMLInputElement).checked })" />
</div>
<p class="rm-share-note">使用浏览器的拼写检查,检查语言取决于浏览器设置。</p>
<div class="rm-setting">
<label for="rm-tab-size" class="rm-setting-label">制表符宽度</label>
<select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })">
<option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option>
</select>
</div>
<p class="rm-share-note">仅调整 Tab 字符的显示宽度,不改写正文。Tab 键仍按两个空格缩进。</p>
<h3 class="rm-settings-group-title">日记与模板</h3>
<div class="rm-setting"><label for="rm-daily-folder" class="rm-setting-label">日记目录</label><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></div>
<div class="rm-setting"><label for="rm-templates-folder" class="rm-setting-label">模板目录</label><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></div>
<div class="rm-setting"><label for="rm-daily-template" class="rm-setting-label">日记模板</label><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></div>
<p class="rm-share-note">日记使用本地日期命名,已有日记直接打开。模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
</template>
<template v-else-if="section === 'editor'">
<h3 class="rm-settings-group-title">正文与代码</h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })"><label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label></SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"><div class="rm-segmented" role="radiogroup" aria-label="正文宽度"><button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">{{ w.label }}</button></div></SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output></SettingRow>
<SettingRow id="paragraph-spacing" control="rm-paragraph-spacing" resettable @reset="change({ paragraphSpacing: DEFAULTS.paragraphSpacing })"><input id="rm-paragraph-spacing" type="range" min="0" max="40" step="2" :value="settings.paragraphSpacing" @change="change({ paragraphSpacing: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-paragraph-spacing">{{ settings.paragraphSpacing }}px</output></SettingRow>
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1"><summary>排版预览</summary><article class="rm-typography-preview"><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article></details>
<h3 class="rm-settings-group-title">编辑行为</h3>
<SettingRow id="selection-toolbar" control="rm-selection-toolbar"><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></SettingRow>
<SettingRow id="spellcheck" control="rm-spellcheck"><input id="rm-spellcheck" type="checkbox" :checked="settings.spellcheck" @change="change({ spellcheck: ($event.target as HTMLInputElement).checked })" /></SettingRow>
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })"><select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })"><option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option></select></SettingRow>
</template>
<template v-else-if="section === 'account'">
<form class="rm-auth-card rm-settings-form" @submit.prevent="changePassword">
<h3>修改登录密码</h3>
<label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label>
<label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label>
<label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label>
<p v-if="pwError" class="rm-auth-error">{{ pwError }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
</form>
<div class="rm-setting">
<span class="rm-setting-label">退出登录</span>
<button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button>
</div>
</template>
<template v-else-if="section === 'templates'">
<SettingRow id="daily-folder" control="rm-daily-folder"><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></SettingRow>
<SettingRow id="templates-folder" control="rm-templates-folder"><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></SettingRow>
<SettingRow id="daily-template" control="rm-daily-template"><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></SettingRow>
<p class="rm-share-note">模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
</template>
<ShortcutSettings v-else-if="section === 'shortcuts'" />
<template v-else-if="section === 'account'">
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
<h3>修改登录密码</h3>
<p class="rm-share-note">修改后其他设备需要重新登录。</p>
<label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label>
<label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label>
<label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label>
<p v-if="pwError" class="rm-auth-error" role="alert">{{ pwError }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
</form>
<SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow>
</template>
<SharePanel v-else-if="section === 'shares'" :path="null" />
<TokensPanel v-else-if="section === 'tokens'" />
<TrashPanel v-else-if="section === 'trash'" />
<template v-else-if="section === 'about'">
<div class="rm-setting"><h3>RawMark</h3></div>
<p class="rm-share-note">纯 Markdown 笔记编辑器,文件即真源。</p>
<div v-if="installPrompt" class="rm-setting">
<span class="rm-setting-label">安装应用</span>
<button class="rm-btn" @click="installApp">安装 RawMark</button>
</div>
<div class="rm-setting">
<span class="rm-setting-label">构建版本</span>
<code>{{ buildVersion }}</code>
</div>
<div class="rm-setting">
<span class="rm-setting-label">API 文档</span>
<a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a>
</div>
<p class="rm-share-note">在「API 令牌」中创建令牌后,可通过 REST API 读取或写入笔记。</p>
</template>
<ShortcutSettings v-else-if="section === 'shortcuts'" />
<TokensPanel v-else-if="section === 'tokens'" />
<TrashPanel v-else-if="section === 'trash'" />
<template v-else-if="section === 'about'">
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
<SettingRow id="install"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装 RawMark</button><span v-else class="rm-settings-unavailable">当前浏览器暂未提供安装入口</span></SettingRow>
<SettingRow id="openapi"><a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a></SettingRow>
</template>
</div>
</div>
</section>
</div>
-22
View File
@@ -1,22 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import SharePanel from './SharePanel.vue'
const props = defineProps<{ path: string | string[] }>()
const title = computed(() => typeof props.path === 'string' ? basename(props.path) : `${props.path.length} 项内容`)
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${title}”`">
<div class="rm-dialog-head">
<h2>分享“{{ title }}”</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div>
<SharePanel :path="path" />
</div>
</div>
</template>
-105
View File
@@ -1,105 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { copyText } from '../clipboard'
import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue'
import { toast } from './workspace'
// path 为空时列出全部分享,否则管理此文件或文件夹的分享。
const props = defineProps<{ path: string | string[] | null }>()
const EXPIRY_OPTIONS = [
{ label: '永久有效', seconds: 0 },
{ label: '1 天', seconds: 86400 },
{ label: '7 天', seconds: 7 * 86400 },
{ label: '30 天', seconds: 30 * 86400 },
]
const shares = ref<Share[] | null>(null)
const password = ref('')
const expiresIn = ref(0)
const busy = ref(false)
const failed = ref(false)
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
const linkOf = (s: Share) => `${location.origin}/s/${encodeURIComponent(s.token)}`
const expired = (s: Share) => s.expiresAt !== 0 && s.expiresAt <= Date.now()
function errorText(err: unknown) {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
}
async function load() {
try {
const selected = props.path
const all = await api.shares(typeof selected === 'string' ? selected : undefined)
shares.value = Array.isArray(selected) ? all.filter((share) => share.items.length === selected.length && share.items.every((item) => selected.includes(item.path))) : all
} catch {
failed.value = true
}
}
async function create() {
busy.value = true
try {
const s = await api.createShare(props.path!, password.value, expiresIn.value)
shares.value = [s, ...(shares.value ?? [])]
password.value = ''
await copy(s)
} catch (err) {
toast('创建分享失败:' + errorText(err))
} finally {
busy.value = false
}
}
async function copy(s: Share) {
toast((await copyText(linkOf(s))) ? '链接已复制' : '复制失败,请手动复制链接')
}
async function revoke(s: Share) {
if (!confirm('撤销后该链接立即失效,确定撤销?')) return
try {
await api.deleteShare(s.token)
shares.value = shares.value!.filter((x) => x.token !== s.token)
} catch (err) {
toast('撤销失败:' + errorText(err))
}
}
onMounted(load)
</script>
<template>
<div>
<form v-if="path" class="rm-share-form" @submit.prevent="create">
<input v-model="password" type="text" autocomplete="off" placeholder="访问密码(可选)" aria-label="访问密码" />
<select v-model="expiresIn" aria-label="有效期">
<option v-for="o in EXPIRY_OPTIONS" :key="o.seconds" :value="o.seconds">{{ o.label }}</option>
</select>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
</form>
<p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。多选生成一个集合链接,部分主体删除不影响其他项。</p>
<p v-if="failed" class="rm-tree-empty">读取分享失败</p>
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}</p>
<ul v-else class="rm-trash-list">
<li v-for="s in shares ?? []" :key="s.token">
<Icon name="link" />
<div class="rm-trash-info">
<span v-if="!path" class="rm-trash-path">{{ s.items.map((item) => item.path).join('、') }}</span>
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
<span class="rm-trash-time">
{{ fmt.format(s.createdAt) }} 创建
<template v-if="s.hasPassword"> · 有密码</template>
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
<template v-if="s.exists && s.items.some((item) => !item.exists)"> · <span class="rm-share-bad">部分主体已不存在</span></template>
<template v-if="!s.exists"> · <span class="rm-share-bad">文件或文件夹不存在</span></template>
</span>
</div>
<button class="rm-btn" @click="copy(s)">复制链接</button>
<button class="rm-btn rm-btn-danger" @click="revoke(s)">撤销</button>
</li>
</ul>
</div>
</template>
+16 -5
View File
@@ -1,21 +1,31 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { allCommands, commandKeys, formatKeys, shortcutConflict, type Command } from './commands'
import { recordedKeys } from '../keys'
import { loadSettings, settings, updateSettings } from './settings'
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
import { toast } from './workspace'
import Icon from './Icon.vue'
const commands = computed(allCommands)
const query = ref('')
const filteredCommands = computed(() => {
const terms = query.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
return commands.value.filter(command => {
const text = [command.title, command.id, ...commandKeys(command).map(formatKeys)].join(' ').toLocaleLowerCase()
return terms.every(term => text.includes(term))
})
})
const recording = ref<string | null>(null)
const recorder = ref<HTMLInputElement>()
const error = ref('')
const busy = ref(false)
watch(query, () => { recording.value = null; error.value = '' })
async function save(shortcuts: Record<string, string[]>) {
busy.value = true
try { await updateSettings({ shortcuts }) }
catch (cause) {
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return }
toast('保存快捷键失败:' + (cause instanceof Error ? cause.message : String(cause)))
await loadSettings().catch((cause) => toast('重新读取设置失败:' + String(cause)))
} finally { busy.value = false }
@@ -51,11 +61,12 @@ async function reset(command: Command) {
<template>
<div class="rm-shortcut-settings">
<button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button>
<div class="rm-shortcut-toolbar"><input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button></div>
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。</p>
<p class="rm-settings-result-count" role="status">{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}</p>
<p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p>
<div v-for="command in commands" :key="command.id" class="rm-shortcut-row">
<span>{{ command.title }}</span>
<div v-for="command in filteredCommands" :key="command.id" class="rm-shortcut-row">
<span class="rm-shortcut-title">{{ command.title }}<small>{{ command.id }}</small></span>
<div class="rm-shortcut-bindings">
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
<kbd>{{ formatKeys(key) }}</kbd>
+46 -25
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import { commandTitle } from './commands'
@@ -9,15 +9,44 @@ import SearchPanel from './SearchPanel.vue'
import { createFolder, createNote, move, openSearch, ws } from './workspace'
import { sidebarViews } from '../plugins/registry'
import PluginMount from './PluginMount.vue'
import { openDailyNote } from './templates'
const emit = defineEmits<{ logout: [] }>()
const rootDrop = ref(false)
const rootMenu = ref<{ x: number; y: number } | null>(null)
function rootContext(event: MouseEvent) {
const menuElement = ref<HTMLElement>()
async function rootContext(event: MouseEvent) {
if (event.target instanceof Element && event.target.closest('.rm-tree-row')) return
event.preventDefault()
rootMenu.value = { x: Math.max(4, Math.min(event.clientX, innerWidth - 170)), y: Math.max(4, Math.min(event.clientY, innerHeight - 50)) }
const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect()
rootMenu.value = event.type === 'click' ? { x: anchor.left, y: anchor.bottom } : { x: event.clientX, y: event.clientY }
await nextTick()
if (!rootMenu.value || !menuElement.value) return
const { width, height } = menuElement.value.getBoundingClientRect()
rootMenu.value.x = Math.max(4, Math.min(rootMenu.value.x, innerWidth - width - 4))
rootMenu.value.y = Math.max(4, Math.min(rootMenu.value.y, innerHeight - height - 4))
}
watch(rootMenu, (menu, _previous, onCleanup) => {
if (!menu) return
const close = () => { rootMenu.value = null }
const onKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') close() }
window.addEventListener('resize', close)
window.addEventListener('keydown', onKeydown)
onCleanup(() => {
window.removeEventListener('resize', close)
window.removeEventListener('keydown', onKeydown)
})
})
function runRootMenu(action: 'search' | 'tags' | 'note' | 'folder' | 'import' | 'images') {
rootMenu.value = null
if (action === 'search') openSearch()
else if (action === 'tags') { ws.view = 'tags'; ws.images = null; ws.sidebarOpen = false }
else if (action === 'note' || action === 'folder') {
selectedView.value = ''
ws.searchOpen = false
void (action === 'note' ? createNote('') : createFolder(''))
} else if (action === 'import') ws.importing = ''
else { ws.imagesAll = false; ws.images = '' }
}
const selectedView = ref('')
const currentView = computed(() => sidebarViews.get(selectedView.value))
@@ -41,23 +70,9 @@ function onRootDrop(e: DragEvent) {
<template>
<div class="rm-sidebar-inner">
<div class="rm-vault-heading">
<span class="rm-vault-icon"><Icon name="folder" :size="22" /></span>
<div><strong>个人知识库 · Vault</strong><small>Personal Knowledge Vault</small></div>
<button class="rm-icon-btn" title="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
<button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
</div>
<div class="rm-sidebar-head">
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" :title="commandTitle('app.search')" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()"><Icon name="search" /><span>搜索</span></button>
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /><span>新建笔记</span></button>
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /><span>新建目录</span></button>
<button class="rm-icon-btn" title="导入" @click="ws.importing = ''"><Icon name="import" /><span>导入文档</span></button>
</div>
<nav class="rm-knowledge-nav" aria-label="知识工具">
<button :class="{ on: ws.view === 'graph' }" @click="ws.view = 'graph'; ws.sidebarOpen = false"><Icon name="graph" />关系图谱</button>
<button :class="{ on: ws.view === 'tags' }" @click="ws.view = 'tags'; ws.sidebarOpen = false"><Icon name="tag" />标签索引</button>
<button @click="openDailyNote"><Icon name="calendar" />今日日记</button>
<button @click="ws.templateOpen = true"><Icon name="noteAdd" />从模板新建</button>
</nav>
<div class="rm-tree-label">文件目录 <span>· VAULT EXPLORER</span></div>
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">文件目录</button>
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
@@ -70,11 +85,17 @@ function onRootDrop(e: DragEvent) {
</ul>
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
</div>
<template v-if="rootMenu">
<div class="rm-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" />
<div class="rm-menu rm-context-menu" :style="{ left: rootMenu.x + 'px', top: rootMenu.y + 'px' }"><button @click="ws.imagesAll = false; ws.images = ''; rootMenu = null"><Icon name="image" />图片管理</button></div>
</template>
<div class="rm-vault-status"><span class="rm-avatar">RM</span><div><strong>个人工作区</strong><small>Markdown 文件 · 自动保存</small></div></div>
<Teleport v-if="rootMenu" to="body">
<div class="rm-menu-backdrop rm-context-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" />
<div ref="menuElement" class="rm-menu rm-context-menu" :style="{ left: rootMenu.x + 'px', top: rootMenu.y + 'px' }">
<button :title="commandTitle('app.search')" @click="runRootMenu('search')"><Icon name="search" />搜索</button>
<button @click="runRootMenu('tags')"><Icon name="tag" />标签索引</button>
<button @click="runRootMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runRootMenu('folder')"><Icon name="folderAdd" />新建目录</button>
<button @click="runRootMenu('import')"><Icon name="import" />导入文档</button>
<button @click="runRootMenu('images')"><Icon name="image" />图片管理</button>
</div>
</Teleport>
<div class="rm-sidebar-foot">
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
+2 -5
View File
@@ -3,7 +3,6 @@ import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename, inAssetDir, isImage, joinPath } from './paths'
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
import { minimalPaths } from './selection'
import { batchFiles, exportFiles } from './batch'
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
@@ -80,7 +79,7 @@ function commitRename() {
if (target && target !== props.node.path) void move(props.node.path, target)
}
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'share' | 'delete' | 'export') {
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'delete' | 'export') {
menuOpen.value = false
const path = props.node.path
if (action === 'note' || action === 'folder') {
@@ -89,7 +88,6 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
} else if (action === 'import') ws.importing = path
else if (action === 'images') { ws.imagesAll = false; ws.images = path }
else if (action === 'rename') ws.renaming = path
else if (action === 'share') ws.sharing = minimalPaths(selectedPaths)
else if (action === 'move') ws.moving = [...selectedPaths]
else if (action === 'export') void exportFiles([...selectedPaths])
else void batchFiles('delete', [...selectedPaths])
@@ -177,7 +175,7 @@ function onDrop(e: DragEvent) {
/>
<Icon v-if="image" name="image" :size="18" />
<span v-if="!isRenaming" 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 = false) : showMenu()">
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<Icon name="more" />
</button>
</div>
@@ -193,7 +191,6 @@ function onDrop(e: DragEvent) {
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button @click="runMenu('share')"><Icon name="link" />分享</button>
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
+16 -17
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api'
import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue'
import AppHeader from './AppHeader.vue'
import KnowledgePanel from './KnowledgePanel.vue'
import TemplateDialog from './TemplateDialog.vue'
import EditorChrome from './EditorChrome.vue'
@@ -17,21 +16,21 @@ import NoteBanner from './NoteBanner.vue'
import OutlinePanel from './OutlinePanel.vue'
import QuickSwitcher from './QuickSwitcher.vue'
import SettingsDialog from './SettingsDialog.vue'
import ShareDialog from './ShareDialog.vue'
import { noteTitle } from './paths'
import { SIDEBAR_WIDTH, settings, updateSettings } from './settings'
import { SIDEBAR_WIDTH, settings, SettingsAppearanceError, updateSettings } from './settings'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
import { activeTab, flushAll, readyToReload, startWorkspace, stopWorkspace, toast, toggleSourceMode, ws } from './workspace'
import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const saving = computed(() => activeTab()?.dirty)
const resizingSidebar = ref(false)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
function startResize(e: PointerEvent) {
const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId)
resizingSidebar.value = true
const startX = e.clientX
const startWidth = settings.sidebarWidth
let width = startWidth
@@ -42,10 +41,13 @@ function startResize(e: PointerEvent) {
const onUp = () => {
handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp)
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(() => toast('保存侧栏宽度失败'))
handle.removeEventListener('lostpointercapture', onUp)
resizingSidebar.value = false
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(error => toast(error instanceof SettingsAppearanceError ? error.message : '保存侧栏宽度失败'))
}
handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp)
handle.addEventListener('lostpointercapture', onUp)
}
async function logout() {
@@ -100,11 +102,10 @@ onBeforeUnmount(() => {
<template>
<div
class="rm-shell"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'focus-mode': ws.focusMode }"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
>
<AppHeader />
<div class="rm-workspace">
<aside class="rm-sidebar">
<Sidebar @logout="logout" />
@@ -113,13 +114,10 @@ onBeforeUnmount(() => {
<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>
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" /></button>
<button class="rm-mobile-title" title="切换笔记" @click="ws.switcher = ''">{{ title }}</button>
<span class="rm-save-state" :class="{ on: saving }" />
<button v-if="ws.active" class="rm-icon-btn" :title="ws.sourceMode ? '关闭源码编辑' : '开启源码编辑'" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="toggleSourceMode"><Icon name="source" :size="20" /></button>
<button v-if="ws.active" class="rm-icon-btn" title="大纲" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" :size="20" /></button>
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" :size="20" /></button>
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" :size="20" /></button>
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" /></button>
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" /></button>
</header>
<div v-show="ws.view === 'notes' && ws.images === null" class="rm-notes-view">
<TabBar />
@@ -131,12 +129,13 @@ onBeforeUnmount(() => {
<KnowledgePanel v-if="ws.view !== 'notes' && ws.images === null" />
<ImageGallery v-if="ws.images !== null" :dir="ws.images" />
</main>
<OutlinePanel v-if="ws.outlineOpen && ws.active && ws.view === 'notes' && ws.images === null" />
<Transition name="rm-outline-panel">
<OutlinePanel v-if="ws.outlineOpen && !ws.focusMode && ws.active && ws.view === 'notes' && ws.images === null" />
</Transition>
</div>
<TemplateDialog v-if="ws.templateOpen" />
<QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
<ImageDialog v-if="ws.image" :path="ws.image" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
-19
View File
@@ -53,18 +53,6 @@ export interface SearchResult {
snippet: string
}
export interface Share {
items: { id: number; path: string; exists: boolean }[]
token: string
path?: string
createdAt: number
// 0 为永久
expiresAt: number
hasPassword: boolean
// 分享的文件或文件夹是否仍存在
exists: boolean
}
export interface ApiToken {
id: string
name: string
@@ -203,13 +191,6 @@ export const api = {
importImage: async (note: string, url: string): Promise<string> =>
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
shares: async (path?: string) =>
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
// expiresIn 为有效时长(秒),0 为永久
createShare: (path: string | string[], password: string, expiresIn: number) =>
json<Share>(request('POST', '/api/v1/shares', { json: { ...(typeof path === 'string' ? { path } : { paths: path }), password, expiresIn } })),
deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)),
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
// 返回的 token 明文只有这一次
createToken: (name: string, scope: ApiToken['scope']) =>
+202 -194
View File
@@ -94,38 +94,52 @@ button {
.rm-shell { display: flex; flex-direction: column; height: 100%; }
.rm-workspace { display: flex; flex: 1; min-height: 0; }
.rm-sidebar {
font-size: var(--rm-ui-font-size);
color: var(--rm-text-muted);
position: relative;
box-sizing: border-box;
width: var(--rm-sidebar-width);
flex: none;
overflow: clip;
background: color-mix(in srgb, var(--rm-sidebar-bg) 78%, transparent);
backdrop-filter: blur(18px) saturate(150%);
border-right: 1px solid var(--rm-divider);
transition: width var(--rm-panel-duration) var(--rm-panel-easing), border-width var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s;
}
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-sidebar-resize { position: absolute; top: 0; right: -3px; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; }
.rm-sidebar-resize { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; }
.rm-sidebar-resize:hover,
.rm-sidebar-resize:active { background: var(--rm-accent); opacity: 0.4; }
.sidebar-collapsed .rm-sidebar { display: none; }
.sidebar-collapsed .rm-sidebar,
.focus-mode .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); }
.sidebar-resizing .rm-sidebar { transition: none; }
/* ---- 大纲 ---- */
.rm-outline-backdrop { display: contents; }
.rm-outline { flex: none; width: 240px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 8px 6px 16px; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); }
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 0 6px 12px; }
.rm-outline-list li { padding-top: 5px; padding-bottom: 5px; padding-right: 8px; border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: 1.4; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-outline-backdrop { display: flex; flex: none; width: 256px; overflow: clip; }
.rm-outline { box-sizing: border-box; flex: none; width: 256px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); color: var(--rm-text-muted); }
.rm-outline-panel-enter-active,
.rm-outline-panel-leave-active { transition: width var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-outline-panel-enter-from,
.rm-outline-panel-leave-to { width: 0; opacity: 0; }
.rm-outline-panel-leave-active { pointer-events: none; }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-1) var(--rm-space-3); font-size: var(--rm-ui-font-size); font-weight: 500; color: var(--rm-text-muted); }
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: var(--rm-space-1) var(--rm-space-2); }
.rm-outline-list li { box-sizing: border-box; height: var(--rm-control-compact); padding: 0 var(--rm-space-2); border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: var(--rm-control-compact); cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-outline-list li:hover { background: var(--rm-hover); }
.rm-outline-list li.on { color: var(--rm-accent-strong); background: var(--rm-active); }
.rm-outline-list li.on { color: var(--rm-text); background: var(--rm-active); font-weight: 500; }
.rm-backdrop,
.rm-mobile-bar { display: none; }
/* ---- 侧栏 ---- */
.rm-sidebar-inner { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.rm-sidebar-head { display: flex; align-items: center; gap: 2px; padding: 12px 10px 8px 16px; }
.rm-sidebar-inner { box-sizing: border-box; display: flex; flex-direction: column; width: calc(var(--rm-sidebar-width) - 1px); height: 100%; overflow-y: auto; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing); }
.sidebar-collapsed .rm-sidebar-inner,
.focus-mode .rm-sidebar-inner { opacity: 0; transform: translateX(-12px); }
.rm-brand { flex: 1; font-weight: 600; font-size: var(--rm-ui-font-size); color: var(--rm-accent-strong); }
.rm-tree { flex: 1; min-height: 100px; 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 {
--rm-tree-row-overlay: transparent;
position: relative;
display: flex;
align-items: center;
@@ -133,14 +147,14 @@ button {
height: var(--rm-control-size);
padding-right: 4px;
border-radius: 6px;
font-size: var(--rm-ui-font-size);
font-size: inherit;
cursor: pointer;
user-select: none;
}
.rm-tree-row:hover { background: var(--rm-hover); }
.rm-tree-row:hover { --rm-tree-row-overlay: var(--rm-hover); background: var(--rm-hover); }
/* 长按由应用处理,不弹出 iOS 的系统菜单 */
.rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; }
.rm-tree-chevron.open { transform: rotate(90deg); }
@@ -158,12 +172,18 @@ button {
font: inherit;
outline: none;
}
.rm-tree-more { width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; }
.rm-tree-more { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); z-index: 1; width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; pointer-events: none; }
/* 按钮覆盖名称末尾,不为隐藏按钮留位;不透明底色遮住下方文字。 */
.rm-tree-row .rm-tree-more { background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg); }
.rm-tree-row:hover .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; }
.rm-tree-row:focus-within .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; pointer-events: auto; }
@media (pointer: coarse), (hover: none) {
.rm-tree-more { position: static; transform: none; opacity: 1; pointer-events: auto; width: max(44px, var(--rm-control-compact)); height: max(44px, var(--rm-control-size)); }
.rm-tree-row .rm-tree-more { background: none; }
}
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); text-align: center; }
/* ---- 搜索 ---- */
.rm-sidebar-head .rm-icon-btn.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.rm-search-box {
display: flex;
@@ -185,7 +205,6 @@ button {
background: none;
color: var(--rm-text);
font: inherit;
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-search-input::-webkit-search-cancel-button { display: none; }
@@ -193,7 +212,7 @@ button {
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.rm-search-item.active { background: var(--rm-hover); }
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: var(--rm-ui-font-size); overflow: hidden; white-space: nowrap; }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: inherit; overflow: hidden; white-space: nowrap; }
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-search-snippet {
margin-top: 2px;
@@ -370,9 +389,8 @@ button {
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 { display: flex; align-items: center; padding: var(--rm-space-3) var(--rm-space-4) var(--rm-space-2); }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: var(--rm-ui-font-size); }
.rm-share-dialog { max-width: 640px; }
.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; }
.rm-share-form input,
.rm-share-form select {
@@ -390,51 +408,127 @@ button {
.rm-share-form select:focus { border-color: var(--rm-accent); }
.rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
.rm-share-note a { color: var(--rm-accent-strong); }
.rm-share-link { font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-bad { color: var(--rm-danger); }
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: var(--rm-ui-font-small); }
.rm-token-created p { margin: 0 0 6px; }
.rm-token-value { display: flex; align-items: center; gap: 8px; }
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); user-select: all; }
/* ---- 手机键盘上方的格式工具条 ---- */
.rm-format-bar { flex: none; display: flex; height: var(--rm-control-size); border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-scroll { flex: 1; display: flex; overflow-x: auto; scrollbar-width: none; }
.rm-format-scroll::-webkit-scrollbar { display: none; }
.rm-format-bar button { flex: none; min-width: var(--rm-control-compact); height: var(--rm-control-size); padding: 0 6px; border: 0; background: none; color: var(--rm-text); font-size: var(--rm-ui-font-size); touch-action: manipulation; }
/* ---- 底部格式工具条 ---- */
.rm-format-bar { flex: none; display: flex; justify-content: center; height: var(--rm-control-size); border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-scroll { display: flex; align-items: center; }
.rm-format-bar button { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; width: var(--rm-control-compact); height: var(--rm-control-size); padding: 0; border: 0; border-radius: 5px; background: none; color: var(--rm-text); cursor: pointer; touch-action: manipulation; }
.rm-format-bar button:active { background: var(--rm-hover); }
.rm-format-bar .bold { font-weight: 700; }
.rm-format-bar .italic { font-style: italic; font-family: Georgia, serif; }
.rm-format-bar .strike { text-decoration: line-through; }
.rm-format-bar .mono { font-family: var(--rm-font-mono); }
.rm-format-hide { border-left: 1px solid var(--rm-divider) !important; }
.down { transform: rotate(90deg); }
.rm-format-icon { transform-origin: center bottom; transition: transform 160ms ease; }
.rm-format-label { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translate(-50%, 4px); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-small); line-height: 1.5; white-space: nowrap; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease, visibility 160ms; }
@media (hover: hover) and (pointer: fine) {
.rm-format-bar button:hover .rm-format-icon { transform: translateY(-4px) scale(1.3); }
.rm-format-bar button:hover .rm-format-label { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
}
.rm-format-bar button:focus-visible .rm-format-icon { transform: translateY(-4px) scale(1.3); }
.rm-format-bar button:focus-visible .rm-format-label { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
.rm-format-icon, .rm-format-label { transition: none; }
.rm-format-bar button:is(:hover, :focus-visible) .rm-format-icon { transform: none; }
}
/* ---- 设置 ---- */
.rm-settings { flex-direction: row; max-width: 820px; height: min(640px, 85vh); max-height: none; overflow: hidden; }
.rm-settings-nav { flex: none; width: 190px; display: flex; flex-direction: column; gap: 2px; padding: 0 8px 12px; border-right: 1px solid var(--rm-divider); overflow-y: auto; }
.rm-settings-nav .rm-dialog-head { padding-left: 12px; }
.rm-settings-close-list,
.rm-settings-back { display: none; }
.rm-settings-tab { display: flex; align-items: center; gap: 10px; height: var(--rm-control-size); padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--rm-text); font: inherit; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; }
.rm-settings-tab:hover { background: var(--rm-hover); }
.rm-settings-tab.on { background: var(--rm-selection); color: var(--rm-accent-strong); font-weight: 600; }
.rm-settings-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-settings-content { flex: 1; overflow-y: auto; padding-bottom: 16px; }
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; }
.rm-setting-label { font-size: var(--rm-ui-font-size); }
.rm-setting select { height: var(--rm-control-size); padding: 0 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font-size: var(--rm-ui-font-size); }
.rm-segmented { display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 8px; overflow: hidden; }
.rm-segmented button { flex: 1; height: var(--rm-control-size); padding: 0 14px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; font-size: var(--rm-ui-font-size); cursor: pointer; }
.rm-setting select { min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; line-height: 1.4; }
.rm-segmented { flex: none; display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 6px; overflow: hidden; }
.rm-segmented button { flex: 1 0 auto; white-space: nowrap; min-height: var(--rm-control-size); padding: 4px 10px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; cursor: pointer; }
.rm-segmented button:first-child { border-left: 0; }
.rm-segmented button.on { background: var(--rm-accent); color: #fff; }
.rm-settings-form { max-width: 360px; padding: 4px 20px 16px; }
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
.rm-shortcuts { width: calc(100% - 40px); margin: 0 20px; border-collapse: collapse; font-size: var(--rm-ui-font-size); }
.rm-shortcuts td { padding: 8px 0; border-bottom: 1px solid var(--rm-divider); }
.rm-shortcuts td:last-child { text-align: right; }
.rm-shortcuts kbd { font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); padding: 2px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-inline-code-bg); }
.rm-segmented button.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.flip { transform: rotate(180deg); }
.rm-dialog-backdrop.rm-settings-backdrop { padding: 24px; background: color-mix(in srgb, var(--rm-bg) 30%, transparent); backdrop-filter: blur(4px); }
.rm-dialog.rm-settings { box-sizing: border-box; flex-direction: row; width: 100%; max-width: 960px; height: min(720px, 100%); max-height: 100%; overflow: hidden; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(20px); outline: none; }
.rm-settings-nav { box-sizing: border-box; flex: none; width: 192px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 70%, transparent); }
.rm-settings .rm-dialog-head { flex: none; box-sizing: border-box; min-height: 52px; gap: 8px; padding: 8px 16px; }
.rm-settings .rm-dialog-head h2 { font-size: var(--rm-ui-font-size); font-weight: 600; min-width: 0; outline: none; }
.rm-settings-close-list, .rm-settings-back { display: none; }
.rm-settings-search { display: flex; align-items: center; gap: 4px; min-width: 0; min-height: var(--rm-control-size); margin: 0 8px 8px; padding: 0 6px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); }
.rm-settings-search > .rm-icon { flex: none; width: 16px; height: 16px; color: var(--rm-text-muted); }
.rm-settings-search input { box-sizing: border-box; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 4px 0; border: 0; outline: 0; background: none; color: var(--rm-text); font: inherit; }
.rm-settings-search input::-webkit-search-cancel-button { display: none; }
.rm-settings-search:focus-within { border-color: var(--rm-accent); }
.rm-settings-categories { min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
.rm-settings-tab { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; min-height: var(--rm-control-size); margin: 2px 0; padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); font: inherit; text-align: left; cursor: pointer; }
.rm-settings-tab .rm-icon { flex: none; }
.rm-settings-tab:hover { background: var(--rm-hover); }
.rm-settings-tab.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-settings-nav-foot { margin-top: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-nav-foot small { font-size: inherit; overflow-wrap: anywhere; }
.rm-settings-body { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.rm-settings-body > .rm-dialog-head { border-bottom: 1px solid var(--rm-divider); }
.rm-settings-save { flex: none; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-save.error { color: var(--rm-danger); }
.rm-settings-content { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; padding: 0 24px 16px; }
.rm-settings-page { min-width: 0; }
.rm-settings .rm-setting { flex-wrap: wrap; gap: 12px 16px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); }
.rm-setting-info { flex: 1 1 180px; min-width: 0; }
.rm-setting-info p { margin: 4px 0 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: var(--rm-ui-line-height); overflow-wrap: anywhere; }
.rm-setting-control { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; max-width: 100%; min-width: 0; margin-left: auto; }
.rm-settings .rm-setting input:not([type=checkbox],[type=range]), .rm-settings .rm-setting select { box-sizing: border-box; max-width: 260px; min-width: 0; min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings .rm-setting-control > input:not([type=checkbox],[type=range]) { width: 240px; max-width: 100%; }
.rm-settings .rm-setting select { max-width: min(260px, 100%); }
.rm-settings .rm-setting input[type=range] { width: 120px; min-width: 0; max-width: 100%; margin: 0; accent-color: var(--rm-accent); }
.rm-settings input[type=checkbox] { accent-color: var(--rm-accent); }
.rm-setting-control output { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
.rm-setting-control code { overflow-wrap: anywhere; }
.rm-setting-reset { flex: none; color: var(--rm-text-muted); }
.rm-settings-group-title { margin: 20px 0 0; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); }
.rm-settings-group-title:first-child { margin-top: 16px; }
.rm-settings-details { margin: 12px 0; }
.rm-settings-details > summary { padding: 8px 0; min-height: var(--rm-control-size); box-sizing: border-box; cursor: pointer; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
.rm-settings :is(.rm-setting, .rm-settings-details, .rm-settings-form):focus { outline: 2px solid var(--rm-accent); outline-offset: -2px; border-radius: 6px; }
.rm-typography-preview { margin: 8px 0 0; padding: 16px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-sidebar-bg); font: var(--rm-font-size)/var(--rm-line-height) var(--rm-font-editor, var(--rm-font-text)); overflow-wrap: anywhere; }
.rm-typography-preview h2 { margin-top: 0; font-family: var(--rm-font-heading); font-size: 1.5em; }
.rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); }
.rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .9em; color: var(--rm-inline-code-text); }
.rm-settings .rm-share-note, .rm-settings .rm-appearance-note { margin: 12px 0; overflow-wrap: anywhere; }
.rm-settings .rm-share-form { flex-wrap: wrap; padding: 16px 0 0; }
.rm-settings .rm-share-form input { flex-basis: 180px; }
.rm-settings .rm-trash-list { padding: 0; }
.rm-settings .rm-trash-list li { flex-wrap: wrap; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); border-radius: 0; }
.rm-settings .rm-trash-info { flex-basis: 180px; }
.rm-settings .rm-token-created { margin: 12px 0; }
.rm-settings :is(.rm-snippet-row, .rm-plugin-row) { padding: 12px 0; flex-wrap: wrap; }
.rm-settings .rm-snippet-form { padding: 12px 0; }
.rm-settings .rm-plugin-view { padding: 12px 0; }
.rm-settings .rm-settings-form { box-sizing: border-box; max-width: 400px; padding: 16px 0; border: 0; border-radius: 0; background: none; box-shadow: none; }
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
.rm-settings-form .rm-share-note { margin: 0; }
.rm-settings-unavailable { max-width: 240px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-result-count { margin: 12px 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-result { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; text-align: left; cursor: pointer; }
.rm-settings-result:hover { background: var(--rm-hover); }
.rm-settings-result > span { flex: 1; min-width: 0; }
.rm-settings-result strong { font-weight: 500; }
.rm-settings-result small { display: block; margin-top: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-result > .rm-icon { flex: none; color: var(--rm-text-muted); }
@media (max-width: 767px) {
.rm-dialog-backdrop.rm-settings-backdrop { padding: 0; }
.rm-dialog.rm-settings { height: 100%; max-width: none; border: 0; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--rm-bg); }
.rm-settings-nav { display: none; width: 100%; border-right: 0; }
.rm-settings.show-list .rm-settings-nav { display: flex; }
.rm-settings.show-list .rm-settings-body { display: none; }
.rm-settings.show-list.searching { flex-direction: column; }
.rm-settings.show-list.searching .rm-settings-body { display: flex; }
.rm-settings.show-list.searching :is(.rm-settings-categories, .rm-settings-nav-foot, .rm-settings-body > .rm-dialog-head) { display: none; }
.rm-settings.show-list:not(.searching) .rm-settings-nav { flex: 1; }
.rm-settings .rm-dialog-head { padding: 8px 12px; }
.rm-settings-close-list, .rm-settings-back { display: inline-flex; }
.rm-settings-content { padding: 0 16px 16px; }
.rm-settings-search { min-height: 44px; }
.rm-settings-tab { min-height: 44px; }
.rm-settings-save { max-width: 6em; }
.rm-settings .rm-setting-info { flex-basis: 100%; }
.rm-settings .rm-setting-control { width: 100%; max-width: 100%; }
.rm-setting-control > select { flex: 1 1 0; }
.rm-settings input:not([type=checkbox],[type=radio],[type=range]), .rm-settings select, .rm-settings textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
.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); }
@@ -460,28 +554,24 @@ button {
/* ---- 移动端 ---- */
@media (max-width: 767px) {
/* 设置在手机上全屏,一次只显示分组列表或分组内容 */
.rm-dialog-backdrop:has(.rm-settings) { padding: 0; }
.rm-settings { max-width: none; height: 100%; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); box-sizing: border-box; }
.rm-settings-nav { width: auto; flex: 1; border-right: 0; display: none; }
.rm-settings.show-list .rm-settings-nav { display: flex; }
.rm-settings.show-list .rm-settings-body { display: none; }
.rm-settings-tab { height: 48px; font-size: var(--rm-ui-font-size); }
.rm-settings-close-list,
.rm-settings-back { display: inline-flex; width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-setting { flex-wrap: wrap; }
.rm-sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 40;
width: min(84vw, 320px);
transform: translateX(-100%);
transition: transform 0.22s ease;
visibility: hidden;
pointer-events: none;
transition: transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s var(--rm-panel-duration);
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; }
.sidebar-collapsed .rm-sidebar,
.focus-mode .rm-sidebar { width: min(84vw, 320px); border-right-width: 1px; transition-delay: 0s, var(--rm-panel-duration); }
.rm-sidebar-inner { width: 100%; }
.sidebar-collapsed .rm-sidebar-inner { opacity: 1; transform: none; }
.sidebar-open:not(.focus-mode) .rm-sidebar { transform: none; visibility: visible; pointer-events: auto; transition-delay: 0s; 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 var(--rm-panel-duration) var(--rm-panel-easing); display: block; }
.sidebar-open:not(.focus-mode) .rm-backdrop { opacity: 1; pointer-events: auto; }
.rm-tabs { display: none; }
.rm-mobile-bar {
display: flex;
@@ -494,18 +584,12 @@ button {
}
.rm-mobile-bar .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-mobile-title { flex: 1; min-width: 0; height: var(--rm-control-size); padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-collapsed .rm-sidebar { display: block; }
.rm-sidebar-resize { display: none; }
/* 大纲在手机上为从右侧滑入的浮层 */
.rm-outline-backdrop { display: block; position: fixed; inset: 0; z-index: 41; background: rgba(0, 0, 0, 0.3); }
.rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(0, 0, 0, 0.3); }
.rm-outline { position: absolute; top: 0; right: 0; bottom: 0; width: min(80vw, 300px); padding-top: env(safe-area-inset-top); box-shadow: var(--rm-shadow); }
.rm-outline-list li { padding-top: 12px; padding-bottom: 12px; font-size: var(--rm-ui-font-size); }
.rm-save-state { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: transparent; }
.rm-save-state.on { background: var(--rm-accent); }
/* 触控热区不小于 44px */
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-tree-more { opacity: 1; width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-sidebar-head .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-auth-card input,
@@ -522,7 +606,7 @@ button {
}
/* 主题与 CSS 编辑 */
.rm-appearance-note { margin: 8px 20px 16px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-appearance-note { margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4); font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; }
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--rm-ui-font-size); }
.rm-snippet-row input { accent-color: var(--rm-accent); flex: none; }
@@ -543,72 +627,45 @@ button {
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } }
.rm-ui-font-control { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rm-font-input { display: inline-flex; align-items: center; gap: 6px; }
.rm-font-input input { min-height: var(--rm-control-size); box-sizing: border-box; font: inherit; width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
.rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); }
.rm-shortcut-settings { padding: 12px 20px; }
.rm-shortcut-settings { padding: 16px 0 0; }
.rm-shortcut-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rm-shortcut-toolbar input { box-sizing: border-box; flex: 1 1 180px; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 6px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; color: var(--rm-text); background: var(--rm-bg); }
.rm-shortcut-title { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
.rm-shortcut-title small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); margin-top: 4px; }
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: var(--rm-ui-font-size); }
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; max-width: 100%; min-width: 0; margin-left: auto; }
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
.rm-context-menu { position: fixed; right: auto; width: max-content; }
.rm-context-menu { position: fixed; right: auto; z-index: 45; width: max-content; min-width: 0; max-width: calc(100vw - 8px); max-height: calc(100dvh - 8px); box-sizing: border-box; overflow: auto; }
.rm-context-menu button { flex: none; white-space: nowrap; }
.rm-context-menu-backdrop { z-index: 44; }
.rm-tree-row.selected { background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
.rm-tree-row.selected { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); }
/* 轻玻璃工作台 */
.rm-global-header { height: var(--rm-header-height); flex: none; display: flex; align-items: center; gap: 18px; padding: 0 24px; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); z-index: 10; }
.rm-wordmark { flex: none; display: flex; align-items: center; gap: 10px; background: none; border: 0; padding: 0; font-family: var(--rm-font-heading); font-size: var(--rm-ui-font-title); font-weight: 600; cursor: pointer; }
.rm-wordmark svg { color: var(--rm-accent); }
.rm-edition { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); padding: 4px 8px; border-radius: 5px; background: var(--rm-sidebar-bg); }
.rm-primary-nav { flex: none; display: flex; align-self: stretch; gap: 18px; margin-left: 12px; }
.rm-primary-nav button { display: flex; align-items: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: none; font-size: var(--rm-ui-font-size); cursor: pointer; padding: 0 3px; }
.rm-primary-nav .on { color: var(--rm-accent); border-bottom-color: var(--rm-accent); }
.rm-global-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 10px; flex: 1; max-width: 330px; min-width: 0; margin-left: auto; padding: 8px 12px; color: var(--rm-text-muted); border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); border-radius: 8px; text-align: left; font-size: var(--rm-ui-font-size); cursor: pointer; }
.rm-global-search > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-global-search kbd { flex: none; font: inherit; font-size: var(--rm-ui-font-caption); margin-left: auto; opacity: .7; white-space: nowrap; }
.rm-header-actions { flex: none; display: flex; align-items: center; gap: 10px; }
.rm-header-actions .rm-btn { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-avatar { display: inline-flex; align-items: center; justify-content: center; width: var(--rm-control-size); height: var(--rm-control-size); flex: none; border-radius: 50%; background: var(--rm-selection); color: var(--rm-accent-strong); font-size: var(--rm-ui-font-caption); font-weight: 700; }
.rm-vault-heading { display: flex; align-items: center; gap: 10px; padding: 20px 16px 10px; }
.rm-vault-heading > div { flex: 1; min-width: 0; }
.rm-vault-heading strong, .rm-vault-status strong { display: block; font-size: var(--rm-ui-font-size); font-weight: 600; }
.rm-vault-heading small, .rm-vault-status small { display: block; margin-top: 4px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); }
.rm-vault-icon { display: grid; flex: none; place-items: center; width: var(--rm-control-size); height: var(--rm-control-size); border-radius: 8px; background: var(--rm-active); color: var(--rm-accent); }
.rm-sidebar-head { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 14px 12px 22px; border-bottom: 1px solid var(--rm-divider); }
.rm-sidebar-head .rm-icon-btn { min-width: 0; width: 100%; min-height: var(--rm-control-size); height: auto; padding: 10px 6px; flex-direction: row; justify-content: flex-start; gap: 8px; font-size: var(--rm-ui-font-size); }
.rm-tree-label { padding: 22px 16px 12px; font-size: var(--rm-ui-font-small); font-weight: 600; color: var(--rm-text-muted); }
.rm-tree-label span { font-size: var(--rm-ui-font-caption); margin-left: 4px; opacity: .7; }
.rm-tree { padding: 0 10px 16px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); gap: 6px; }
.rm-tree-row.active { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-vault-status { display: flex; gap: 10px; align-items: center; padding: 14px 16px 4px; border-top: 1px solid var(--rm-divider); }
.rm-sidebar-foot { border: 0; padding: 8px 10px 12px; }
.rm-foot-btn { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-outline { width: 256px; background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); }
.rm-outline-head { padding: 18px 14px; font-size: var(--rm-ui-font-size); }
.rm-outline-list { padding: 8px 12px; }
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: var(--rm-ui-font-size); }
.rm-outline-list li.on { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-tabs { height: auto; min-height: calc(var(--rm-control-size) + 4px); padding: 5px 12px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { min-width: 0; height: var(--rm-control-size); max-width: 240px; font-size: var(--rm-ui-font-size); border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
.rm-tree-label { flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: left; cursor: pointer; }
.rm-tree-label:hover { background: var(--rm-hover); }
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
.rm-sidebar-foot { border: 0; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-2); }
.rm-foot-btn { height: var(--rm-control-size); font-size: inherit; }
.rm-tabs { height: auto; min-height: 31px; padding: 2px 6px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { box-sizing: border-box; min-width: 84px; height: max(31px, calc(var(--rm-ui-font-size) + 15px)); max-width: 196px; padding: 0 3px 0 8px; font-size: var(--rm-ui-font-size); font-weight: 500; border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); }
.rm-tab-add { margin: 4px; }
.rm-editor-chrome { flex: none; padding: 14px 28px 16px; border-bottom: 1px solid var(--rm-divider); }
.rm-breadcrumb { min-width: 0; display: flex; align-items: center; gap: 10px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow: hidden; white-space: nowrap; }
.rm-breadcrumb .current { color: var(--rm-accent); overflow: hidden; text-overflow: ellipsis; }
.rm-crumb-separator { opacity: .4; }
.rm-document-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); margin-top: 10px; }
.rm-document-meta strong { font-weight: 500; color: var(--rm-text); }
.rm-document-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 18px; }
.rm-mode-switch { flex: none; display: inline-flex; padding: 3px; border-radius: 9px; background: var(--rm-sidebar-bg); }
.rm-mode-switch button { min-height: var(--rm-control-size); display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: var(--rm-ui-font-size); border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-mode-switch .on { background: var(--rm-active); color: var(--rm-accent-strong); box-shadow: 0 1px 4px rgba(19,27,46,.06); }
.rm-document-filename { margin-left: auto; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.focus-mode .rm-sidebar, .focus-mode .rm-outline-backdrop { display: none; }
.rm-tab-add { margin: 0 3px; align-self: center; }
.rm-tab-close { width: max(24px, calc(var(--rm-ui-font-size) + 10px)); height: max(24px, calc(var(--rm-ui-font-size) + 10px)); }
@media (pointer: coarse) {
.rm-tab { height: max(48px, calc(var(--rm-ui-font-size) + 26px)); }
.rm-tab-close { width: var(--rm-control-compact); height: var(--rm-control-compact); }
}
.rm-editor-chrome { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 2px; padding: 2px 6px; border-bottom: 1px solid var(--rm-divider); }
.rm-editor-chrome .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-compact); }
.rm-btn { border-radius: 8px; background: color-mix(in srgb, var(--rm-bg) 80%, transparent); }
.rm-btn-primary { background: var(--rm-accent); box-shadow: 0 2px 5px var(--rm-selection); }
.rm-auth-card { box-sizing: border-box; gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); }
@@ -616,87 +673,38 @@ button {
.rm-dialog { border-radius: 20px; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) 96%, transparent); }
.rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); border-radius: 14px; padding: 7px; }
.rm-menu button { min-height: var(--rm-control-size); }
@media (max-width: 1399px) {
.rm-global-search kbd { display: none; }
}
@media (min-width: 768px) and (max-width: 1199px) {
.rm-global-header { gap: 12px; padding: 0 16px; }
.rm-edition { display: none; }
.rm-outline-backdrop { display: block; position: fixed; inset: var(--rm-header-height) 0 0; z-index: 41; background: rgba(19,27,46,.12); }
.rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(19,27,46,.12); }
.rm-outline { position: absolute; right: 0; top: 0; bottom: 0; box-shadow: var(--rm-shadow); }
}
@media (max-width: 767px) {
.rm-global-header { display: none; }
.rm-editor-chrome { padding: 12px 16px; }
.rm-breadcrumb, .rm-document-actions { display: none; }
.rm-document-meta { font-size: var(--rm-ui-font-small); margin: 0; }
.rm-document-meta > :nth-child(3) { display: none; }
.rm-tree-label { padding-top: 16px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
.rm-outline { width: min(84vw, 320px); }
.rm-auth-card { padding: 28px 24px; }
}
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
.rm-knowledge-nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--rm-ui-font-size) * 5 + 48px)), 1fr)); gap: 4px; padding: 14px 12px 0; }
.rm-knowledge-nav button { min-height: var(--rm-control-size); overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; padding: 9px 8px; font-size: var(--rm-ui-font-size); border: 0; border-radius: 7px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-knowledge-nav button:hover, .rm-knowledge-nav .on { background: var(--rm-active); color: var(--rm-accent); }
/* 偏好设置沿用即时保存,在主画布中展开。 */
.rm-dialog-backdrop:has(.rm-settings) { top: var(--rm-header-height); padding: 28px; background: radial-gradient(ellipse at 70% 10%, var(--rm-selection), transparent 65%), var(--rm-bg); backdrop-filter: none; }
.rm-settings { max-width: 1250px; width: 100%; height: 100%; background: none; border: 0; border-radius: 0; box-shadow: none; gap: 28px; }
.rm-settings-nav { width: 228px; border: 1px solid var(--rm-divider); border-radius: 16px; padding: 18px 12px; background: color-mix(in srgb, var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); }
.rm-settings-nav .rm-dialog-head { padding: 8px 10px 24px; border-bottom: 1px solid var(--rm-divider); margin-bottom: 16px; }
.rm-settings-nav h2 { font-size: var(--rm-ui-font-size); } .rm-settings-nav h2 small { display: block; font: var(--rm-ui-font-caption) var(--rm-font-text); color: var(--rm-text-muted); margin-top: 6px; }
.rm-settings-tab { flex: none; font-size: var(--rm-ui-font-size); min-height: var(--rm-control-size); color: var(--rm-text-muted); }
.rm-settings-tab.on { background: var(--rm-active); box-shadow: inset 3px 0 var(--rm-accent); }
.rm-settings-nav-foot { margin-top: auto; display: grid; gap: 12px; padding: 24px 10px 0; } .rm-settings-nav-foot small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-body > .rm-dialog-head { padding: 6px 2px 24px; align-items: flex-start; }
.rm-settings-heading small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-settings-heading h2 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); margin: 12px 0; } .rm-settings-heading p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); margin: 0; }
.rm-settings-content { border-radius: 16px; border: 1px solid var(--rm-divider); padding: 20px 8px; background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); box-shadow: 0 3px 18px rgba(19,27,46,.025); }
.rm-setting { padding: 13px 20px; font-size: var(--rm-ui-font-size); } .rm-setting-label { font-size: var(--rm-ui-font-size); } .rm-setting input[type=range] { width: 145px; accent-color: var(--rm-accent); }
.rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { max-width: 260px; min-width: 0; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings-group-title { font: 600 var(--rm-ui-font-heading) var(--rm-font-heading); border-top: 1px solid var(--rm-divider); margin: 26px 20px 10px; padding-top: 24px; }
.rm-theme-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 6px 20px 20px; }
.rm-theme-cards button { display: flex; flex-direction: column; gap: 8px; text-align: left; padding: 12px; border: 1px solid var(--rm-divider); border-radius: 12px; background: var(--rm-active); cursor: pointer; min-width: 0; }
.rm-theme-cards .on { outline: 2px solid var(--rm-accent); outline-offset: -1px; }
.rm-theme-cards strong { font-size: var(--rm-ui-font-size); font-weight: 600; } .rm-theme-cards small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); }
.rm-theme-sample { width: 100%; height: 60px; border-radius: 7px; background: #f2f3ff; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 12px; box-sizing: border-box; }
.rm-theme-sample i { width: 65%; height: 3px; background: #c7c4d8; border-radius: 4px; } .rm-theme-sample i:first-child { width: 45%; height: 5px; background: #4f46e5; } .dark .rm-theme-sample { background: #1e2022; } .dark .rm-theme-sample i:first-child { background: #42d392; } .system .rm-theme-sample { background: linear-gradient(120deg,#f2f3ff 50%,#1e2022 50%); }
.rm-typography-preview { margin: 20px; padding: 24px; border-radius: 12px; border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); font-size: var(--rm-font-size); line-height: var(--rm-line-height); }
.rm-typography-preview > small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .12em; } .rm-typography-preview h2 { font-family: var(--rm-font-heading); font-size: 1.5em; } .rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); } .rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .75em; color: var(--rm-inline-code-text); }
@media(max-width: 767px) {
.rm-dialog-backdrop:has(.rm-settings) { top: 0; padding: 0; }
.rm-settings { gap: 0; } .rm-settings-nav { width: auto; border-radius: 0; padding: 20px 16px; } .rm-settings-body { padding: 16px; } .rm-settings-content { padding: 12px 0; } .rm-settings-heading h2 { font-size: var(--rm-ui-font-heading); } .rm-settings-body > .rm-dialog-head { gap: 10px; }
.rm-theme-cards { grid-template-columns: 1fr; padding: 12px; gap: 8px; } .rm-theme-cards button { padding: 8px; }
.rm-setting { padding: 12px; } .rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { font-size: max(16px, var(--rm-ui-font-size)); max-width: 100%; } .rm-settings-tab { min-height: 48px; font-size: var(--rm-ui-font-size); }
}
.rm-selection-toolbar { position: fixed; display: flex; align-items: center; box-sizing: border-box; max-width: calc(100vw - 24px); overflow-x: auto; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar button { width: var(--rm-control-compact); height: var(--rm-control-compact); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; cursor: pointer; font: var(--rm-ui-font-size) var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; }
@media(min-width:768px) { .rm-format-bar { box-sizing: content-box; position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 40px); height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } .rm-format-bar button { min-width: var(--rm-control-compact); height: var(--rm-control-size); font-size: var(--rm-ui-font-size); border-radius: 5px; } .rm-format-scroll { align-items: center; } }
@media(min-width:768px) { .rm-format-bar { box-sizing: content-box; position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 18px); padding: 4px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } }
@media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } }
.rm-typography-preview { font-family: var(--rm-font-editor, var(--rm-font-text)); }
.rm-shell { position: relative; }
.rm-global-header { box-sizing: border-box; }
.rm-dialog-backdrop:has(.rm-settings) { position: absolute; }
.rm-settings-heading { flex: 1; }
.rm-appearance-advanced { margin: 4px 0 16px; }
.rm-appearance-advanced > summary { margin: 0 20px 12px; padding: 10px 0; cursor: pointer; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { display: none; } }
@media(min-width:768px) and (max-width:1000px) {
.rm-global-header { gap: 8px; padding: 0 12px; }
.rm-global-search, .rm-header-actions .rm-avatar { display: none; }
.rm-header-actions { margin-left: auto; gap: 6px; }
.rm-primary-nav { margin-left: auto; gap: 8px; }
.rm-wordmark { font-size: calc(var(--rm-ui-font-size) + 8px); }
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); } }
@media (max-width: 1199px) {
.rm-outline-panel-enter-active,
.rm-outline-panel-leave-active { transition-property: opacity; }
.rm-outline { transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-outline-panel-enter-from .rm-outline,
.rm-outline-panel-leave-to .rm-outline { transform: translateX(100%); }
.rm-outline-panel-enter-from,
.rm-outline-panel-leave-to { width: auto; }
}
@media(max-width:360px) {
.rm-sidebar-head .rm-icon-btn { flex-direction: column; justify-content: center; gap: 6px; }
@media (prefers-reduced-motion: reduce) {
:root { --rm-panel-duration: 0ms; }
}
.rm-setting select { height: auto; min-height: var(--rm-control-size); line-height: 1.4; }
@media (max-width: 767px), (pointer: coarse) {
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
+175
View File
@@ -0,0 +1,175 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api, ApiError } from './api'
import { allCommands, commandKeys, formatKeys, registerCommand } from './commands'
import SettingsDialog from './SettingsDialog.vue'
import ShortcutSettings from './ShortcutSettings.vue'
import { DEFAULTS, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings } from './settings'
import { searchSettings } from './settingsCatalog'
import { ws } from './workspace'
let app: App | undefined
let host: HTMLElement | undefined
const cleanup: (() => void)[] = []
beforeEach(() => {
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
})
afterEach(() => {
app?.unmount()
app = undefined
host?.remove()
cleanup.splice(0).forEach(fn => fn())
ws.settings = null
vi.restoreAllMocks()
})
function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
host = document.createElement('div')
document.body.append(host)
app = createApp(component)
app.mount(host)
}
async function search(selector: string, query: string) {
const input = host!.querySelector<HTMLInputElement>(selector)!
input.value = query
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
describe('设置查找与重置', () => {
it('设置和命令不再提供分享入口', async () => {
expect(searchSettings('分享')).toEqual([])
expect(allCommands().map(command => command.id)).not.toContain('note.share')
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
ws.settings = 'appearance'
mount(SettingsDialog)
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(9)
expect(host!.textContent).not.toContain('分享')
})
it('按名称、关键词及多词匹配,只索引静态说明', () => {
expect(searchSettings(' ')).toEqual([])
expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })])
expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'tokens', title: 'API 令牌' }), expect.objectContaining({ id: 'openapi' })]))
settings.dailyFolder = 'private-note-folder'
settings.cssSnippets = [{ id: 'private', name: 'private-snippet-name', enabled: true, css: ':root { --private: 1 }' }]
expect(searchSettings('private')).toEqual([])
})
it('从搜索结果跳转设置行,并展开所在折叠区', async () => {
ws.settings = 'editor'
mount(SettingsDialog)
await search('input[aria-label="搜索设置"]', 'CSS 高级')
host!.querySelector<HTMLButtonElement>('.rm-settings-result')!.click()
await vi.waitFor(() => expect(document.activeElement?.id).toBe('rm-setting-css-snippets'))
expect(ws.settings).toBe('appearance')
expect(host!.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open).toBe(true)
expect(host!.querySelector<HTMLInputElement>('input[aria-label="搜索设置"]')!.value).toBe('')
})
it('单项重置只提交对应键,不影响另一类字号,并可重新读取', async () => {
settings.uiFontSize = 20
settings.fontSize = 24
settings.lineHeight = 2
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('button[aria-label="重置界面字号"]')!.click()
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledWith({ uiFontSize: DEFAULTS.uiFontSize }))
expect(settings.fontSize).toBe(24)
const editor = [...host!.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(e => e.textContent === '编辑器')!
editor.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('button[aria-label="重置正文行高"]')!.click()
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenLastCalledWith({ lineHeight: DEFAULTS.lineHeight }))
expect(settings.fontSize).toBe(24)
vi.mocked(api.settings).mockResolvedValue({ uiFontSize: DEFAULTS.uiFontSize, fontSize: 24, lineHeight: DEFAULTS.lineHeight })
await loadSettings()
expect(settings.fontSize).toBe(24)
expect(settings.lineHeight).toBe(DEFAULTS.lineHeight)
})
})
describe('保存反馈', () => {
it('所有排队请求完成之前保留保存中,不提前发送后续请求', async () => {
let finish!: (value: Record<string, unknown>) => void
vi.mocked(api.patchSettings).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const first = updateSettings({ uiFontSize: 18 })
const second = updateSettings({ fontSize: 22 })
expect(settingsSave.pending).toBe(2)
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledTimes(1))
expect(settingsSave.result).toBe('idle')
finish({})
await Promise.all([first, second])
expect(api.patchSettings).toHaveBeenNthCalledWith(2, { fontSize: 22 })
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved', error: '' })
})
it('失败可见,重新读取不冒充保存成功,后续保存仍可执行', async () => {
vi.mocked(api.patchSettings).mockRejectedValueOnce(new ApiError(500, '写入失败'))
await expect(updateSettings({ uiFontSize: 18 })).rejects.toThrow('写入失败')
expect(settingsSave).toMatchObject({ pending: 0, result: 'error', error: '写入失败' })
await loadSettings()
expect(settingsSave.result).toBe('error')
await updateSettings({ uiFontSize: 16 })
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved', error: '' })
})
it('写入成功但主题加载失败,保留保存结果和原始错误', async () => {
const cause = new ApiError(503, '主题暂时不可用')
vi.spyOn(api, 'theme').mockRejectedValueOnce(cause)
const failure = await updateSettings({ theme: 'unavailable' }).catch(error => error)
expect(failure).toBeInstanceOf(SettingsAppearanceError)
expect(failure.cause).toBe(cause)
expect(failure.message).toContain('设置已保存,但主题应用失败')
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved' })
})
it('CSS 已写入但主题应用失败时结束编辑,不把成功的写入留作重试', async () => {
settings.theme = 'broken-theme'
vi.spyOn(api, 'theme').mockRejectedValue(new ApiError(503, '主题暂时不可用'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open = true
const add = [...host!.querySelectorAll<HTMLButtonElement>('button')].find(e => e.textContent === '新建片段')!
add.click()
await nextTick()
await search('input[aria-label="片段名称"]', '主题失败时的片段')
const css = host!.querySelector<HTMLTextAreaElement>('textarea')!
css.value = ':root { --test-marker: 1; }'
css.dispatchEvent(new Event('input', { bubbles: true }))
host!.querySelector<HTMLFormElement>('.rm-snippet-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(host!.querySelector('.rm-snippet-form')).toBeNull())
expect(settings.cssSnippets).toHaveLength(1)
expect(api.patchSettings).toHaveBeenCalledTimes(1)
expect(settingsSave.result).toBe('saved')
})
})
describe('快捷键查找', () => {
it('过滤列表后仍检查隐藏命令的冲突,可按 ID 和组合键查找', async () => {
const occupied = { id: 'plugin:settings-test:hidden', title: '隐藏占用命令', keys: ['Mod+Alt+Shift+9'], run() {} }
cleanup.push(registerCommand({ id: 'settings-test.target', title: '审查定位命令', run() {} }), registerCommand(occupied))
mount(ShortcutSettings)
await search('input[aria-label="搜索快捷键"]', '审查定位命令')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
const add = [...host!.querySelectorAll<HTMLButtonElement>('.rm-shortcut-row button')].find(e => e.textContent === '添加')!
add.click()
await nextTick()
host!.querySelector('[data-shortcut-recorder]')!.dispatchEvent(new KeyboardEvent('keydown', { key: '9', code: 'Digit9', ctrlKey: true, altKey: true, shiftKey: true, bubbles: true, cancelable: true }))
await nextTick()
expect(host!.querySelector('[role="alert"]')!.textContent).toContain('隐藏占用命令')
expect(api.patchSettings).not.toHaveBeenCalled()
await search('input[aria-label="搜索快捷键"]', 'plugin:settings-test:hidden')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
expect(host!.querySelector('[data-shortcut-recorder]')).toBeNull()
await search('input[aria-label="搜索快捷键"]', formatKeys(commandKeys(occupied)[0]))
expect(host!.textContent).toContain('隐藏占用命令')
await search('input[aria-label="搜索快捷键"]', '不存在的命令')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(0)
expect(host!.textContent).toContain('没有匹配的快捷键')
})
})
+23 -3
View File
@@ -17,12 +17,20 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings'
export const settings = reactive<Settings>(sanitize(JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}')))
// 只记录写入结果;重新读取设置和主题应用不代表保存成功。
export const settingsSave = reactive({ pending: 0, result: 'idle' as 'idle' | 'saved' | 'error', error: '' })
export class SettingsAppearanceError extends Error {
constructor(cause: unknown) {
super('设置已保存,但主题应用失败:' + (cause instanceof Error ? cause.message : String(cause)), { cause })
}
}
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
function sanitize(raw: Record<string, unknown>): Settings {
@@ -101,12 +109,24 @@ export async function updateSettings(patch: Partial<Settings>) {
applied({ ...settings, ...patch })
// 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。
await saveSettings(patch)
await applySettingsAppearance()
try { await applySettingsAppearance() }
catch (cause) { throw new SettingsAppearanceError(cause) }
}
let saveQueue: Promise<unknown> = Promise.resolve()
function saveSettings(patch: Partial<Settings>) {
const pending = saveQueue.then(() => api.patchSettings(patch))
settingsSave.pending++
const pending = saveQueue.then(async () => {
try {
await api.patchSettings(patch)
settingsSave.result = 'saved'
settingsSave.error = ''
} catch (cause) {
settingsSave.result = 'error'
settingsSave.error = cause instanceof Error ? cause.message : String(cause)
throw cause
} finally { settingsSave.pending-- }
})
saveQueue = pending.catch(() => {})
return pending
}
+62
View File
@@ -0,0 +1,62 @@
import type { IconName } from './Icon.vue'
import type { SettingsSection } from './workspace'
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
{ id: 'templates', title: '日记与模板', icon: 'calendar', description: '日记目录、模板目录与日记模板' },
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
{ id: 'tokens', title: 'API 令牌', icon: 'key', description: '创建或撤销 REST API 访问令牌' },
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
{ id: 'account', title: '账户与安全', icon: 'user', description: '修改登录密码与退出登录' },
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
]
export const SETTING_ITEMS = {
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
'glass-opacity': { section: 'appearance', title: '磨砂玻璃通透度', description: '调整玻璃面板的透明程度,保留当前主题配色。', keywords: '透明度 背景' },
'css-snippets': { section: 'appearance', title: 'CSS 片段', description: '管理自定义主题和 CSS 片段。', keywords: '样式 自定义 高级' },
'reset-appearance': { section: 'appearance', title: '恢复默认外观与排版', description: '重置主题、界面与正文排版;停用并保留 CSS 片段。', keywords: '重置 默认 恢复' },
'text-font': { section: 'editor', title: '正文主要字体', description: '用于笔记正文,不改变界面字体。', keywords: '无衬线 宋体 系统' },
'code-font': { section: 'editor', title: '代码字体', description: '用于代码块和行内代码。', keywords: '等宽 JetBrains Mono' },
'font-size': { section: 'editor', title: '编辑器字号', description: '调整正文与代码的基础字号,不影响界面字号。', keywords: '正文 大小' },
'content-width': { section: 'editor', title: '正文宽度', description: '限制正文最大宽度,或使用可用空间的全宽。', keywords: '窄 标准 宽 全宽' },
'line-height': { section: 'editor', title: '正文行高', description: '调整正文各行之间的距离。', keywords: '行距 排版' },
'paragraph-spacing': { section: 'editor', title: '段落间距', description: '调整相邻段落之间的距离。', keywords: '段距 排版' },
'selection-toolbar': { section: 'editor', title: '选中文本浮动工具栏', description: '选中文字后显示常用格式操作。', keywords: '格式 编辑' },
'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' },
'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' },
'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' },
'daily-folder': { section: 'templates', title: '日记目录', description: '仓库相对目录;留空时保存在根目录,修改不会移动已有日记。', keywords: '路径 Daily' },
'templates-folder': { section: 'templates', title: '模板目录', description: '仓库相对目录,修改不会移动已有模板。', keywords: '路径 Templates' },
'daily-template': { section: 'templates', title: '日记模板', description: '日记按本地日期命名,已有日记直接打开。', keywords: '空白笔记 日期' },
'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' },
'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' },
'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' },
'version': { section: 'about', title: '构建版本', description: '当前页面加载的应用版本。', keywords: 'RawMark 关于' },
'openapi': { section: 'about', title: 'API 文档', description: '在「API 令牌」创建令牌后,通过 REST API 读取或写入笔记。', keywords: 'OpenAPI 接口 自动化' },
} satisfies Record<string, { section: SettingsSection; title: string; description: string; keywords: string }>
export type SettingId = keyof typeof SETTING_ITEMS
export interface SettingSearchResult {
section: SettingsSection
id?: SettingId
title: string
description: string
}
/** 只搜索设置说明,不读取设置值、笔记或管理列表。 */
export function searchSettings(query: string): SettingSearchResult[] {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!terms.length) return []
const matches = (text: string) => terms.every(term => text.toLocaleLowerCase().includes(term))
return [
...SETTINGS_SECTIONS.filter(s => matches(s.title + ' ' + s.description)).map(s => ({ section: s.id, title: s.title, description: s.description })),
...(Object.entries(SETTING_ITEMS) as [SettingId, typeof SETTING_ITEMS[SettingId]][])
.filter(([, item]) => matches(item.title + ' ' + item.description + ' ' + item.keywords))
.map(([id, item]) => ({ id, section: item.section, title: item.title, description: item.description })),
]
}
+1 -5
View File
@@ -35,7 +35,7 @@ export interface Tab {
conflictRev?: string
}
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash' | 'plugins' | 'about'
export type SettingsSection = 'appearance' | 'editor' | 'templates' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about'
export interface Toast {
id: number
@@ -63,8 +63,6 @@ export const ws = reactive({
switcher: null as string | null,
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
// 分享当前笔记的对话框(笔记路径)
sharing: null as string | string[] | null,
// 「移动到…」对话框(笔记或文件夹路径)
moving: null as string | string[] | null,
// 导入目标目录,空字符串为根目录
@@ -1100,9 +1098,7 @@ function registerBuiltinCommands() {
{ id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'view.source', title: '开启/关闭源码编辑', when: hasNote, run: toggleSourceMode },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) },
{ id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } },
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') },
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') },
]) {
registerCommand(cmd)
+1 -1
View File
@@ -6,7 +6,7 @@ export interface HighlightRequest {
code: string
}
// 保留 Prism 的嵌套 token 与原文,编辑器和分享页共用官方 token 类样式。
// 保留 Prism 的嵌套 token 与原文,编辑器使用官方 token 类样式。
export type TokenRun = (string | { classes: string; content: TokenRun })[]
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
+1 -1
View File
@@ -462,7 +462,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
const name = text(label.from + 2, label.to - 2)
return `<div class="footnote" id="${escapeAttr(footnoteId(name))}"><sup>${escapeHtml(name)}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
}
// 目录占位,由分享页根据标题填充
// 目录占位,由调用方根据标题填充
case 'TableOfContents':
return '<nav class="toc"></nav>\n'
// front matter 是元数据,不输出
+1 -1
View File
@@ -50,7 +50,7 @@ self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
if (request.method !== 'GET' || url.origin !== self.location.origin) return
// 只匹配构建产物与应用入口,不缓存 API、分享地址、笔记图片或插件。
// 只匹配构建产物与应用入口,不缓存 API、笔记图片或插件。
const entry = request.mode === 'navigate' && (url.pathname === '/' || url.pathname === '/index.html')
if (!entry && !paths.has(url.pathname) && !url.pathname.startsWith('/assets/')) return
event.respondWith((async () => {
-422
View File
@@ -1,422 +0,0 @@
// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器
import { copyText } from '../clipboard'
import { readingStats } from '../app/reading'
import { applyDisplaySettings, sanitizeDisplaySettings } from '../themes/typography'
import { requestTokens, type TokenRun } from '../highlight/client'
import { renderMarkdown } from '../markdown/html'
import { hydrate, renderMermaid } from '../markdown/render'
import { applyAppearance, sanitizeAppearance } from '../themes/appearance'
import '../themes/base.css'
import './share.css'
interface SharedNote {
title: string
dir: false
content: string
updatedAt: number
}
interface SharedDirectory {
title: string
dir: true
entries: { path: string; dir: boolean }[]
}
interface SharedFile {
title: string
dir: false
}
interface SharedCollection {
collection: true
title: string
items: { id: number; title: string; exists: boolean; dir: boolean }[]
}
const root = document.getElementById('share')!
const token = decodeURIComponent(location.pathname.split('/')[2] ?? '')
const api = '/api/v1/public/shares/' + encodeURIComponent(token)
const selectedItem = new URL(location.href).searchParams.get('item')
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
function pageUrl(path: string, item: string | null = selectedItem) {
const query = new URLSearchParams()
if (item !== null) query.set('item', item)
if (path) query.set('path', path)
return location.pathname + (query.size ? '?' + query : '')
}
function downloadUrl() {
const url = new URL(api + '/assets/' + encodePath(selectedPath), location.origin)
url.searchParams.set('download', '1')
if (selectedItem !== null) url.searchParams.set('item', selectedItem)
return url.href
}
function message(text: string) {
root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text }))
}
async function load() {
let res: Response
try {
const query = new URLSearchParams()
if (selectedPath) query.set('path', selectedPath)
if (selectedItem !== null) query.set('item', selectedItem)
res = await fetch(api + (query.size ? '?' + query : ''), { credentials: 'same-origin', cache: 'no-store' })
} catch {
message('网络错误,请检查连接后刷新')
return
}
if (res.ok) {
const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile | SharedCollection
let appearanceError = false
try {
const response = await fetch(api + '/appearance', { cache: 'no-store' })
if (!response.ok) throw new Error('读取外观失败')
const appearance = await response.json() as Record<string, unknown>
applyDisplaySettings(sanitizeDisplaySettings(appearance))
applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '')
} catch {
appearanceError = true
}
if ('collection' in content) renderCollection(content)
else if (content.dir) renderDirectory(content)
else if ('content' in content) render(content)
else {
document.title = content.title
const link = Object.assign(document.createElement('a'), { href: downloadUrl(), download: content.title, textContent: '下载 ' + content.title })
root.replaceChildren(navigation(), link)
}
if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' }))
}
else if (res.status === 401) askPassword()
else if (res.status === 404) {
message(selectedPath || selectedItem !== null ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
if (selectedPath || selectedItem !== null) root.prepend(navigation())
}
else message(`加载失败(${res.status})`)
}
function askPassword(error = '') {
const form = document.createElement('form')
form.className = 'rm-share-password'
form.innerHTML = `
<p>这篇分享需要访问密码</p>
<input type="password" autocomplete="current-password" required aria-label="访问密码" />
<p class="rm-share-error"></p>
<button type="submit">查看</button>`
const input = form.querySelector('input')!
const button = form.querySelector('button')!
form.querySelector('.rm-share-error')!.textContent = error
form.addEventListener('submit', async (e) => {
e.preventDefault()
button.disabled = true
try {
const res = await fetch(api + '/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: input.value }),
})
if (res.ok) return void load()
if (res.status === 404) return message('分享不存在、已过期或已被撤销')
askPassword(res.status === 401 ? '密码错误' : res.status === 429 ? '尝试次数过多,请稍后再试' : `验证失败(${res.status})`)
} catch {
askPassword('网络错误,请重试')
}
})
root.replaceChildren(form)
input.focus()
}
const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
function navigation() {
const nav = document.createElement('nav')
nav.className = 'rm-share-nav'
nav.setAttribute('aria-label', '分享导航')
nav.append(Object.assign(document.createElement('a'), { href: pageUrl('', null), textContent: '分享首页' }))
if (selectedItem !== null) nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '主体首页' }))
const parts = selectedPath.split('/').filter(Boolean)
for (let i = 0; i < parts.length; i++) {
nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] }))
}
return nav
}
function renderCollection(collection: SharedCollection) {
document.title = collection.title
const heading = Object.assign(document.createElement('h1'), { textContent: collection.title })
const list = document.createElement('ul')
list.className = 'rm-share-files'
for (const item of collection.items) {
const row = document.createElement('li')
row.append(item.exists
? Object.assign(document.createElement('a'), { href: pageUrl('', String(item.id)), textContent: item.title + (item.dir ? '/' : '') })
: Object.assign(document.createElement('span'), { textContent: item.title + '(已不可用)' }))
list.append(row)
}
root.replaceChildren(heading, list)
}
function renderDirectory(directory: SharedDirectory) {
document.title = directory.title
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
const list = document.createElement('ul')
list.className = 'rm-share-files'
for (const entry of directory.entries) {
const li = document.createElement('li')
li.style.paddingLeft = (entry.path.split('/').length - 1) * 16 + 'px'
const path = selectedPath ? selectedPath + '/' + entry.path : entry.path
li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') }))
list.append(li)
}
const listing = document.createElement('section')
listing.className = 'rm-share-listing'
listing.append(navigation(), heading, list)
root.replaceChildren(publicHeader(document.title), listing)
if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' }))
}
function action(text: string, title: string, run: () => void) {
const button = Object.assign(document.createElement('button'), { type: 'button', textContent: text, title })
button.addEventListener('click', run)
return button
}
function publicHeader(title: string) {
const bar = document.createElement('header')
bar.className = 'rm-public-header'
const brand = Object.assign(document.createElement('a'), { className: 'rm-public-brand', href: pageUrl('', null), textContent: 'RawMark' })
const badge = Object.assign(document.createElement('span'), { className: 'rm-public-badge', textContent: '只读知识库' })
const current = Object.assign(document.createElement('span'), { className: 'rm-public-current', textContent: title })
const controls = document.createElement('div')
controls.className = 'rm-public-actions'
const toc = action('大纲', '显示 / 隐藏文章大纲', () => { const open = root.classList.toggle('toc-open'); toc.setAttribute('aria-expanded', String(open)) })
toc.className = 'rm-reading-action rm-toc-toggle'
toc.setAttribute('aria-expanded', 'false')
const focus = action('专注', '专注阅读', () => { const on = root.classList.toggle('rm-share-focus'); focus.setAttribute('aria-pressed', String(on)); focus.textContent = on ? '退出专注' : '专注' })
focus.className = 'rm-reading-action'
focus.setAttribute('aria-pressed', 'false')
const copy = action('复制链接', '复制当前分享链接', () => { void copyText(location.href).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
controls.append(toc, focus, copy, action('打印', '打印文章', () => window.print()))
bar.append(brand, badge, current, controls)
return bar
}
let stopReading: (() => void) | undefined
function render(note: SharedNote) {
stopReading?.()
document.title = note.title
const aside = document.createElement('aside')
aside.className = 'rm-share-sidebar'
const header = document.createElement('header')
header.className = 'rm-share-header'
const article = document.createElement('article')
article.className = 'rm-share-content'
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤。
article.innerHTML = renderMarkdown(note.content)
const headings = buildToc(article)
const first = article.firstElementChild
const title = first?.tagName === 'H1' ? first.textContent ?? note.title : note.title.replace(/\.md$/, '')
if (first?.tagName === 'H1') first.remove()
const stats = readingStats(article.textContent ?? '')
header.append(
Object.assign(document.createElement('span'), { className: 'rm-share-kicker', textContent: 'PUBLIC NOTE · 公开分享' }),
first?.tagName === 'H1' ? first : Object.assign(document.createElement('h1'), { textContent: title }),
Object.assign(document.createElement('p'), { className: 'rm-share-time', textContent: `更新于 ${dateFormat.format(note.updatedAt)} · ${stats.words.toLocaleString('zh-CN')} 字词 · 预计阅读 ${stats.minutes} 分钟` }),
)
const surface = document.createElement('div')
surface.className = 'rm-reading-card'
const footer = Object.assign(document.createElement('footer'), { className: 'rm-reading-footer', textContent: '由 RawMark 分享 · 内容来自源文件的最新版本' })
surface.append(header, article, footer)
const layout = document.createElement('div')
layout.className = 'rm-reading-layout'
layout.append(aside, surface)
root.replaceChildren(publicHeader(title), ...(selectedPath || selectedItem !== null ? [navigation()] : []), layout)
const backdrop = action('', '关闭文章大纲', () => root.classList.remove('toc-open'))
backdrop.className = 'rm-share-sidebar-backdrop'
root.append(backdrop)
resolveImages(surface)
resolveLinks(surface)
const toc = document.createElement('nav')
toc.className = 'rm-public-toc'
toc.setAttribute('aria-label', '文章大纲')
toc.append(Object.assign(document.createElement('h2'), { textContent: '文章大纲' }))
const min = Math.min(...headings.map(h => Number(h.tagName[1])))
for (const heading of headings) {
const link = Object.assign(document.createElement('a'), { href: '#' + heading.id, textContent: heading.textContent })
link.style.paddingLeft = (Number(heading.tagName[1]) - min) * 14 + 12 + 'px'
link.addEventListener('click', () => root.classList.remove('toc-open'))
toc.append(link)
}
if (!headings.length) toc.append(Object.assign(document.createElement('p'), { textContent: '此文章没有小标题' }))
const progressLabel = Object.assign(document.createElement('div'), { className: 'rm-reading-progress-label' })
const progress = document.createElement('progress'); progress.max = 100; progress.value = 0
progress.setAttribute('aria-label', '阅读进度')
toc.append(progressLabel, progress)
aside.append(toc)
const hint = Object.assign(document.createElement('p'), { className: 'rm-reading-hint', textContent: '当前内容为只读分享,修改由知识库作者在源文件中完成。' })
aside.append(hint)
void sharedNavigation(aside)
hydrate(surface)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch(error => {
code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error)
})
let frame = 0
function update() {
frame = 0
const rect = surface.getBoundingClientRect()
const value = Math.round(Math.max(0, Math.min(100, (innerHeight - rect.top) / Math.max(1, rect.height) * 100)))
progress.value = value
progressLabel.textContent = '阅读进度 · ' + value + '%'
let active = headings[0]
for (const heading of headings) { if (heading.getBoundingClientRect().top < 160) active = heading }
for (const link of toc.querySelectorAll('a')) { const on = link.getAttribute('href') === '#' + active?.id; link.classList.toggle('on', on); if (on) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current') }
}
const schedule = () => { if (!frame) frame = requestAnimationFrame(update) }
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule)
const observer = new ResizeObserver(schedule)
observer.observe(surface)
stopReading = () => { observer.disconnect(); window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); cancelAnimationFrame(frame) }
schedule()
if (location.hash) {
try { const id = decodeURIComponent(location.hash.slice(1)); requestAnimationFrame(() => document.getElementById(id)?.scrollIntoView()) }
catch { root.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '链接锚点编码无效' })) }
}
}
// 目录导航只读取当前分享主体,不请求登录后的仓库元数据。
async function sharedNavigation(aside: HTMLElement) {
if (!selectedPath) return
try {
const response = await fetch(api + (selectedItem !== null ? '?item=' + encodeURIComponent(selectedItem) : ''), { cache: 'no-store' })
if (!response.ok) throw new Error(`读取分享目录失败(${response.status})`)
const directory = await response.json() as SharedNote | SharedDirectory
if (!directory.dir) return
const nav = document.createElement('nav')
nav.className = 'rm-public-file-nav'
nav.setAttribute('aria-label', '分享目录')
nav.append(Object.assign(document.createElement('h2'), { textContent: directory.title }))
for (const entry of directory.entries) {
if (entry.path.split('/').some(part => part === 'assets' || part.endsWith('.assets')) || !entry.dir && !entry.path.endsWith('.md')) continue
const link = Object.assign(document.createElement('a'), { href: pageUrl(entry.path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') })
if (entry.path === selectedPath) link.className = 'on'
nav.append(link)
}
aside.prepend(nav)
} catch (error) { aside.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: error instanceof Error ? error.message : '分享目录读取失败' })) }
}
// 相对引用始终经当前分享读取,范围由服务器按分享主体校验。
function resourceUrl(src: string): URL | null {
try {
const prefix = new URL(api + '/assets/', location.origin)
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
if (selectedItem !== null) target.searchParams.set('item', selectedItem)
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
} catch { return null }
}
function resolveImages(article: HTMLElement) {
for (const img of article.querySelectorAll('img')) {
const src = img.getAttribute('src') ?? ''
if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) {
img.src = resourceUrl(src)?.href ?? '#'
}
img.loading = 'lazy'
}
}
function resolveLinks(article: HTMLElement) {
for (const link of article.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = link.getAttribute('href')!
if (!href || /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('/') || href.startsWith('#')) continue
const target = resourceUrl(href)
if (!target) { link.href = '#'; continue }
if (selectedPath) {
try {
const path = decodeURIComponent(target.pathname.slice((api + '/assets/').length)).replace(/\/$/, '')
link.href = pageUrl(path) + target.hash
} catch { link.href = '#' }
} else {
target.searchParams.set('download', '1')
link.href = target.href
link.download = ''
}
}
}
function buildToc(article: HTMLElement) {
const headings = [...article.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')]
const used = new Set<string>()
for (const h of headings) {
const base = (h.textContent ?? '').trim().toLowerCase().replace(/\s+/g, '-') || 'section'
let id = base
for (let i = 1; used.has(id) || document.getElementById(id); i++) id = `${base}-${i}`
used.add(id)
h.id = id
}
const navs = article.querySelectorAll('nav.toc')
const top = Math.min(...headings.map((h) => Number(h.tagName[1])))
const list = document.createElement('ul')
for (const h of headings) {
const li = document.createElement('li')
li.style.paddingLeft = `${(Number(h.tagName[1]) - top) * 1.2}em`
li.append(Object.assign(document.createElement('a'), { href: '#' + h.id, textContent: h.textContent }))
list.append(li)
}
for (const nav of navs) nav.replaceChildren(list.cloneNode(true))
return headings
}
async function decorateCode(code: HTMLElement) {
const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase()
const text = (code.textContent ?? '').replace(/\n$/, '')
if (lang === 'mermaid') {
const figure = document.createElement('div')
figure.className = 'rm-share-mermaid'
figure.dataset.code = text
try {
figure.innerHTML = await renderMermaid(text)
code.parentElement!.replaceWith(figure)
} catch {
// 语法错误时保留源码
}
return
}
const pre = code.parentElement!
const tools = document.createElement('div'); tools.className = 'rm-public-code-tools'
const copy = action('复制', '复制代码', () => { void copyText(text).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
tools.append(Object.assign(document.createElement('span'), { textContent: lang || 'text' }), copy)
pre.prepend(tools)
if (!lang) return
const run = await requestTokens(lang, text)
if (!run) return
const out = document.createDocumentFragment()
function appendTokens(parent: DocumentFragment | HTMLElement, tokens: TokenRun) {
for (const token of tokens) {
if (typeof token === 'string') parent.append(token)
else {
const span = document.createElement('span')
span.className = token.classes
appendTokens(span, token.content)
parent.append(span)
}
}
}
appendTokens(out, run)
code.replaceChildren(out)
}
window.addEventListener('rawmark-appearance', () => {
for (const el of root.querySelectorAll<HTMLElement>('.rm-share-mermaid')) {
const code = el.dataset.code
if (code !== undefined) void renderMermaid(code).then((svg) => { el.innerHTML = svg }).catch(() => { el.textContent = '图表渲染失败,请刷新重试' })
}
})
window.addEventListener('keydown', event => { if (event.key === 'Escape') root.classList.remove('toc-open') })
void load()
-162
View File
@@ -1,162 +0,0 @@
/* 只读分享页:排版与编辑器渲染效果保持一致(同一套 --rm-* 变量) */
.rm-share {
max-width: var(--rm-content-width);
margin: 0 auto;
padding: 24px 24px 80px;
font-size: var(--rm-font-size);
line-height: var(--rm-line-height);
overflow-wrap: break-word;
}
.rm-share-message { font-size: var(--rm-ui-font-size); margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
.rm-share-nav { margin-bottom: 16px; color: var(--rm-text-muted); }
.rm-share-nav a, .rm-share-files a { color: var(--rm-accent-strong); }
.rm-share-files { list-style: none; padding: 0; }
.rm-share-files li { margin: 8px 0; }
.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; }
.rm-share-password p { margin: 0; }
.rm-share-password input {
min-width: 0;
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-share-password input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
.rm-share-password button {
height: var(--rm-control-size);
border: 0;
border-radius: 8px;
background: var(--rm-accent);
color: #fff;
font-size: var(--rm-ui-font-size);
cursor: pointer;
}
.rm-share-password button:disabled { opacity: 0.6; }
.rm-share-error { min-height: 1.2em; color: #e5484d; font-size: var(--rm-ui-font-small); }
.rm-share-header {
display: flex;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--rm-divider);
color: var(--rm-text-muted);
font-size: var(--rm-font-size);
}
.rm-share-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-time { flex: none; }
.rm-share-content h1 { font-size: 2.2em; line-height: 1.3; margin: 0.9em 0 0.4em; }
.rm-share-content h2 { font-size: 1.75em; line-height: 1.225; padding-bottom: 0.5em; border-bottom: 1px solid var(--rm-divider); margin: 0.9em 0 0.4em; }
.rm-share-content h3 { font-size: 1.4em; line-height: 1.43; margin: 0.8em 0 0.4em; }
.rm-share-content h4 { font-size: 1.2em; margin: 0.8em 0 0.4em; }
.rm-share-content h5,
.rm-share-content h6 { font-size: 1em; margin: 0.8em 0 0.4em; }
.rm-share-content p { margin: 0.6em 0; }
.rm-share-content a { color: var(--rm-accent-strong); text-decoration: none; }
.rm-share-content a:hover { text-decoration: underline; }
.rm-share-header img,
.rm-share-content img { max-width: 100%; border-radius: 4px; }
.rm-share-content mark { background: var(--rm-highlight-bg); color: inherit; padding: 0 2px; border-radius: 2px; }
.rm-share-content hr { border: 0; border-top: 1px solid var(--rm-divider); margin: 1.5em 0; }
.rm-share-content blockquote { margin: 0.8em 0; padding: 5px 15px; border-left: 4px solid #42b983; background: rgba(66, 185, 131, 0.1); }
.rm-share-content ul,
.rm-share-content ol { padding-left: 1.6em; }
.rm-share-content li > input[type='checkbox'] { margin: 0 0.4em 0 -1.4em; accent-color: var(--rm-accent); vertical-align: -0.1em; }
.rm-share-content li:has(> input[type='checkbox']) { list-style: none; }
.rm-share-content code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.rm-share-content pre {
margin: 0.8em 0;
padding: 12px 24px;
border-radius: 8px;
background: var(--rm-code-bg);
overflow-x: auto;
line-height: 1.5;
tab-size: 4;
}
.rm-share-content pre code {
padding: 0;
background: none;
color: var(--rm-code-text);
font-size: var(--rm-code-font-size);
}
.rm-share-content table { border-collapse: collapse; display: block; max-width: 100%; width: fit-content; overflow-x: auto; margin: 0.8em 0; font-size: 0.95em; line-height: 1.5; }
.rm-share-content th,
.rm-share-content td { border: 1px solid var(--rm-divider); padding: 6px 12px; }
.rm-share-content th { font-weight: 600; background: var(--rm-inline-code-bg); }
.rm-share-content div[data-math] { margin: 0.8em 0; overflow-x: auto; }
.rm-share-mermaid { margin: 0.8em 0; text-align: center; overflow-x: auto; }
.rm-share-content nav.toc { margin: 0.8em 0; padding: 8px 16px; border-left: 3px solid var(--rm-divider); }
.rm-share-content nav.toc ul { list-style: none; margin: 0; padding: 0; }
.rm-share-content nav.toc li { margin: 2px 0; }
.rm-share-content .footnote-ref a { text-decoration: none; }
.rm-share-content .footnote { margin-top: 0.4em; font-size: 0.9em; color: var(--rm-text-muted); }
.rm-share-content .footnote:first-of-type { margin-top: 2em; padding-top: 1em; border-top: 1px solid var(--rm-divider); }
@media (max-width: 600px) {
.rm-share { padding: 16px 16px 60px; }
.rm-share-time { display: none; }
}
/* 公开阅读工作台:展示当前分享范围,不加载编辑功能。 */
html { scroll-padding-top: calc(var(--rm-header-height) + 24px); }
body { background: radial-gradient(ellipse at 25% 0,var(--rm-selection),transparent 55%), var(--rm-bg); }
.rm-share { max-width: none; padding: calc(var(--rm-header-height) + 36px) 32px 72px; font-family: var(--rm-font-editor, var(--rm-font-text)); }
.rm-public-header { box-sizing: border-box; font-size: var(--rm-ui-font-size); line-height: 1.5; position: fixed; inset: 0 0 auto; height: var(--rm-header-height); display: flex; align-items: center; gap: 20px; padding: 0 28px; z-index: 40; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(20px); font-family: var(--rm-font-text); }
.rm-public-brand { color: var(--rm-accent-strong); text-decoration: none; font: 600 var(--rm-ui-font-title) var(--rm-font-heading); }
.rm-public-badge { font-size: var(--rm-ui-font-caption); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 30px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-public-current { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-public-actions { display: flex; gap: 8px; margin-left: auto; }
.rm-public-actions button { min-height: var(--rm-control-size); border: 1px solid transparent; border-radius: 7px; background: none; color: var(--rm-text-muted); padding: 7px 10px; font: var(--rm-ui-font-size) var(--rm-font-text); cursor: pointer; white-space: nowrap; }
.rm-public-actions button:hover, .rm-public-actions button[aria-pressed=true] { background: var(--rm-hover); color: var(--rm-accent); } .rm-public-actions button:nth-last-child(2) { border-color: var(--rm-divider); }
.rm-share > .rm-share-nav { max-width: 1100px; margin: -16px auto 24px; font: var(--rm-ui-font-caption) var(--rm-font-text); }
.rm-reading-layout { display: flex; align-items: flex-start; justify-content: center; gap: 32px; max-width: 1140px; margin: auto; }
.rm-share-sidebar { position: sticky; top: calc(var(--rm-header-height) + 24px); width: 240px; flex: none; max-height: calc(100dvh - 120px); overflow: auto; font-family: var(--rm-font-text); }
.rm-public-toc, .rm-public-file-nav { padding: 20px 14px; border: 1px solid var(--rm-divider); border-radius: 13px; background: color-mix(in srgb,var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(18px); margin-bottom: 16px; }
.rm-share-sidebar h2 { font-size: var(--rm-ui-font-size); margin: 0 10px 16px; color: var(--rm-accent); font-weight: 600; }
.rm-share-sidebar a { display: block; padding: 8px 12px; text-decoration: none; color: var(--rm-text-muted); font-size: var(--rm-ui-font-size); line-height: 1.65; border-radius: 6px; overflow-wrap: anywhere; }
.rm-share-sidebar a:hover { background: var(--rm-hover); } .rm-share-sidebar a.on { color: var(--rm-accent); background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
.rm-reading-progress-label { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); border-top: 1px solid var(--rm-divider); padding-top: 18px; margin-top: 20px; } progress { display: block; width: 100%; height: 5px; margin-top: 9px; border: 0; border-radius: 6px; overflow: hidden; accent-color: var(--rm-accent); } progress::-webkit-progress-bar { background: var(--rm-divider); } progress::-webkit-progress-value { background: var(--rm-accent); } progress::-moz-progress-bar { background: var(--rm-accent); }
.rm-reading-hint { border: 1px solid var(--rm-divider); border-radius: 12px; padding: 18px; font-size: var(--rm-ui-font-caption); line-height: 1.9; color: var(--rm-text-muted); background: var(--rm-active); }
.rm-reading-card { flex: 1; min-width: 0; max-width: var(--rm-content-width); padding: 40px 44px; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 18px; background: color-mix(in srgb,var(--rm-bg) 90%,transparent); box-shadow: 0 8px 40px rgba(19,27,46,.03); }
.rm-share-header { display: block; padding-bottom: 28px; margin-bottom: 28px; border-bottom: 1px solid var(--rm-divider); }
.rm-share-kicker { display: inline-block; font: var(--rm-ui-font-caption) var(--rm-font-text); letter-spacing: .1em; color: var(--rm-accent); background: var(--rm-selection); padding: 5px 8px; border-radius: 5px; }
.rm-share-header h1 { font: 600 2.125em/1.5 var(--rm-font-heading); color: var(--rm-text); margin: 20px 0 18px; }
.rm-share-time { display: block; font: var(--rm-ui-font-small)/1.8 var(--rm-font-text); color: var(--rm-text-muted); margin: 0; }
.rm-share-content :is(h1,h2,h3,h4,h5,h6) { font-family: var(--rm-font-heading); font-weight: 600; scroll-margin-top: var(--rm-header-height); } .rm-share-content h2 { font-size: 1.5em; line-height: 1.5; margin-top: 1.5em; } .rm-share-content p { margin: 0 0 var(--rm-paragraph-spacing); } .rm-share-content blockquote { border-left-color: var(--rm-accent); background: color-mix(in srgb,var(--rm-accent) 5%,transparent); border-radius: 0 8px 8px 0; padding: 16px 20px; } .rm-share-content blockquote p:last-child { margin-bottom: 0; }
.rm-share-content pre { position: relative; border: 1px solid var(--rm-divider); border-radius: 12px; padding: calc(var(--rm-control-compact) + 20px) 20px 20px; }
.rm-public-code-tools { position: absolute; inset: 8px 12px auto; display: flex; justify-content: space-between; align-items: center; font: var(--rm-ui-font-caption) var(--rm-font-mono); color: var(--rm-text-muted); user-select: none; }
.rm-public-code-tools button { min-height: var(--rm-control-compact); color: inherit; font: inherit; background: none; border: 0; padding: 4px 8px; border-radius: 5px; cursor: pointer; } .rm-public-code-tools button:hover { background: var(--rm-hover); color: var(--rm-accent); }
.rm-reading-footer { margin-top: 40px; border-top: 1px solid var(--rm-divider); padding-top: 24px; color: var(--rm-text-muted); font: var(--rm-ui-font-small) var(--rm-font-text); text-align: center; }
.rm-share-sidebar-backdrop { display: none; }
.rm-share-focus .rm-share-sidebar { display: none; } .rm-share-focus .rm-reading-layout { max-width: 960px; } .rm-share-focus .rm-reading-card { box-shadow: none; }
.rm-share-listing { font-size: var(--rm-ui-font-size); max-width: 900px; margin: auto; border: 1px solid var(--rm-divider); border-radius: 18px; padding: 36px; background: var(--rm-active); } .rm-share-listing h1 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); } .rm-share-listing .rm-share-nav { font-size: var(--rm-ui-font-size); } .rm-share-files li { padding: 10px; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); } .rm-share-files a { text-decoration: none; } .rm-share:has(.rm-share-listing) .rm-reading-action { display: none; }
.rm-share-password { font-size: var(--rm-ui-font-size); box-sizing: border-box; width: 100%; max-width: 340px; padding: 32px; border: 1px solid var(--rm-divider); border-radius: 18px; background: var(--rm-active); box-shadow: var(--rm-shadow); font-family: var(--rm-font-text); }
@media(min-width:1001px) { .rm-public-actions .rm-toc-toggle { display: none; } }
@media(max-width:1000px) { .rm-share { padding: calc(var(--rm-header-height) + 24px) 24px 48px; } .rm-share-sidebar { display: none; } .rm-public-current { display: none; } .toc-open .rm-share-sidebar { display: block; position: fixed; inset: var(--rm-header-height) auto 0 0; width: min(84vw, 300px); box-sizing: border-box; max-height: none; z-index: 35; padding: 20px; background: var(--rm-bg); box-shadow: var(--rm-shadow); } .toc-open .rm-share-sidebar-backdrop { display: block; position: fixed; inset: var(--rm-header-height) 0 0; border: 0; background: rgba(19,27,46,.2); z-index: 34; } }
@media(max-width:767px) { :root { --rm-header-height: calc(var(--rm-control-size) + var(--rm-ui-font-size) * 1.5 + 20px); } .rm-public-header { flex-wrap: wrap; align-content: center; padding: max(8px, env(safe-area-inset-top)) 12px 8px; gap: 4px 8px; } .rm-public-brand { font-size: var(--rm-ui-font-heading); } .rm-public-badge { display: none; } .rm-public-actions { width: 100%; justify-content: space-between; margin-left: 0; gap: 0; } .rm-public-actions button { min-width: 44px; min-height: 44px; font-size: var(--rm-ui-font-size); padding: 5px 4px; } }
@media(max-width:600px) { .rm-share { padding: calc(var(--rm-header-height) + 20px) 12px 40px; } .rm-reading-card { padding: 24px 20px; border-radius: 14px; } .rm-share-header h1 { font-size: 1.75em; } .rm-share-time { display: block; font-size: var(--rm-ui-font-small); } .rm-share-content pre { padding: calc(var(--rm-control-compact) + 20px) 16px 16px; } .rm-public-code-tools { color: #b9c2d6; } .rm-share-listing { padding: 24px 16px; } }
@media print { body { background: white; color: black; } .rm-share { padding: 0; } .rm-public-header, .rm-share-sidebar, .rm-share-nav, .rm-share-sidebar-backdrop, .rm-public-code-tools { display: none !important; } .rm-reading-layout { display: block; max-width: none; } .rm-reading-card, .rm-share-listing { border: 0; box-shadow: none; max-width: none; padding: 0; background: white; } .rm-share-content pre, .rm-share-content table { overflow: visible; } .rm-share-content pre { white-space: pre-wrap; padding: 16px; } a { color: inherit; } }
@media (max-width: 767px), (pointer: coarse) {
.rm-share-password input { font-size: max(16px, var(--rm-ui-font-size)); }
}
+27 -19
View File
@@ -10,14 +10,22 @@
--rm-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--rm-font-size: 16px;
/* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */
--rm-ui-font-size: 16px;
--rm-ui-font-size: 14px;
--rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
--rm-ui-font-heading: calc(var(--rm-ui-font-size) + 4px);
--rm-ui-font-title: calc(var(--rm-ui-font-size) + 12px);
--rm-icon-size: calc(var(--rm-ui-font-size) + 4px);
--rm-control-size: max(40px, calc(var(--rm-ui-font-size) + 24px));
--rm-control-compact: max(32px, calc(var(--rm-ui-font-size) + 16px));
--rm-ui-font-title: calc(var(--rm-ui-font-size) + 8px);
--rm-ui-line-height: 1.5;
--rm-icon-size: max(16px, calc(var(--rm-ui-font-size) + 2px));
--rm-control-size: max(32px, calc(var(--rm-ui-font-size) + 18px));
--rm-control-compact: max(28px, calc(var(--rm-ui-font-size) + 14px));
--rm-space-1: 4px;
--rm-space-2: 8px;
--rm-space-3: 12px;
--rm-space-4: 16px;
--rm-space-6: 24px;
--rm-panel-duration: 220ms;
--rm-panel-easing: cubic-bezier(0.2, 0, 0, 1);
--rm-header-height: calc(var(--rm-ui-font-size) + 48px);
--rm-line-height: 1.75;
--rm-content-width: 760px;
@@ -99,7 +107,7 @@ body {
font-family: var(--rm-font-text);
}
body { font-size: var(--rm-ui-font-size); line-height: 1.5; }
body { font-size: var(--rm-ui-font-size); line-height: var(--rm-ui-line-height); }
button, input, select, textarea { font: inherit; }
.rm-icon { flex: none; width: var(--rm-icon-size); height: var(--rm-icon-size); }
@media (pointer: coarse), (max-width: 767px) {
@@ -335,27 +343,27 @@ button, input, select, textarea { font: inherit; }
.cm-line.cm-rm-frontmatter-begin { border-radius: 6px 6px 0 0; padding-top: 4px; }
.cm-line.cm-rm-frontmatter-end { border-radius: 0 0 6px 6px; padding-bottom: 4px; }
/* 浅色编辑排版与分享共用同一套 Prism token 颜色。 */
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) .token { text-shadow: none; background: none; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.comment,.prolog,.doctype,.cdata) { color: #94a3b8; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.punctuation,.operator) { color: #64748b; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.keyword,.atrule,.tag) { color: #7c3aed; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.string,.char,.attr-value,.builtin) { color: #059669; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.function,.class-name) { color: #2563eb; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.number,.boolean,.constant) { color: #d97706; }
/* 浅色编辑器的 Prism token 颜色。 */
:root:not([data-rm-color-mode="dark"]) .cm-rm-code .token { text-shadow: none; background: none; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.comment,.prolog,.doctype,.cdata) { color: #94a3b8; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.punctuation,.operator) { color: #64748b; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.keyword,.atrule,.tag) { color: #7c3aed; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.string,.char,.attr-value,.builtin) { color: #059669; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.function,.class-name) { color: #2563eb; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.number,.boolean,.constant) { color: #d97706; }
.cm-line:is(.cm-rm-h1,.cm-rm-h2,.cm-rm-h3,.cm-rm-h4,.cm-rm-h5,.cm-rm-h6) { font-family: var(--rm-font-heading); font-weight: 600; }
.cm-line.cm-rm-h1 { font-size: 2.125em; line-height: 1.4; }
.cm-line.cm-rm-h2 { font-size: 1.5em; line-height: 1.5; }
.cm-editor .cm-content { padding-top: 36px; }
.cm-editor .cm-content { padding-top: 16px; }
.cm-rm-code-lang { font-family: var(--rm-font-mono); }
.cm-line.cm-rm-code-begin::before { border-top: 1px solid var(--rm-divider); }
.cm-line.cm-rm-code-end::before { border-bottom: 1px solid var(--rm-divider); }
.cm-line.cm-rm-paragraph-end { padding-bottom: var(--rm-paragraph-spacing); }
@media (max-width: 767px) {
:root:not([data-rm-color-mode="dark"]) { --rm-code-bg: #283044; --rm-code-text: #eef0ff; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.keyword,.atrule,.tag) { color: #c4b5fd; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.string,.char,.attr-value,.builtin) { color: #6ee7b7; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.function,.class-name) { color: #93c5fd; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.number,.boolean,.constant) { color: #fcd34d; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.keyword,.atrule,.tag) { color: #c4b5fd; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.string,.char,.attr-value,.builtin) { color: #6ee7b7; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.function,.class-name) { color: #93c5fd; }
:root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.number,.boolean,.constant) { color: #fcd34d; }
.cm-line.cm-rm-h1 { font-size: 1.75em; }
}
+4 -4
View File
@@ -5,8 +5,8 @@ import { applyDisplaySettings, DISPLAY_DEFAULTS, sanitizeDisplaySettings } from
afterEach(() => document.documentElement.removeAttribute('style'))
describe('界面与正文排版设置', () => {
it('旧设置缺少界面字号时使用 16px,保留正文字号', () => {
expect(sanitizeDisplaySettings({ fontSize: 24 })).toMatchObject({ uiFontSize: 16, fontSize: 24 })
it('旧设置缺少界面字号时使用 14px,保留正文字号', () => {
expect(sanitizeDisplaySettings({ fontSize: 24 })).toMatchObject({ uiFontSize: 14, fontSize: 24 })
})
it.each([14, 16, 20])('接受范围内的整数界面字号 %s', uiFontSize => {
@@ -14,7 +14,7 @@ describe('界面与正文排版设置', () => {
})
it.each([13, 21, 16.5, '18', null, Number.NaN, Number.POSITIVE_INFINITY])('拒绝非法界面字号 %s', uiFontSize => {
expect(sanitizeDisplaySettings({ uiFontSize }).uiFontSize).toBe(16)
expect(sanitizeDisplaySettings({ uiFontSize }).uiFontSize).toBe(14)
})
it('应用界面字号不改变正文、代码字体和正文宽度', () => {
@@ -27,7 +27,7 @@ describe('界面与正文排版设置', () => {
expect(style.getPropertyValue('--rm-font-editor')).toContain('Noto Serif SC')
expect(style.getPropertyValue('--rm-font-mono')).toBe('ui-monospace, monospace')
applyDisplaySettings({ ...display, uiFontSize: DISPLAY_DEFAULTS.uiFontSize })
expect(style.getPropertyValue('--rm-ui-font-size')).toBe('16px')
expect(style.getPropertyValue('--rm-ui-font-size')).toBe('14px')
expect(style.getPropertyValue('--rm-font-size')).toBe('24px')
})
})
+2 -2
View File
@@ -1,4 +1,4 @@
// 编辑器和只读分享共用可公开的排版设置;账户、快捷键等设置不进入此模块。
// 编辑器排版设置;账户、快捷键等设置不进入此模块。
export const TEXT_FONTS = {
sans: { label: 'Manrope · 清晰无衬线', css: "'Manrope', 'PingFang SC', 'Microsoft YaHei', sans-serif" },
serif: { label: 'Noto Serif SC · 思源宋体', css: "'Noto Serif SC', 'Source Han Serif SC', serif" },
@@ -8,7 +8,7 @@ export const CODE_FONTS = {
jetbrains: { label: 'JetBrains Mono', css: "'JetBrains Mono', ui-monospace, monospace" },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
}
export const UI_FONT_SIZE = { min: 14, max: 20, default: 16 }
export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
export const DISPLAY_DEFAULTS = { uiFontSize: UI_FONT_SIZE.default, fontSize: 16, contentWidth: 760, textFont: 'sans' as keyof typeof TEXT_FONTS, codeFont: 'jetbrains' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export type DisplaySettings = typeof DISPLAY_DEFAULTS
export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySettings {
+2 -12
View File
@@ -33,21 +33,11 @@ export default defineConfig({
this.emitFile({ type: 'asset', fileName: 'sw.js', source })
},
},
// 开发服务器中把分享地址 /s/{token} 交给分享页入口(生产环境由 Go 服务处理)
{
name: 'rawmark-share-route',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith('/s/')) req.url = '/share.html'
next()
})
},
},
],
build: {
rolldownOptions: {
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用;share.html 为只读分享页(/s/{token})
input: { index: 'index.html', demo: 'demo.html', share: 'share.html' },
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用
input: { index: 'index.html', demo: 'demo.html' },
},
},
worker: { format: 'es' },