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:
1 parent
91da6e116e
commit
64915a7b94
7 files changed
+322
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user