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

+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 地址在当前文档中查找