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
+525
-28
No files matched your search
@@ -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>
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { availableCommands, formatKeys } from './commands'
|
import { availableCommands, formatKeys } from './commands'
|
||||||
import { fuzzyMatch } from './fuzzy'
|
import { fuzzyMatch, segments } from './fuzzy'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { dirname, NOTE_EXT, noteTitle } from './paths'
|
import { dirname, NOTE_EXT, noteTitle } from './paths'
|
||||||
import { createNoteAt, openAt, ws } from './workspace'
|
import { createNoteAt, openAt, ws } from './workspace'
|
||||||
@@ -39,18 +39,6 @@ function cancel() {
|
|||||||
previousFocus?.focus()
|
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[] {
|
function noteItems(q: string): Item[] {
|
||||||
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
|
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
|
||||||
const notes = ws.entries.filter((e) => !e.dir)
|
const notes = ws.entries.filter((e) => !e.dir)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
|||||||
|
|
||||||
// 编辑器内置的按键(不在命令注册表中)
|
// 编辑器内置的按键(不在命令注册表中)
|
||||||
const EDITOR_KEYS = [
|
const EDITOR_KEYS = [
|
||||||
|
{ title: '粗体', keys: 'Mod+B' },
|
||||||
|
{ title: '斜体', keys: 'Mod+I' },
|
||||||
|
{ title: '插入链接', keys: 'Mod+K' },
|
||||||
{ title: '撤销', keys: 'Mod+Z' },
|
{ title: '撤销', keys: 'Mod+Z' },
|
||||||
{ title: '重做', keys: 'Mod+Shift+Z' },
|
{ title: '重做', keys: 'Mod+Shift+Z' },
|
||||||
{ title: '缩进 / 取消缩进', keys: 'Tab / Shift+Tab' },
|
{ title: '缩进 / 取消缩进', keys: 'Tab / Shift+Tab' },
|
||||||
|
|||||||
@@ -15,7 +15,36 @@ const dropTarget = ref(false)
|
|||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const draft = ref('')
|
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() {
|
function activate() {
|
||||||
|
// 长按后松手产生的点击不打开笔记
|
||||||
|
if (longPressed) {
|
||||||
|
longPressed = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if (props.node.dir) {
|
if (props.node.dir) {
|
||||||
if (isOpen.value) expanded.delete(props.node.path)
|
if (isOpen.value) expanded.delete(props.node.path)
|
||||||
else expanded.add(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)
|
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
|
menuOpen.value = false
|
||||||
const path = props.node.path
|
const path = props.node.path
|
||||||
if (action === 'note' || action === 'folder') {
|
if (action === 'note' || action === 'folder') {
|
||||||
@@ -49,6 +78,7 @@ function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') {
|
|||||||
void (action === 'note' ? createNote(path) : createFolder(path))
|
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||||
} else if (action === 'rename') ws.renaming = path
|
} else if (action === 'rename') ws.renaming = path
|
||||||
else if (action === 'share') ws.sharing = path
|
else if (action === 'share') ws.sharing = path
|
||||||
|
else if (action === 'move') ws.moving = path
|
||||||
else void remove(path)
|
else void remove(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +119,10 @@ function onDrop(e: DragEvent) {
|
|||||||
:draggable="!isRenaming"
|
:draggable="!isRenaming"
|
||||||
@click="activate"
|
@click="activate"
|
||||||
@contextmenu.prevent="menuOpen = true"
|
@contextmenu.prevent="menuOpen = true"
|
||||||
|
@touchstart.passive="onTouchStart"
|
||||||
|
@touchmove.passive="onTouchMove"
|
||||||
|
@touchend="cancelPress"
|
||||||
|
@touchcancel="cancelPress"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@dragover="onDragOver"
|
@dragover="onDragOver"
|
||||||
@dragleave="dropTarget = false"
|
@dragleave="dropTarget = false"
|
||||||
@@ -115,6 +149,7 @@ function onDrop(e: DragEvent) {
|
|||||||
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||||
</template>
|
</template>
|
||||||
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
<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 v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||||
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { api } from './api'
|
|||||||
import { registerCommand } from './commands'
|
import { registerCommand } from './commands'
|
||||||
import EditorPane from './EditorPane.vue'
|
import EditorPane from './EditorPane.vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
|
import MobileToolbar from './MobileToolbar.vue'
|
||||||
|
import MoveDialog from './MoveDialog.vue'
|
||||||
import NoteBanner from './NoteBanner.vue'
|
import NoteBanner from './NoteBanner.vue'
|
||||||
import QuickSwitcher from './QuickSwitcher.vue'
|
import QuickSwitcher from './QuickSwitcher.vue'
|
||||||
import SettingsDialog from './SettingsDialog.vue'
|
import SettingsDialog from './SettingsDialog.vue'
|
||||||
@@ -65,9 +67,11 @@ onBeforeUnmount(() => {
|
|||||||
<TabBar />
|
<TabBar />
|
||||||
<NoteBanner />
|
<NoteBanner />
|
||||||
<EditorPane />
|
<EditorPane />
|
||||||
|
<MobileToolbar />
|
||||||
</main>
|
</main>
|
||||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||||
<SettingsDialog v-if="ws.settings" />
|
<SettingsDialog v-if="ws.settings" />
|
||||||
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+20
-2
@@ -21,10 +21,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body {
|
||||||
#app {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
|
||||||
|
#app {
|
||||||
|
height: var(--rm-app-height, 100%);
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -132,6 +135,8 @@ button {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.rm-tree-row:hover { background: var(--rm-hover); }
|
.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.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-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; }
|
.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-created p { margin: 0 0 6px; }
|
||||||
.rm-token-value { display: flex; align-items: center; gap: 8px; }
|
.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-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 { 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; }
|
.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; }
|
||||||
|
|||||||
@@ -53,3 +53,15 @@ export function fuzzyMatch(query: string, target: string, titleStart: number): F
|
|||||||
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
|
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
|
||||||
return { score, positions: [...positions].sort((a, b) => a - b) }
|
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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { nextTick, reactive, watch } from 'vue'
|
import { nextTick, reactive, watch } from 'vue'
|
||||||
import { createEditorState, pasteImages } from '../editor'
|
import { createEditorState, insertImageFiles, pasteImages } from '../editor'
|
||||||
import { handleShortcut, registerCommand } from './commands'
|
import { handleShortcut, registerCommand } from './commands'
|
||||||
import { api, ApiError, type Entry } from './api'
|
import { api, ApiError, type Entry } from './api'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
import { connectEvents, type VaultEvent } from './events'
|
import { connectEvents, type VaultEvent } from './events'
|
||||||
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||||
import { loadSettings, settings } from './settings'
|
import { loadSettings, settings } from './settings'
|
||||||
|
import { trackVisualViewport } from './viewport'
|
||||||
|
|
||||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ export const ws = reactive({
|
|||||||
settings: null as SettingsSection | 'index' | null,
|
settings: null as SettingsSection | 'index' | null,
|
||||||
// 分享当前笔记的对话框(笔记路径)
|
// 分享当前笔记的对话框(笔记路径)
|
||||||
sharing: null as string | null,
|
sharing: null as string | null,
|
||||||
|
// 「移动到…」对话框(笔记或文件夹路径)
|
||||||
|
moving: null as string | null,
|
||||||
// 最近打开的笔记,最新的在前
|
// 最近打开的笔记,最新的在前
|
||||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||||
toasts: [] as Toast[],
|
toasts: [] as Toast[],
|
||||||
@@ -116,6 +119,26 @@ export function attachView(v: EditorView | null) {
|
|||||||
view = v
|
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 {
|
export function editorStateOf(path: string): EditorState | undefined {
|
||||||
return editorStates.get(path)
|
return editorStates.get(path)
|
||||||
}
|
}
|
||||||
@@ -750,6 +773,10 @@ export async function startWorkspace() {
|
|||||||
})
|
})
|
||||||
window.addEventListener('pagehide', () => void flushAll(true))
|
window.addEventListener('pagehide', () => void flushAll(true))
|
||||||
window.addEventListener('keydown', handleShortcut)
|
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: 'folder.new', title: '新建文件夹', run: () => withSidebar(() => createFolder('')) },
|
||||||
{ id: 'note.save', title: '保存当前笔记', keys: ['Mod+S'], when: hasNote, run: () => void flush(ws.active!) },
|
{ 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.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.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
|
||||||
|
{ id: 'note.insertImage', title: '插入图片…', when: hasNote, run: insertImagesFromPicker },
|
||||||
// 浏览器不允许网页覆盖 Ctrl+W
|
// 浏览器不允许网页覆盖 Ctrl+W
|
||||||
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
||||||
{ id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') },
|
{ id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') },
|
||||||
|
|||||||
@@ -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 { renderedBlocks } from './blocks'
|
||||||
import { codeBlockHighlight } from './codeblocks'
|
import { codeBlockHighlight } from './codeblocks'
|
||||||
import { editorFocus } from './focus'
|
import { editorFocus } from './focus'
|
||||||
|
import { insertLink, toggleBold, toggleItalic } from './format'
|
||||||
import { livePreview, resolveImageUrl } from './livepreview'
|
import { livePreview, resolveImageUrl } from './livepreview'
|
||||||
import { tableEditing } from './table'
|
import { tableEditing } from './table'
|
||||||
import { markdownTheme } from './theme'
|
import { markdownTheme } from './theme'
|
||||||
|
|
||||||
export { pasteImages } from './paste'
|
export { insertImageFiles, pasteImages } from './paste'
|
||||||
|
|
||||||
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
||||||
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
||||||
@@ -41,6 +42,14 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
|||||||
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
||||||
rawmarkLanguage,
|
rawmarkLanguage,
|
||||||
Prec.high(keymap.of(markdownKeymap)),
|
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,
|
markdownTheme,
|
||||||
livePreview,
|
livePreview,
|
||||||
codeBlockHighlight,
|
codeBlockHighlight,
|
||||||
|
|||||||
+21
-10
@@ -1,4 +1,4 @@
|
|||||||
import type { Extension } from '@codemirror/state'
|
import { Facet, type Extension } from '@codemirror/state'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
export interface PasteImagesOptions {
|
export interface PasteImagesOptions {
|
||||||
@@ -9,11 +9,16 @@ export interface PasteImagesOptions {
|
|||||||
onPending: (delta: 1 | -1) => void
|
onPending: (delta: 1 | -1) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 粘贴或拖入图片:先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接
|
const uploadOptions = Facet.define<PasteImagesOptions, PasteImagesOptions | undefined>({ combine: (v) => v[0] })
|
||||||
export function pasteImages(opts: PasteImagesOptions): Extension {
|
|
||||||
const insert = (view: EditorView, files: File[], pos?: number) => {
|
/**
|
||||||
|
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 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/'))
|
const images = files.filter((f) => f.type.startsWith('image/'))
|
||||||
if (!images.length) return false
|
if (!opts || !images.length) return false
|
||||||
const urls = images.map((f) => URL.createObjectURL(f))
|
const urls = images.map((f) => URL.createObjectURL(f))
|
||||||
const text = urls.map((u) => ``).join('\n')
|
const text = urls.map((u) => ``).join('\n')
|
||||||
const range = view.state.selection.main
|
const range = view.state.selection.main
|
||||||
@@ -39,26 +44,32 @@ export function pasteImages(opts: PasteImagesOptions): Extension {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return EditorView.domEventHandlers({
|
|
||||||
|
// 粘贴或拖入图片
|
||||||
|
export function pasteImages(opts: PasteImagesOptions): Extension {
|
||||||
|
return [
|
||||||
|
uploadOptions.of(opts),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
paste(e, view) {
|
paste(e, view) {
|
||||||
// 个别浏览器/来源只在 items 中提供图片
|
// 个别浏览器/来源只在 items 中提供图片
|
||||||
let files = [...(e.clipboardData?.files ?? [])]
|
let files = [...(e.clipboardData?.files ?? [])]
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
files = [...(e.clipboardData?.items ?? [])].filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter((f) => f !== null)
|
files = [...(e.clipboardData?.items ?? [])].filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter((f) => f !== null)
|
||||||
}
|
}
|
||||||
if (!insert(view, files)) return false
|
if (!insertImageFiles(view, files)) return false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
drop(e, view) {
|
drop(e, view) {
|
||||||
const files = [...(e.dataTransfer?.files ?? [])]
|
const files = [...(e.dataTransfer?.files ?? [])]
|
||||||
const pos = view.posAtCoords(e)
|
const pos = view.posAtCoords(e)
|
||||||
if (!files.length || !insert(view, files, pos ?? undefined)) return false
|
if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 占位可能已被用户编辑移动,按 blob 地址在当前文档中查找
|
// 占位可能已被用户编辑移动,按 blob 地址在当前文档中查找
|
||||||
|
|||||||
Reference in new issue
Block a user