feat(ui): establish glass workbench and local typography

This commit is contained in:
eezd committed 2026-10-22 14:46:22 +08:00
1 parent ed83b2c590
commit d34d1f2183
129 files changed
+1458 -78

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
<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"><Icon name="edit" :size="22" /><span>RawMark</span></button>
<span class="rm-edition">个人知识库</span>
<nav class="rm-primary-nav" aria-label="工作区"><button class="on"><Icon name="folder" />笔记</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>
+34
View File
@@ -0,0 +1,34 @@
<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))
</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>
</template>
+11
View File
@@ -1,6 +1,17 @@
<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',
graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8',
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
menu: 'M4 6h16M4 12h16M4 18h16',
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5',
+12 -8
View File
@@ -39,15 +39,18 @@ function onRootDrop(e: DragEvent) {
<template>
<div class="rm-sidebar-inner">
<div class="rm-sidebar-head">
<span class="rm-brand">RawMark</span>
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" :title="commandTitle('app.search')" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()">
<Icon name="search" />
</button>
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
<button class="rm-icon-btn" title="导入" @click="ws.importing = ''"><Icon name="import" /></button>
<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>
</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>
<div class="rm-tree-label">文件目录 <span>· VAULT EXPLORER</span></div>
<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>
@@ -64,6 +67,7 @@ function onRootDrop(e: DragEvent) {
<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.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>
<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 -9
View File
@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import { commandTitle } from './commands'
import Icon from './Icon.vue'
import { noteTitle } from './paths'
import { closeTab, toggleSourceMode, ws, type Tab } from './workspace'
import { closeTab, createNote, ws, type Tab } from './workspace'
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
const TAB_TYPE = 'application/x-rawmark-tab'
@@ -104,14 +104,7 @@ function closeRight(path: string) {
<Icon name="close" :size="12" />
</button>
</div>
<div v-if="ws.active" class="rm-tabs-actions">
<button class="rm-icon-btn" :title="ws.sourceMode ? '关闭源码编辑' : '开启源码编辑'" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="toggleSourceMode">
<Icon name="source" />
</button>
<button class="rm-icon-btn rm-tabs-outline" :class="{ on: ws.outlineOpen }" :title="commandTitle('view.outline')" @click="ws.outlineOpen = !ws.outlineOpen">
<Icon name="outline" />
</button>
</div>
<button class="rm-icon-btn rm-tab-add" title="新建笔记" @click="createNote('')"><Icon name="plus" /></button>
<template v-if="menu">
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: menu.x + 'px', top: menu.y + 'px' }">
+7 -1
View File
@@ -3,6 +3,8 @@ import { computed, onBeforeUnmount, onMounted } from 'vue'
import { api, ApiError } from './api'
import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue'
import AppHeader from './AppHeader.vue'
import EditorChrome from './EditorChrome.vue'
import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.vue'
import MoveDialog from './MoveDialog.vue'
@@ -96,10 +98,12 @@ onBeforeUnmount(() => {
<template>
<div
class="rm-shell"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed }"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'focus-mode': ws.focusMode }"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
>
<AppHeader />
<div class="rm-workspace">
<aside class="rm-sidebar">
<Sidebar @logout="logout" />
<div class="rm-sidebar-resize" title="拖动调整宽度" @pointerdown.prevent="startResize" />
@@ -116,11 +120,13 @@ onBeforeUnmount(() => {
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" :size="20" /></button>
</header>
<TabBar />
<EditorChrome />
<NoteBanner />
<EditorPane />
<MobileToolbar />
</main>
<OutlinePanel v-if="ws.outlineOpen && ws.active" />
</div>
<QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
+80 -2
View File
@@ -89,12 +89,14 @@ button {
.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: 13px; }
/* ---- 布局 ---- */
.rm-shell { display: flex; height: 100%; }
.rm-shell { display: flex; flex-direction: column; height: 100%; }
.rm-workspace { display: flex; flex: 1; min-height: 0; }
.rm-sidebar {
position: relative;
width: var(--rm-sidebar-width);
flex: none;
background: var(--rm-sidebar-bg);
background: color-mix(in srgb, var(--rm-sidebar-bg) 78%, transparent);
backdrop-filter: blur(18px) saturate(150%);
border-right: 1px solid var(--rm-divider);
}
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@@ -550,3 +552,79 @@ button {
.rm-context-menu { position: fixed; right: auto; width: max-content; }
.rm-tree-row.selected { background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
/* 轻玻璃工作台 */
.rm-global-header { height: 56px; 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 { display: flex; align-items: center; gap: 10px; background: none; border: 0; padding: 0; font-family: var(--rm-font-heading); font-size: 24px; font-weight: 600; cursor: pointer; }
.rm-wordmark svg { color: var(--rm-accent); }
.rm-edition { font-size: 10px; color: var(--rm-text-muted); padding: 4px 8px; border-radius: 5px; background: var(--rm-sidebar-bg); }
.rm-primary-nav { 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: 12px; cursor: pointer; padding: 0 3px; }
.rm-primary-nav .on { color: var(--rm-accent); border-bottom-color: var(--rm-accent); }
.rm-global-search { display: flex; align-items: center; gap: 10px; flex: 1; max-width: 330px; min-width: 120px; 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: 11px; cursor: pointer; }
.rm-global-search kbd { font: inherit; font-size: 9px; margin-left: auto; opacity: .7; white-space: nowrap; }
.rm-header-actions { display: flex; align-items: center; gap: 10px; }
.rm-header-actions .rm-btn { height: 34px; font-size: 11px; }
.rm-avatar { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: none; border-radius: 50%; background: var(--rm-selection); color: var(--rm-accent-strong); font-size: 10px; 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: 12px; font-weight: 600; }
.rm-vault-heading small, .rm-vault-status small { display: block; margin-top: 4px; color: var(--rm-text-muted); font-size: 9px; }
.rm-vault-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: var(--rm-active); color: var(--rm-accent); }
.rm-sidebar-head { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; padding: 14px 12px 22px; border-bottom: 1px solid var(--rm-divider); }
.rm-sidebar-head .rm-icon-btn { width: 100%; height: 54px; flex-direction: column; gap: 8px; font-size: 10px; }
.rm-tree-label { padding: 22px 16px 12px; font-size: 11px; font-weight: 600; color: var(--rm-text-muted); }
.rm-tree-label span { font-size: 8px; margin-left: 4px; opacity: .7; }
.rm-tree { padding: 0 10px 16px; }
.rm-tree-row { height: 36px; font-size: 12px; 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: 32px; font-size: 11px; }
.rm-outline { width: 256px; background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); }
.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-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); }
.rm-tab-add { margin: 4px; }
.rm-editor-chrome { flex: none; padding: 14px 28px 16px; border-bottom: 1px solid var(--rm-divider); }
.rm-breadcrumb { display: flex; align-items: center; gap: 10px; font-size: 10px; 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: 9px; color: var(--rm-text-muted); margin-top: 10px; }
.rm-document-meta strong { font-weight: 500; color: var(--rm-text); }
.rm-document-actions { display: flex; align-items: center; gap: 8px; margin-top: 18px; }
.rm-document-actions .rm-btn { font-size: 10px; height: 28px; }
.rm-mode-switch { display: inline-flex; padding: 3px; border-radius: 9px; background: var(--rm-sidebar-bg); }
.rm-mode-switch button { display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: 10px; 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: 9px; 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-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 { 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); }
.rm-dialog-backdrop { background: rgba(19,27,46,.15); backdrop-filter: blur(8px); }
.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: 36px; }
@media (min-width: 768px) and (max-width: 1199px) {
.rm-global-header { gap: 12px; padding: 0 16px; }
.rm-edition, .rm-global-search kbd { display: none; }
.rm-outline-backdrop { display: block; position: fixed; inset: 56px 0 0; 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: 10px; margin: 0; }
.rm-document-meta > :nth-child(3) { display: none; }
.rm-sidebar-head .rm-icon-btn { width: 100%; height: 56px; }
.rm-tree-label { padding-top: 16px; }
.rm-tree-row { height: 44px; font-size: 14px; }
.rm-outline { width: min(84vw, 320px); }
.rm-auth-card { padding: 28px 24px; }
}
+6
View File
@@ -0,0 +1,6 @@
// 中文按字、其他文字按连续词计数;界面统计不改变 Markdown 原文。
export function readingStats(text: string) {
const han = text.match(/\p{Script=Han}/gu)?.length ?? 0
const words = han + (text.replace(/\p{Script=Han}/gu, ' ').match(/[\p{L}\p{N}]+/gu)?.length ?? 0)
return { words, characters: Array.from(text.replace(/\s/gu, '')).length, minutes: Math.ceil(words / 300) }
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 260, spellcheck: false, tabSize: 4 }
const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings'
+1
View File
@@ -75,6 +75,7 @@ export const ws = reactive({
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
outlineOpen: layout.outlineOpen ?? false,
sourceMode: false,
focusMode: false,
// 最近打开的笔记,最新的在前
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[],