Add tab reordering, sidebar resize and collapse, and outline panel

- Drag tabs to reorder; context menu to close, close others, close right
- Resizable sidebar width (synced setting), collapsible with Mod+\
- Outline panel (Mod+Shift+O): right column on desktop, overlay on mobile
- Mobile: title opens quick switcher, swipe left closes the drawer,
  44px touch targets
This commit is contained in:
eezd committed 2026-10-17 17:44:05 +08:00
1 parent 91da6e116e
commit 64915a7b94
7 files changed
+322 -15

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ const paths = {
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01',
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
}
export type IconName = keyof typeof paths
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import type { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { activeTab, editorView, onEditorUpdate, ws } from './workspace'
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
interface Heading {
level: number
text: string
from: number
}
const headings = ref<Heading[]>([])
const current = ref(-1)
function collect(state: EditorState): Heading[] {
// 长文档的语法树可能尚未解析完,最多等 50ms,之后随编辑更新
const tree = ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state)
const out: Heading[] = []
tree.iterate({
enter(n) {
const m = /^(ATX|Setext)Heading(\d)$/.exec(n.name)
if (!m) return
const line = state.doc.lineAt(n.from)
const text = m[1] === 'ATX' ? line.text.replace(/^\s*#{1,6}\s*/, '').replace(/\s+#+\s*$/, '') : line.text.trim()
out.push({ level: Number(m[2]), text: text || '(无标题)', from: n.from })
return false
},
})
return out
}
let timer: ReturnType<typeof setTimeout> | undefined
let lastDoc: unknown
function refresh() {
const v = editorView()
if (!v || activeTab()?.status !== 'ready') {
headings.value = []
lastDoc = undefined
return
}
// 只移动光标时不重新收集标题
if (v.state.doc !== lastDoc) {
headings.value = collect(v.state)
lastDoc = v.state.doc
}
const head = v.state.selection.main.head
let i = headings.value.length - 1
while (i >= 0 && headings.value[i].from > head) i--
current.value = i
}
function scheduleRefresh() {
clearTimeout(timer)
timer = setTimeout(refresh, 150)
}
function jump(h: Heading) {
const v = editorView()
if (!v) return
v.dispatch({ selection: { anchor: h.from }, effects: EditorView.scrollIntoView(h.from, { y: 'start', yMargin: 16 }) })
if (matchMedia('(max-width: 768px)').matches) ws.outlineOpen = false
else v.focus()
}
let unsubscribe: (() => void) | undefined
onMounted(() => {
unsubscribe = onEditorUpdate(scheduleRefresh)
// 切换标签页时编辑器整体替换状态,不经过更新监听;等编辑器面板换好状态后再读
requestAnimationFrame(refresh)
})
onBeforeUnmount(() => {
unsubscribe?.()
clearTimeout(timer)
})
watch(
() => [ws.active, activeTab()?.status],
() => requestAnimationFrame(refresh),
)
const minLevel = () => Math.min(...headings.value.map((h) => h.level))
</script>
<template>
<div class="rm-outline-backdrop" @click.self="ws.outlineOpen = false">
<aside class="rm-outline" aria-label="大纲">
<div class="rm-outline-head">
<span>大纲</span>
<button class="rm-icon-btn" title="关闭大纲" @click="ws.outlineOpen = false"><Icon name="close" /></button>
</div>
<ul v-if="headings.length" class="rm-outline-list">
<li
v-for="(h, i) in headings"
:key="h.from"
:class="{ on: i === current }"
:style="{ paddingLeft: (h.level - minLevel()) * 14 + 12 + 'px' }"
@mousedown.prevent
@click="jump(h)"
>
{{ h.text }}
</li>
</ul>
<p v-else class="rm-tree-empty">没有标题</p>
</aside>
</div>
</template>
+86 -2
View File
@@ -1,21 +1,94 @@
<script setup lang="ts">
import { ref } from 'vue'
import Icon from './Icon.vue'
import { noteTitle } from './paths'
import { closeTab, ws } from './workspace'
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
const TAB_TYPE = 'application/x-rawmark-tab'
const dragging = ref<string | null>(null)
const dropAt = ref<{ path: string; after: boolean } | null>(null)
function onDragStart(e: DragEvent, path: string) {
dragging.value = path
e.dataTransfer!.setData(TAB_TYPE, path)
e.dataTransfer!.effectAllowed = 'move'
}
function onDragOver(e: DragEvent, path: string) {
if (!e.dataTransfer?.types.includes(TAB_TYPE)) return
e.preventDefault()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
dropAt.value = { path, after: e.clientX > r.left + r.width / 2 }
}
function onDrop(e: DragEvent) {
const from = dragging.value
const target = dropAt.value
dragging.value = dropAt.value = null
if (!from || !target || from === target.path) return
e.preventDefault()
const tab = ws.tabs.splice(
ws.tabs.findIndex((t) => t.path === from),
1,
)[0]
const i = ws.tabs.findIndex((t) => t.path === target.path) + (target.after ? 1 : 0)
ws.tabs.splice(i, 0, tab)
}
function onDragEnd() {
dragging.value = dropAt.value = null
}
// ---- 右键菜单 ----
const menu = ref<{ path: string; x: number; y: number } | null>(null)
function openMenu(e: MouseEvent, path: string) {
menu.value = { path, x: Math.min(e.clientX, innerWidth - 200), y: e.clientY }
}
async function closeMany(keep: (index: number, path: string) => boolean) {
const paths = ws.tabs.filter((t, i) => !keep(i, t.path)).map((t) => t.path)
menu.value = null
for (const p of paths) await closeTab(p)
}
function closeOthers(path: string) {
void closeMany((_, p) => p === path)
}
function closeRight(path: string) {
const at = ws.tabs.findIndex((t) => t.path === path)
void closeMany((i) => i <= at)
}
</script>
<template>
<nav class="rm-tabs" role="tablist">
<nav class="rm-tabs" :class="{ empty: !ws.tabs.length && !ws.sidebarCollapsed }" role="tablist">
<button v-if="ws.sidebarCollapsed" class="rm-icon-btn rm-tabs-side" title="显示侧栏(Ctrl+\)" @click="ws.sidebarCollapsed = false">
<Icon name="menu" />
</button>
<div
v-for="tab in ws.tabs"
:key="tab.path"
class="rm-tab"
:class="{ active: ws.active === tab.path }"
:class="{
active: ws.active === tab.path,
dragging: dragging === tab.path,
'drop-before': dropAt?.path === tab.path && !dropAt.after,
'drop-after': dropAt?.path === tab.path && dropAt.after,
}"
role="tab"
:aria-selected="ws.active === tab.path"
:title="tab.path"
draggable="true"
@click="ws.active = tab.path"
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
@contextmenu.prevent="openMenu($event, tab.path)"
@dragstart="onDragStart($event, tab.path)"
@dragover="onDragOver($event, tab.path)"
@drop="onDrop"
@dragend="onDragEnd"
>
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
@@ -23,5 +96,16 @@ import { closeTab, ws } from './workspace'
<Icon name="close" :size="12" />
</button>
</div>
<button v-if="ws.active" class="rm-icon-btn rm-tabs-outline" :class="{ on: ws.outlineOpen }" title="大纲(Ctrl+Shift+O)" @click="ws.outlineOpen = !ws.outlineOpen">
<Icon name="outline" />
</button>
<template v-if="menu">
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
<div class="rm-menu rm-tab-menu" :style="{ left: menu.x + 'px', top: menu.y + 'px' }">
<button @click="closeMany((_, p) => p !== menu!.path)">关闭</button>
<button @click="closeOthers(menu.path)">关闭其他标签页</button>
<button @click="closeRight(menu.path)">关闭右侧标签页</button>
</div>
</template>
</nav>
</template>
+49 -5
View File
@@ -7,10 +7,12 @@ import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.vue'
import MoveDialog from './MoveDialog.vue'
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 from './Sidebar.vue'
import TabBar from './TabBar.vue'
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
@@ -19,6 +21,26 @@ const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const saving = computed(() => activeTab()?.dirty)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
function startResize(e: PointerEvent) {
const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId)
const startX = e.clientX
const startWidth = settings.sidebarWidth
let width = startWidth
const onMove = (ev: PointerEvent) => {
width = Math.round(Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min, startWidth + ev.clientX - startX)))
document.documentElement.style.setProperty('--rm-sidebar-width', width + 'px')
}
const onUp = () => {
handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp)
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(() => toast('保存侧栏宽度失败'))
}
handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp)
}
async function logout() {
await flushAll()
try {
@@ -33,14 +55,26 @@ async function logout() {
// 移动端:从屏幕左边缘右滑打开侧栏
let touchX: number | null = null
// 侧栏打开时向左滑关闭(以水平方向为主,避免与列表上下滚动冲突)
let touchStart: { x: number; y: number } | null = null
function onTouchStart(e: TouchEvent) {
touchX = e.touches[0].clientX < 24 ? e.touches[0].clientX : null
const t = e.touches[0]
touchX = t.clientX < 24 ? t.clientX : null
touchStart = { x: t.clientX, y: t.clientY }
}
function onTouchMove(e: TouchEvent) {
if (touchX !== null && e.touches[0].clientX - touchX > 60) {
const t = e.touches[0]
if (touchX !== null && t.clientX - touchX > 60) {
ws.sidebarOpen = true
touchX = null
}
if (ws.sidebarOpen && touchStart) {
const dx = t.clientX - touchStart.x
if (dx < -60 && Math.abs(t.clientY - touchStart.y) < Math.abs(dx) / 2) {
ws.sidebarOpen = false
touchStart = null
}
}
}
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
@@ -53,14 +87,23 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="rm-shell" :class="{ 'sidebar-open': ws.sidebarOpen }" @touchstart.passive="onTouchStart" @touchmove.passive="onTouchMove">
<aside class="rm-sidebar"><Sidebar @logout="logout" /></aside>
<div
class="rm-shell"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed }"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
>
<aside class="rm-sidebar">
<Sidebar @logout="logout" />
<div class="rm-sidebar-resize" title="拖动调整宽度" @pointerdown.prevent="startResize" />
</aside>
<div class="rm-backdrop" @click="ws.sidebarOpen = false" />
<main class="rm-main">
<header class="rm-mobile-bar">
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button>
<span class="rm-mobile-title">{{ title }}</span>
<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="大纲" @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>
</header>
@@ -69,6 +112,7 @@ onBeforeUnmount(() => {
<EditorPane />
<MobileToolbar />
</main>
<OutlinePanel v-if="ws.outlineOpen && ws.active" />
<QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
+35 -6
View File
@@ -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,
+8 -1
View File
@@ -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<string, unknown>): 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 })
+36 -1
View File
@@ -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<boolean>()
// 编辑器内容或光标变化的订阅者(大纲面板);不经过 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<HTMLInputElement>('.rm-search-input')