Add mobile format toolbar, image picker and move-to dialog

- Markdown format commands shared by Ctrl+B/I/K and a toolbar above the
  soft keyboard on touch devices; bold and italic stack correctly
- Fit the app to the visual viewport so the keyboard does not cover the
  caret or the toolbar
- Insert images from the photo library or a file picker
- Move notes and folders with a folder picker from the file tree menu or
  the command palette
- Open the file tree menu with a long press on touch devices
This commit is contained in:
eezd committed 2026-10-17 17:12:43 +08:00
1 parent eb63b14c8e
commit 91da6e116e
14 files changed
+565 -68

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { indentLess, indentMore, redo, undo } from '@codemirror/commands'
import type { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike } from '../editor/format'
import Icon from './Icon.vue'
import { editorView, insertImagesFromPicker } from './workspace'
// 手机键盘上方的 Markdown 工具条:只在触屏设备上、编辑器获得焦点(软键盘弹出)时显示。
// 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起
// (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click)
const visible = ref(false)
const coarse = matchMedia('(pointer: coarse)')
const BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown; cls?: string }[] = [
{ label: 'H', title: '标题', run: cycleHeading, cls: 'bold' },
{ label: 'B', title: '粗体', run: toggleBold, cls: 'bold' },
{ label: 'I', title: '斜体', run: toggleItalic, cls: 'italic' },
{ label: 'S', title: '删除线', run: toggleStrike, cls: 'strike' },
{ label: '•', title: '无序列表', run: toggleBlock('bullet') },
{ label: '1.', title: '有序列表', run: toggleBlock('ordered') },
{ label: '☐', title: '任务列表', run: toggleBlock('task') },
{ label: '❝', title: '引用', run: toggleBlock('quote') },
{ label: '`', title: '行内代码', run: toggleCode, cls: 'mono' },
{ label: '🔗', title: '链接', run: insertLink },
{ label: '🖼', title: '插入图片', run: () => insertImagesFromPicker() },
{ label: '⇥', title: '缩进', run: indentMore },
{ label: '⇤', title: '取消缩进', run: indentLess },
{ label: '↶', title: '撤销', run: undo },
{ label: '↷', title: '重做', run: redo },
]
function run(fn: (v: EditorView) => unknown) {
const v = editorView()
if (v) fn(v)
}
function hideKeyboard() {
editorView()?.contentDOM.blur()
}
// 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断
function sync() {
requestAnimationFrame(() => (visible.value = coarse.matches && !!editorView()?.hasFocus))
}
onMounted(() => {
document.addEventListener('focusin', sync)
document.addEventListener('focusout', sync)
})
onBeforeUnmount(() => {
document.removeEventListener('focusin', sync)
document.removeEventListener('focusout', sync)
})
</script>
<template>
<div v-if="visible" class="rm-format-bar" role="toolbar" aria-label="格式">
<div class="rm-format-scroll">
<button
v-for="b in BUTTONS"
:key="b.title"
type="button"
tabindex="-1"
:class="b.cls"
:title="b.title"
:aria-label="b.title"
@mousedown.prevent
@click="run(b.run)"
>
{{ b.label }}
</button>
</div>
<button type="button" tabindex="-1" class="rm-format-hide" title="收起键盘" aria-label="收起键盘" @mousedown.prevent @click="hideKeyboard">
<Icon name="chevron" class="down" />
</button>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { fuzzyMatch, segments } from './fuzzy'
import Icon from './Icon.vue'
import { basename, dirname, joinPath, noteTitle } from './paths'
import { move, ws } from './workspace'
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
const props = defineProps<{ path: string }>()
const emit = defineEmits<{ close: [] }>()
const input = ref<HTMLInputElement>()
const list = ref<HTMLElement>()
const query = ref('')
const selected = ref(0)
const isDir = computed(() => ws.entries.some((e) => e.path === props.path && e.dir))
const title = computed(() => (isDir.value ? basename(props.path) : noteTitle(props.path)))
// 可选的目标:根目录与所有文件夹;排除当前所在的文件夹,移动文件夹时还要排除它自己及其子文件夹
const targets = computed(() => {
const here = dirname(props.path)
const inside = (d: string) => isDir.value && (d === props.path || d.startsWith(props.path + '/'))
const dirs = ws.entries.filter((e) => e.dir && e.path !== here && !inside(e.path)).map((e) => e.path)
return here === '' ? dirs : ['', ...dirs]
})
const items = computed(() => {
const q = query.value.trim()
if (!q) return [...targets.value].sort((a, b) => a.localeCompare(b, 'zh-CN')).map((path) => ({ path, hits: new Set<number>() }))
return targets.value
.filter((p) => p !== '')
.map((path) => ({ path, m: fuzzyMatch(q, path, path.lastIndexOf('/') + 1) }))
.filter((x) => x.m)
.sort((a, b) => b.m!.score - a.m!.score)
.map((x) => ({ path: x.path, hits: new Set(x.m!.positions) }))
})
watch(query, () => (selected.value = 0))
watch(selected, (i) => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
function choose(i: number) {
const target = items.value[i]
if (!target) return
emit('close')
void move(props.path, joinPath(target.path, basename(props.path)))
}
function onKeydown(e: KeyboardEvent) {
const n = items.value.length
if (e.key === 'Escape') emit('close')
else if (e.key === 'ArrowDown' && n) selected.value = (selected.value + 1) % n
else if (e.key === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
else return
e.preventDefault()
}
onMounted(() => {
// 触屏设备上不自动弹出键盘,文件夹不多时直接点选更方便
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
})
</script>
<template>
<div class="rm-switcher-backdrop" @mousedown.self="emit('close')">
<div class="rm-switcher" role="dialog" :aria-label="`移动“${title}”`">
<div class="rm-search-box">
<Icon name="folder" />
<input ref="input" v-model="query" class="rm-search-input" :placeholder="`把“${title}”移动到…`" enterkeyhint="go" @keydown="onKeydown" />
<button class="rm-icon-btn" title="取消" @click="emit('close')"><Icon name="close" /></button>
</div>
<ul v-if="items.length" ref="list" class="rm-switcher-list">
<li
v-for="(item, i) in items"
:key="item.path"
class="rm-switcher-item"
:class="{ active: i === selected }"
@click="choose(i)"
@mousemove="selected = i"
>
<Icon name="folder" />
<span class="rm-switcher-title">
<template v-if="item.path === ''">根目录</template>
<span v-for="(s, j) in segments(item.path, item.hits)" v-else :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
</li>
</ul>
<p v-else class="rm-tree-empty">没有可移动到的文件夹</p>
</div>
</div>
</template>
+1 -13
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { availableCommands, formatKeys } from './commands'
import { fuzzyMatch } from './fuzzy'
import { fuzzyMatch, segments } from './fuzzy'
import Icon from './Icon.vue'
import { dirname, NOTE_EXT, noteTitle } from './paths'
import { createNoteAt, openAt, ws } from './workspace'
@@ -39,18 +39,6 @@ function cancel() {
previousFocus?.focus()
}
/** 按命中与否把文本切成连续片段 */
function segments(text: string, hits: Set<number>) {
const out: { text: string; hit: boolean }[] = []
for (let i = 0; i < text.length; i++) {
const hit = hits.has(i)
const last = out[out.length - 1]
if (last?.hit === hit) last.text += text[i]
else out.push({ text: text[i], hit })
}
return out
}
function noteItems(q: string): Item[] {
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
const notes = ws.entries.filter((e) => !e.dir)
+3
View File
@@ -20,6 +20,9 @@ const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
// 编辑器内置的按键(不在命令注册表中)
const EDITOR_KEYS = [
{ title: '粗体', keys: 'Mod+B' },
{ title: '斜体', keys: 'Mod+I' },
{ title: '插入链接', keys: 'Mod+K' },
{ title: '撤销', keys: 'Mod+Z' },
{ title: '重做', keys: 'Mod+Shift+Z' },
{ title: '缩进 / 取消缩进', keys: 'Tab / Shift+Tab' },
+36 -1
View File
@@ -15,7 +15,36 @@ const dropTarget = ref(false)
const input = ref<HTMLInputElement>()
const draft = ref('')
// 长按打开菜单:Android 会触发 contextmenu,iOS Safari 不会,统一用触摸计时
const LONG_PRESS_MS = 500
let pressTimer: ReturnType<typeof setTimeout> | undefined
let pressStart: { x: number; y: number } | null = null
let longPressed = false
function onTouchStart(e: TouchEvent) {
longPressed = false
pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
pressTimer = setTimeout(() => {
longPressed = true
menuOpen.value = true
}, LONG_PRESS_MS)
}
function onTouchMove(e: TouchEvent) {
if (pressStart && Math.hypot(e.touches[0].clientX - pressStart.x, e.touches[0].clientY - pressStart.y) > 10) cancelPress()
}
function cancelPress() {
clearTimeout(pressTimer)
pressStart = null
}
function activate() {
// 长按后松手产生的点击不打开笔记
if (longPressed) {
longPressed = false
return
}
if (props.node.dir) {
if (isOpen.value) expanded.delete(props.node.path)
else expanded.add(props.node.path)
@@ -41,7 +70,7 @@ function commitRename() {
if (target && target !== props.node.path) void move(props.node.path, target)
}
function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') {
function runMenu(action: 'note' | 'folder' | 'rename' | 'move' | 'share' | 'delete') {
menuOpen.value = false
const path = props.node.path
if (action === 'note' || action === 'folder') {
@@ -49,6 +78,7 @@ function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') {
void (action === 'note' ? createNote(path) : createFolder(path))
} else if (action === 'rename') ws.renaming = path
else if (action === 'share') ws.sharing = path
else if (action === 'move') ws.moving = path
else void remove(path)
}
@@ -89,6 +119,10 @@ function onDrop(e: DragEvent) {
:draggable="!isRenaming"
@click="activate"
@contextmenu.prevent="menuOpen = true"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="cancelPress"
@touchcancel="cancelPress"
@dragstart="onDragStart"
@dragover="onDragOver"
@dragleave="dropTarget = false"
@@ -115,6 +149,7 @@ function onDrop(e: DragEvent) {
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
</template>
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button>
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
+4
View File
@@ -4,6 +4,8 @@ import { api } from './api'
import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.vue'
import MoveDialog from './MoveDialog.vue'
import NoteBanner from './NoteBanner.vue'
import QuickSwitcher from './QuickSwitcher.vue'
import SettingsDialog from './SettingsDialog.vue'
@@ -65,9 +67,11 @@ onBeforeUnmount(() => {
<TabBar />
<NoteBanner />
<EditorPane />
<MobileToolbar />
</main>
<QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
</div>
</template>
+20 -2
View File
@@ -21,10 +21,13 @@
}
html,
body,
#app {
body {
height: 100%;
}
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
#app {
height: var(--rm-app-height, 100%);
}
body {
overflow: hidden;
-webkit-tap-highlight-color: transparent;
@@ -132,6 +135,8 @@ button {
user-select: none;
}
.rm-tree-row:hover { background: var(--rm-hover); }
/* 长按由应用处理,不弹出 iOS 的系统菜单 */
.rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; width: 14px; color: var(--rm-text-muted); transition: transform 0.15s; }
@@ -378,6 +383,19 @@ button {
.rm-token-created p { margin: 0 0 6px; }
.rm-token-value { display: flex; align-items: center; gap: 8px; }
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: 12px; user-select: all; }
/* ---- 手机键盘上方的格式工具条 ---- */
.rm-format-bar { flex: none; display: flex; height: 44px; border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-scroll { flex: 1; display: flex; overflow-x: auto; scrollbar-width: none; }
.rm-format-scroll::-webkit-scrollbar { display: none; }
.rm-format-bar button { flex: none; min-width: 44px; height: 44px; padding: 0 6px; border: 0; background: none; color: var(--rm-text); font-size: 17px; touch-action: manipulation; }
.rm-format-bar button:active { background: var(--rm-hover); }
.rm-format-bar .bold { font-weight: 700; }
.rm-format-bar .italic { font-style: italic; font-family: Georgia, serif; }
.rm-format-bar .strike { text-decoration: line-through; }
.rm-format-bar .mono { font-family: var(--rm-font-mono); }
.rm-format-hide { border-left: 1px solid var(--rm-divider) !important; }
.down { transform: rotate(90deg); }
/* ---- 设置 ---- */
.rm-settings { flex-direction: row; max-width: 820px; height: min(640px, 85vh); max-height: none; overflow: hidden; }
.rm-settings-nav { flex: none; width: 190px; display: flex; flex-direction: column; gap: 2px; padding: 0 8px 12px; border-right: 1px solid var(--rm-divider); overflow-y: auto; }
+12
View File
@@ -53,3 +53,15 @@ export function fuzzyMatch(query: string, target: string, titleStart: number): F
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
return { score, positions: [...positions].sort((a, b) => a - b) }
}
/** 按命中与否把文本切成连续片段,用于高亮 */
export function segments(text: string, hits: Set<number>) {
const out: { text: string; hit: boolean }[] = []
for (let i = 0; i < text.length; i++) {
const hit = hits.has(i)
const last = out[out.length - 1]
if (last?.hit === hit) last.text += text[i]
else out.push({ text: text[i], hit })
}
return out
}
+19
View File
@@ -0,0 +1,19 @@
// 软键盘弹出时,iOS Safari 与新版 Android Chrome 只缩小可视区域、页面高度不变,
// 编辑器底部(光标、键盘上方的工具条)会被键盘挡住。把应用高度设为可视区域的高度。
// 双指缩放也会改变可视区域,缩放时不调整
export function trackVisualViewport(onShrink: () => void) {
const vv = window.visualViewport
if (!vv) return
let last = vv.height
const update = () => {
const height = vv.scale > 1.01 ? null : vv.height
document.documentElement.style.setProperty('--rm-app-height', height === null ? '100%' : `${height}px`)
// iOS 会把页面整体上推以露出输入位置,应用高度已经适配,推回原位
if (height !== null && vv.offsetTop > 0) window.scrollTo(0, 0)
if (height !== null && height < last) requestAnimationFrame(onShrink)
last = vv.height
}
vv.addEventListener('resize', update)
vv.addEventListener('scroll', update)
update()
}
+30 -1
View File
@@ -3,13 +3,14 @@
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue'
import { createEditorState, pasteImages } from '../editor'
import { createEditorState, insertImageFiles, pasteImages } from '../editor'
import { handleShortcut, registerCommand } from './commands'
import { api, ApiError, type Entry } from './api'
import { drafts } from './drafts'
import { connectEvents, type VaultEvent } from './events'
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
import { loadSettings, settings } from './settings'
import { trackVisualViewport } from './viewport'
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
@@ -52,6 +53,8 @@ export const ws = reactive({
settings: null as SettingsSection | 'index' | null,
// 分享当前笔记的对话框(笔记路径)
sharing: null as string | null,
// 「移动到…」对话框(笔记或文件夹路径)
moving: null as string | null,
// 最近打开的笔记,最新的在前
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[],
@@ -116,6 +119,26 @@ export function attachView(v: EditorView | null) {
view = v
}
/** 当前显示的编辑器视图(手机工具条等需要直接操作编辑器) */
export function editorView(): EditorView | null {
return view
}
/** 从相册/文件中选择图片插入到光标处 */
export function insertImagesFromPicker() {
const v = view
const tab = activeTab()
if (!v || tab?.status !== 'ready') return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.multiple = true
input.addEventListener('change', () => {
if (input.files?.length && view === v) insertImageFiles(v, [...input.files])
})
input.click()
}
export function editorStateOf(path: string): EditorState | undefined {
return editorStates.get(path)
}
@@ -750,6 +773,10 @@ export async function startWorkspace() {
})
window.addEventListener('pagehide', () => void flushAll(true))
window.addEventListener('keydown', handleShortcut)
// 软键盘弹出、编辑区变矮后,让光标保持可见
trackVisualViewport(() => {
if (view?.hasFocus) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest', yMargin: 24 }) })
})
}
// 内置命令不依赖工作区状态,模块载入时注册,排在组件注册的命令(如退出登录)之前
@@ -769,7 +796,9 @@ function registerBuiltinCommands() {
{ id: 'folder.new', title: '新建文件夹', run: () => withSidebar(() => createFolder('')) },
{ id: 'note.save', title: '保存当前笔记', keys: ['Mod+S'], when: hasNote, run: () => void flush(ws.active!) },
{ id: 'note.rename', title: '重命名当前笔记', when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) },
{ id: 'note.move', title: '移动当前笔记到…', when: hasNote, run: () => (ws.moving = ws.active) },
{ id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
{ id: 'note.insertImage', title: '插入图片…', when: hasNote, run: insertImagesFromPicker },
// 浏览器不允许网页覆盖 Ctrl+W
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
{ id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') },
+71
View File
@@ -0,0 +1,71 @@
import { EditorSelection, EditorState } from '@codemirror/state'
import { describe, expect, it } from 'vitest'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, type FormatCommand } from './format'
// 用 | 标记光标、[ ] 标记选区,执行命令后以同样的形式返回结果
function run(cmd: FormatCommand, doc: string): string {
const ranges: { anchor: number; head: number }[] = []
let text = ''
let start = -1
for (const ch of doc) {
if (ch === '|') ranges.push({ anchor: text.length, head: text.length })
else if (ch === '[' && start < 0 && doc.includes(']')) start = text.length
else if (ch === ']' && start >= 0) {
ranges.push({ anchor: start, head: text.length })
start = -2
} else text += ch
}
let state = EditorState.create({
doc: text,
selection: EditorSelection.create(ranges.map((r) => EditorSelection.range(r.anchor, r.head))),
extensions: EditorState.allowMultipleSelections.of(true),
})
cmd({ state, dispatch: (spec) => (state = state.update(spec).state) })
let out = state.doc.toString()
for (const r of [...state.selection.ranges].reverse()) {
out = r.empty ? out.slice(0, r.from) + '|' + out.slice(r.from) : out.slice(0, r.from) + '[' + out.slice(r.from, r.to) + ']' + out.slice(r.to)
}
return out
}
describe('行内标记', () => {
it('加上与去掉', () => {
expect(run(toggleBold, 'a [bc] d')).toBe('a **[bc]** d')
expect(run(toggleBold, 'a **[bc]** d')).toBe('a [bc] d')
expect(run(toggleBold, 'a [**bc**] d')).toBe('a [bc] d')
expect(run(toggleCode, 'x|')).toBe('x`|`')
})
it('粗体与斜体叠加', () => {
expect(run(toggleItalic, '**[x]**')).toBe('***[x]***')
expect(run(toggleItalic, '***[x]***')).toBe('**[x]**')
expect(run(toggleBold, '***[x]***')).toBe('*[x]*')
expect(run(toggleBold, '*[x]*')).toBe('***[x]***')
})
})
describe('行首标记', () => {
it('列表、任务、引用:同类再按去掉,异类替换', () => {
expect(run(toggleBlock('bullet'), 'a|')).toBe('- a|')
expect(run(toggleBlock('bullet'), '- a|')).toBe('a|')
expect(run(toggleBlock('task'), '- a|')).toBe('- [ ] a|')
expect(run(toggleBlock('quote'), '## 标题|')).toBe('> 标题|')
expect(run(toggleBlock('bullet'), '|')).toBe('- |')
})
it('多行:有序列表按行编号,保留缩进', () => {
expect(run(toggleBlock('ordered'), '[a\n b\nc]')).toBe('[1. a\n 2. b\n3. c]')
expect(run(toggleBlock('ordered'), '[1. a\n2. b]')).toBe('[a\nb]')
// 部分行已是该类型时统一加上
expect(run(toggleBlock('bullet'), '[- a\nb]')).toBe('[- a\n- b]')
})
it('标题循环', () => {
expect(run(cycleHeading, '标题|')).toBe('# 标题|')
expect(run(cycleHeading, '# 标题|')).toBe('## 标题|')
expect(run(cycleHeading, '### 标题|')).toBe('标题|')
expect(run(cycleHeading, '- 项|')).toBe('# 项|')
})
})
it('链接', () => {
expect(run(insertLink, '看 [文档] 吧')).toBe('看 [文档](|) 吧')
expect(run(insertLink, '看|')).toBe('看[|]()')
})
+129
View File
@@ -0,0 +1,129 @@
// Markdown 格式命令:电脑上的快捷键与手机键盘上方的工具条共用
import { EditorSelection, type ChangeSpec, type EditorState, type TransactionSpec } from '@codemirror/state'
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
export interface FormatTarget {
readonly state: EditorState
dispatch(spec: TransactionSpec): void
}
export type FormatCommand = (target: FormatTarget) => boolean
/**
* 切换行内标记(粗体 **、斜体 *、删除线 ~~、行内代码 `):选区两侧或选区首尾已有该标记时去掉,否则加上;
* 没有选中文字时插入一对标记,光标放在中间
*/
export function toggleInline(mark: string): FormatCommand {
const n = mark.length
const ch = mark[0]
// 选区两侧连续的标记字符数;* 同时用于斜体与粗体:*** 两者都有,单数个才有斜体
const runLength = (state: EditorState, pos: number, dir: 1 | -1) => {
let k = 0
while (state.sliceDoc(dir > 0 ? pos + k : pos - k - 1, dir > 0 ? pos + k + 1 : pos - k) === ch) k++
return k
}
const wrapped = (state: EditorState, from: number, to: number) => {
const k = Math.min(runLength(state, from, -1), runLength(state, to, 1))
return mark === '*' ? k % 2 === 1 : k >= n
}
return ({ state, dispatch }) => {
const tr = state.changeByRange((r) => {
if (wrapped(state, r.from, r.to)) {
return {
changes: [
{ from: r.from - n, to: r.from },
{ from: r.to, to: r.to + n },
],
range: EditorSelection.range(r.from - n, r.to - n),
}
}
const text = state.sliceDoc(r.from, r.to)
if (text.length >= 2 * n && text.startsWith(mark) && text.endsWith(mark)) {
return { changes: { from: r.from, to: r.to, insert: text.slice(n, -n) }, range: EditorSelection.range(r.from, r.to - 2 * n) }
}
return {
changes: [
{ from: r.from, insert: mark },
{ from: r.to, insert: mark },
],
range: EditorSelection.range(r.from + n, r.to + n),
}
})
dispatch({ ...tr, scrollIntoView: true, userEvent: 'input.format' })
return true
}
}
// 行首的块标记:缩进 + 标题 / 任务 / 无序列表 / 有序列表 / 引用(每行只识别一个)
const LINE_PREFIX = /^(\s*)(#{1,6} |[-*+] \[[ xX]\] |[-*+] |\d+[.)] |> )?/
type PrefixKind = 'heading' | 'task' | 'bullet' | 'ordered' | 'quote'
function kindOf(prefix: string): PrefixKind | null {
if (!prefix) return null
if (prefix.startsWith('#')) return 'heading'
if (prefix.startsWith('>')) return 'quote'
if (/^\d/.test(prefix)) return 'ordered'
return prefix.includes('[') ? 'task' : 'bullet'
}
/** 改写选区涉及的每一行的行首标记;next 返回新的标记 */
function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKind | null)[], index: number) => string): boolean {
const lineNumbers = new Set<number>()
for (const r of state.selection.ranges) {
for (let l = state.doc.lineAt(r.from).number, end = state.doc.lineAt(r.to).number; l <= end; l++) lineNumbers.add(l)
}
const lines = [...lineNumbers].sort((a, b) => a - b).map((l) => state.doc.line(l))
const matches = lines.map((line) => LINE_PREFIX.exec(line.text)!)
const kinds = matches.map((m) => kindOf(m[2] ?? ''))
const specs: ChangeSpec[] = lines.map((line, i) => {
const from = line.from + matches[i][1].length
const current = matches[i][2] ?? ''
return { from, to: from + current.length, insert: next(kinds, i) }
})
const changes = state.changes(specs)
// 光标在行首时随新插入的标记后移;选区则向两端扩展,把新标记包含在内
const selection = EditorSelection.create(
state.selection.ranges.map((r) => {
if (r.empty) return EditorSelection.cursor(changes.mapPos(r.head, 1))
const [lo, hi] = [changes.mapPos(r.from, -1), changes.mapPos(r.to, 1)]
return r.anchor <= r.head ? EditorSelection.range(lo, hi) : EditorSelection.range(hi, lo)
}),
state.selection.mainIndex,
)
dispatch({ changes, selection, scrollIntoView: true, userEvent: 'input.format' })
return true
}
/** 切换列表或引用:所选各行都已是该类型时去掉,否则全部改为该类型(替换原有的其他块标记) */
export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand {
return (target) =>
editPrefixes(target, (kinds, i) => {
if (kinds.every((k) => k === kind)) return ''
return { task: '- [ ] ', bullet: '- ', ordered: `${i + 1}. `, quote: '> ' }[kind]
})
}
/** 标题级别循环:正文 → 一级 → 二级 → 三级 → 正文;以第一行为准 */
export const cycleHeading: FormatCommand = (target) => {
const first = LINE_PREFIX.exec(target.state.doc.lineAt(target.state.selection.main.from).text)![2] ?? ''
const level = kindOf(first) === 'heading' ? first.length - 1 : 0
const prefix = level >= 3 ? '' : '#'.repeat(level + 1) + ' '
return editPrefixes(target, () => prefix)
}
/** 插入链接:选中文字作为链接文字,光标放到地址处;没有选中时光标放在文字处 */
export const insertLink: FormatCommand = ({ state, dispatch }) => {
const tr = state.changeByRange((r) => {
const text = state.sliceDoc(r.from, r.to)
const insert = `[${text}]()`
const cursor = text ? r.from + insert.length - 1 : r.from + 1
return { changes: { from: r.from, to: r.to, insert }, range: EditorSelection.cursor(cursor) }
})
dispatch({ ...tr, scrollIntoView: true, userEvent: 'input.format' })
return true
}
export const toggleBold = toggleInline('**')
export const toggleItalic = toggleInline('*')
export const toggleStrike = toggleInline('~~')
export const toggleCode = toggleInline('`')
+10 -1
View File
@@ -8,11 +8,12 @@ import { typoraExtensions } from '../markdown/extensions'
import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus'
import { insertLink, toggleBold, toggleItalic } from './format'
import { livePreview, resolveImageUrl } from './livepreview'
import { tableEditing } from './table'
import { markdownTheme } from './theme'
export { pasteImages } from './paste'
export { insertImageFiles, pasteImages } from './paste'
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
@@ -41,6 +42,14 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
rawmarkLanguage,
Prec.high(keymap.of(markdownKeymap)),
// 与 Typora 一致的格式快捷键
Prec.high(
keymap.of([
{ key: 'Mod-b', run: toggleBold, preventDefault: true },
{ key: 'Mod-i', run: toggleItalic, preventDefault: true },
{ key: 'Mod-k', run: insertLink, preventDefault: true },
]),
),
markdownTheme,
livePreview,
codeBlockHighlight,
+61 -50
View File
@@ -1,4 +1,4 @@
import type { Extension } from '@codemirror/state'
import { Facet, type Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
@@ -9,56 +9,67 @@ export interface PasteImagesOptions {
onPending: (delta: 1 | -1) => void
}
// 粘贴或拖入图片:先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接
export function pasteImages(opts: PasteImagesOptions): Extension {
const insert = (view: EditorView, files: File[], pos?: number) => {
const images = files.filter((f) => f.type.startsWith('image/'))
if (!images.length) return false
const urls = images.map((f) => URL.createObjectURL(f))
const text = urls.map((u) => `![](${u})`).join('\n')
const range = view.state.selection.main
const from = pos ?? range.from
const to = pos ?? range.to
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
userEvent: 'input.paste',
})
images.forEach((file, i) => {
opts.onPending(1)
opts
.upload(file)
.then((link) => replaceUrl(view, urls[i], link))
.catch((err) => {
removePlaceholder(view, urls[i])
opts.onError(err)
})
.finally(() => {
opts.onPending(-1)
URL.revokeObjectURL(urls[i])
})
})
return true
}
return EditorView.domEventHandlers({
paste(e, view) {
// 个别浏览器/来源只在 items 中提供图片
let files = [...(e.clipboardData?.files ?? [])]
if (!files.length) {
files = [...(e.clipboardData?.items ?? [])].filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter((f) => f !== null)
}
if (!insert(view, files)) return false
e.preventDefault()
return true
},
drop(e, view) {
const files = [...(e.dataTransfer?.files ?? [])]
const pos = view.posAtCoords(e)
if (!files.length || !insert(view, files, pos ?? undefined)) return false
e.preventDefault()
return true
},
const uploadOptions = Facet.define<PasteImagesOptions, PasteImagesOptions | undefined>({ combine: (v) => v[0] })
/**
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
* 返回是否有图片被插入;编辑器未配置上传(pasteImages)时不处理
*/
export function insertImageFiles(view: EditorView, files: File[], pos?: number): boolean {
const opts = view.state.facet(uploadOptions)
const images = files.filter((f) => f.type.startsWith('image/'))
if (!opts || !images.length) return false
const urls = images.map((f) => URL.createObjectURL(f))
const text = urls.map((u) => `![](${u})`).join('\n')
const range = view.state.selection.main
const from = pos ?? range.from
const to = pos ?? range.to
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
userEvent: 'input.paste',
})
images.forEach((file, i) => {
opts.onPending(1)
opts
.upload(file)
.then((link) => replaceUrl(view, urls[i], link))
.catch((err) => {
removePlaceholder(view, urls[i])
opts.onError(err)
})
.finally(() => {
opts.onPending(-1)
URL.revokeObjectURL(urls[i])
})
})
return true
}
// 粘贴或拖入图片
export function pasteImages(opts: PasteImagesOptions): Extension {
return [
uploadOptions.of(opts),
EditorView.domEventHandlers({
paste(e, view) {
// 个别浏览器/来源只在 items 中提供图片
let files = [...(e.clipboardData?.files ?? [])]
if (!files.length) {
files = [...(e.clipboardData?.items ?? [])].filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter((f) => f !== null)
}
if (!insertImageFiles(view, files)) return false
e.preventDefault()
return true
},
drop(e, view) {
const files = [...(e.dataTransfer?.files ?? [])]
const pos = view.posAtCoords(e)
if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false
e.preventDefault()
return true
},
}),
]
}
// 占位可能已被用户编辑移动,按 blob 地址在当前文档中查找