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:
1 parent
eb63b14c8e
commit
91da6e116e
14 files changed
+565
-68
No files matched your search
@@ -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('看[|]()')
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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) => ``).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) => ``).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 地址在当前文档中查找
|
||||
|
||||
Reference in new issue
Block a user