+
- {{ title }}
+
+
@@ -69,6 +112,7 @@ onBeforeUnmount(() => {
+
diff --git a/web/src/app/app.css b/web/src/app/app.css
index b13fa2c..1ce4a5e 100644
--- a/web/src/app/app.css
+++ b/web/src/app/app.css
@@ -106,12 +106,26 @@ button {
/* ---- 布局 ---- */
.rm-shell { display: flex; height: 100%; }
.rm-sidebar {
+ position: relative;
width: var(--rm-sidebar-width);
flex: none;
background: var(--rm-sidebar-bg);
border-right: 1px solid var(--rm-divider);
}
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+.rm-sidebar-resize { position: absolute; top: 0; right: -3px; 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; }
+
+/* ---- 大纲 ---- */
+.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: 13px; 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: 13px; line-height: 1.4; 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-backdrop,
.rm-mobile-bar { display: none; }
@@ -284,7 +298,15 @@ button {
scrollbar-width: none;
flex: none;
}
-.rm-tabs:empty { display: none; }
+.rm-tabs.empty { display: none; }
+.rm-tabs-side,
+.rm-tabs-outline { flex: none; align-self: center; margin-bottom: 4px; }
+.rm-tabs-outline { margin-left: auto; position: sticky; right: 0; background: var(--rm-bg); }
+.rm-tabs-outline.on { color: var(--rm-accent-strong); }
+.rm-tab.dragging { opacity: 0.5; }
+.rm-tab.drop-before { box-shadow: inset 2px 0 0 var(--rm-accent); }
+.rm-tab.drop-after { box-shadow: inset -2px 0 0 var(--rm-accent); }
+.rm-tab-menu { position: fixed; z-index: 20; min-width: 160px; }
.rm-tab {
display: flex;
align-items: center;
@@ -479,13 +501,20 @@ button {
border-bottom: 1px solid var(--rm-divider);
flex: none;
}
- .rm-mobile-bar .rm-icon-btn { width: 40px; height: 40px; }
- .rm-mobile-title { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .rm-mobile-bar .rm-icon-btn { width: 44px; height: 44px; }
+ .rm-mobile-title { flex: 1; min-width: 0; height: 40px; 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 { 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: 15px; }
.rm-save-state { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: transparent; }
.rm-save-state.on { background: var(--rm-accent); }
- .rm-tree-row { height: 40px; font-size: 15px; }
- .rm-tree-more { opacity: 1; width: 36px; height: 36px; }
- .rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
+ /* 触控热区不小于 44px */
+ .rm-tree-row { height: 44px; font-size: 15px; }
+ .rm-tree-more { opacity: 1; width: 44px; height: 44px; }
+ .rm-sidebar-head .rm-icon-btn { width: 44px; height: 44px; }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: 40px; font-size: 16px; }
.rm-auth-card input,
diff --git a/web/src/app/settings.ts b/web/src/app/settings.ts
index 834ee63..0f93e3a 100644
--- a/web/src/app/settings.ts
+++ b/web/src/app/settings.ts
@@ -12,7 +12,9 @@ export const CONTENT_WIDTHS = [
{ label: '全宽', value: 0 },
]
-const DEFAULTS = { fontSize: 16, contentWidth: 760 }
+export const SIDEBAR_WIDTH = { min: 200, max: 480 }
+
+const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 260 }
export type Settings = typeof DEFAULTS
const CACHE_KEY = 'rawmark.settings'
@@ -29,6 +31,10 @@ function sanitize(raw: Record
): Settings {
CONTENT_WIDTHS.map((w) => w.value),
DEFAULTS.contentWidth,
),
+ sidebarWidth:
+ Number.isInteger(raw.sidebarWidth) && (raw.sidebarWidth as number) >= SIDEBAR_WIDTH.min && (raw.sidebarWidth as number) <= SIDEBAR_WIDTH.max
+ ? (raw.sidebarWidth as number)
+ : DEFAULTS.sidebarWidth,
}
}
@@ -38,6 +44,7 @@ function applied(next: Settings) {
const root = document.documentElement.style
root.setProperty('--rm-font-size', settings.fontSize + 'px')
root.setProperty('--rm-content-width', settings.contentWidth ? settings.contentWidth + 'px' : 'none')
+ root.setProperty('--rm-sidebar-width', settings.sidebarWidth + 'px')
}
applied({ ...settings })
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index 5e08849..cd7bf46 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -35,6 +35,9 @@ export interface Toast {
}
const RECENT_KEY = 'rawmark.recent'
+// 本机的界面状态(不属于设置):侧栏折叠、大纲面板
+const LAYOUT_KEY = 'rawmark.layout'
+const layout = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') as { sidebarCollapsed?: boolean; outlineOpen?: boolean }
const RECENT_MAX = 30
export const ws = reactive({
@@ -55,6 +58,10 @@ export const ws = reactive({
sharing: null as string | null,
// 「移动到…」对话框(笔记或文件夹路径)
moving: null as string | null,
+ // 电脑上侧栏折叠
+ sidebarCollapsed: layout.sidebarCollapsed ?? false,
+ // 大纲面板(电脑上在右侧常驻,手机上为浮层)
+ outlineOpen: layout.outlineOpen ?? false,
// 最近打开的笔记,最新的在前
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[],
@@ -87,6 +94,13 @@ let opsInFlight = 0
const externalChange = Annotation.define()
+// 编辑器内容或光标变化的订阅者(大纲面板);不经过 Vue 响应式,避免每次按键都触发组件更新
+const editorListeners = new Set<() => void>()
+export function onEditorUpdate(fn: () => void): () => void {
+ editorListeners.add(fn)
+ return () => editorListeners.delete(fn)
+}
+
export function tabOf(path: string | null): Tab | undefined {
return path === null ? undefined : ws.tabs.find((t) => t.path === path)
}
@@ -164,6 +178,9 @@ function buildState(path: string, doc: string): EditorState {
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
},
extensions: [
+ EditorView.updateListener.of((u) => {
+ if (u.docChanged || u.selectionSet) editorListeners.forEach((fn) => fn())
+ }),
pasteImages({
upload: (file) => api.upload(ref.path, file),
onError: (err) => toast('图片上传失败:' + errorText(err)),
@@ -762,9 +779,17 @@ export async function startWorkspace() {
() => ws.recent,
(r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)),
)
+ watch(
+ () => ({ sidebarCollapsed: ws.sidebarCollapsed, outlineOpen: ws.outlineOpen }),
+ (l) => {
+ localStorage.setItem(LAYOUT_KEY, JSON.stringify(l))
+ // 编辑区宽度变化,需要重新测量
+ requestAnimationFrame(() => view?.requestMeasure())
+ },
+ )
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
watch(
- () => [settings.fontSize, settings.contentWidth],
+ () => [settings.fontSize, settings.contentWidth, settings.sidebarWidth],
() => view?.requestMeasure(),
)
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
@@ -802,6 +827,8 @@ function registerBuiltinCommands() {
// 浏览器不允许网页覆盖 Ctrl+W
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
{ id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') },
+ { id: 'view.sidebar', title: '显示/隐藏侧栏', keys: ['Mod+\\'], run: toggleSidebar },
+ { id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) },
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') },
@@ -811,16 +838,24 @@ function registerBuiltinCommands() {
}
}
+// 手机上侧栏是抽屉,电脑上折叠/展开
+function toggleSidebar() {
+ if (matchMedia('(max-width: 768px)').matches) ws.sidebarOpen = !ws.sidebarOpen
+ else ws.sidebarCollapsed = !ws.sidebarCollapsed
+}
+
// 需要在目录树中操作的命令:切回目录树,移动端打开侧栏
function withSidebar(fn: () => unknown) {
ws.searchOpen = false
ws.sidebarOpen = true
+ ws.sidebarCollapsed = false
void fn()
}
export function openSearch() {
ws.searchOpen = true
ws.sidebarOpen = true
+ ws.sidebarCollapsed = false
// 已打开时重新聚焦并全选,便于直接输入新的搜索词
void nextTick(() => {
const input = document.querySelector('.rm-search-input')