feat(ui): redesign public reading and complete glass workspace

Add scoped public navigation, reading progress, focus and print controls without loading the editor. Polish responsive settings, graph and media layouts.

Correct share expiry units, preserve math and code during link maintenance, and normalize Markdown destinations for templates and note navigation. Verify browser flows and document the new tools.
This commit is contained in:
eezd committed 2026-10-22 15:43:52 +08:00
1 parent 91a359c2b7
commit 4d1339851b
28 files changed
+472 -66

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ import { createNote, ws } from './workspace'
<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' }" @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>
<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>
+2
View File
@@ -75,6 +75,7 @@ async function reset() {
<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'] })">
@@ -105,4 +106,5 @@ async function reset() {
</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>
</details>
</template>
+4 -1
View File
@@ -48,14 +48,17 @@ function show() {
onMounted(() => {
view = createEditorView(host.value!, EditorState.create())
attachView(view)
document.fonts.addEventListener('loadingdone', measure)
show()
})
function measure() { view.requestMeasure() }
onBeforeUnmount(() => {
document.fonts.removeEventListener('loadingdone', measure)
if (shown) storeViewState(shown.path, view.state)
attachView(null)
view.destroy()
})
watch(() => [ws.active, tab.value?.status], show, { flush: 'post' })
watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => { show(); requestAnimationFrame(() => view.requestMeasure()) }, { flush: 'post' })
</script>
<template>
+2 -2
View File
@@ -38,7 +38,7 @@ const references = computed(() => {
})
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 * 1000 > 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/'))))
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
@@ -88,7 +88,7 @@ async function clean() {
</script>
<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">返回笔记</button></header>
<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-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>
+1 -1
View File
@@ -77,7 +77,7 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' })
.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: 9px; letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: 28px; font-weight: 600; margin: 12px 0; } p { font-size: 12px; 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: 11px; } 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: 12px; }
.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; } 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: 10px; } .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: 10px; color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.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: 10px; } .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: 10px; 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: 12px; 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: 12px; 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: 13px; font-weight: 500; } small { display: block; margin-top: 5px; font-size: 10px; color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: 10px; 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: 23px; } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: 10px; } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
+2 -2
View File
@@ -16,7 +16,7 @@ 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: 'editor', title: '编辑器与模板', icon: 'edit' },
{ id: 'plugins', title: '插件', icon: 'command' },
{ id: 'account', title: '账户', icon: 'user' },
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
@@ -172,7 +172,7 @@ async function changePassword() {
<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">日记使用本地日期命名,已有日记直接打开。模板支持 {{ '{{date}}' }}、{{ '{{time}}' }}、{{ '{{title}}' }}。</p>
<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>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { copyText } from '../editor/widgets'
import { copyText } from '../clipboard'
import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue'
import { toast } from './workspace'
+1 -1
View File
@@ -20,7 +20,7 @@ async function create() {
<div class="rm-dialog-backdrop" @click.self="!busy && (ws.templateOpen = false)" @keydown.esc.stop="!busy && (ws.templateOpen = false)">
<section class="rm-dialog rm-template-dialog" role="dialog" aria-label="从模板新建">
<div class="rm-dialog-head"><h2>从模板开始</h2><button class="rm-icon-btn" title="关闭" :disabled="busy" @click="ws.templateOpen = false"><Icon name="close" /></button></div>
<p class="rm-share-note">模板是 {{ settings.templatesFolder }}/ 下的 Markdown 文件。支持 {{ '{{date}}' }}、{{ '{{time}}' }} 和 {{ '{{title}}' }},图片沿用原模板资源。</p>
<p class="rm-share-note">模板是 {{ settings.templatesFolder }}/ 下的 Markdown 文件。支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code> 和 <code v-pre>{{title}}</code>,图片沿用原模板资源。</p>
<form @submit.prevent="create">
<label>选择模板<select v-model="selected"><option value="">空白笔记</option><option v-for="template in templates" :key="template.path" :value="template.path">{{ template.path }}</option></select></label>
<label>笔记路径<input v-model="path" aria-label="笔记路径" required /></label>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { copyText } from '../editor/widgets'
import { copyText } from '../clipboard'
import { api, ApiError, type ApiToken } from './api'
import Icon from './Icon.vue'
import { toast } from './workspace'
+11 -1
View File
@@ -585,7 +585,7 @@ button {
.rm-outline-head { padding: 18px 14px; font-size: 11px; }
.rm-outline-list { padding: 8px 12px; }
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: 11px; }
.rm-outline-list li.active { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-outline-list li.on { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-tabs { min-height: 40px; padding: 5px 12px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { height: 34px; max-width: 240px; font-size: 11px; 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); }
@@ -667,3 +667,13 @@ button {
@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: 11px; 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-search { display: none; } .rm-header-actions { margin-left: auto; } .rm-primary-nav { margin-left: auto; } }
.rm-setting select { height: auto; min-height: 36px; line-height: 1.4; }
+3 -1
View File
@@ -7,7 +7,9 @@ import { createNoteAt, openNote, toast, ws } from './workspace'
export async function createFromTemplate(path: string, template: string) {
if (ws.entries.some(entry => entry.path === path)) { toast('目标笔记已存在'); return false }
const content = template ? renderTemplate((await api.readFile(template)).content, template, path) : ''
return createNoteAt(path, content)
const created = await createNoteAt(path, content)
if (created) ws.sidebarOpen = false
return created
}
export async function openDailyNote() {
+5 -2
View File
@@ -1,5 +1,6 @@
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { decodeLinkDestination } from '../markdown/links'
import { Annotation, EditorState, Transaction } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue'
@@ -1139,7 +1140,7 @@ export function stopWorkspace() {
async function openMarkdownLink(note: string, href: string) {
const decoded = href.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g, '$1')
const decoded = decodeLinkDestination(href)
let url: URL
try { url = new URL(decoded, 'https://rawmark.local/' + encodePath(note)) }
catch { toast('链接格式无效'); return }
@@ -1148,7 +1149,9 @@ async function openMarkdownLink(note: string, href: string) {
else toast('不支持的链接协议')
return
}
const target = decodeURIComponent(url.pathname.slice(1))
let target: string
try { target = decodeURIComponent(url.pathname.slice(1)); decodeURIComponent(url.hash.slice(1)) }
catch { toast('链接编码无效'); return }
if (!ws.entries.some(e => e.path === target && !e.dir)) { toast('链接目标不存在:' + target); return }
if (!target.endsWith('.md')) { window.open('/api/v1/raw/' + encodePath(target), '_blank', 'noopener'); return }
await openNote(target)