feat(editor): replace format bar with selection appearance controls

This commit is contained in:
eezd committed 2026-10-24 18:11:57 +08:00
1 parent 19aab631ec
commit 5abc99b89e
15 files changed
+634 -130

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ docker run -d --name rawmark \
- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。
- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { HOLLOW, INLINE_FONTS, SHADOW, type InlineStyles } from '../markdown/inlineStyles'
import { fixedThemeMode } from '../themes/appearance'
import { settings } from './settings'
const props = defineProps<{ styles: InlineStyles; baseSize: number }>()
const emit = defineEmits<{ change: [patch: InlineStyles]; clear: [] }>()
const system = matchMedia('(prefers-color-scheme: dark)')
const systemDark = ref(system.matches)
const onSystem = () => { systemDark.value = system.matches }
system.addEventListener('change', onSystem)
onBeforeUnmount(() => system.removeEventListener('change', onSystem))
const dark = computed(() => (fixedThemeMode(settings.theme) ?? (settings.colorMode === 'system' ? (systemDark.value ? 'dark' : 'light') : settings.colorMode)) === 'dark')
// 思源色板顺序;只影响选中文字,不修改主题背景。
const foreground = computed(() => dark.value
? ['#f39993','#ffd599','#a6d5fa','#b7dfb9','#9aa0a6','#3575f0','#f3a92f','#d23f31','#f47c9c','#4dd0e1','#10df54','#ecd424','#1e1e1e']
: ['#790600','#8a4f00','#005599','#006b04','#5f6368','#3575f0','#ff9200','#d23f31','#a40074','#007080','#006e2a','#6b5a00','#ffffff'])
const background = computed(() => dark.value
? ['#442724','#554636','#28405c','#425347','#3a3f42','#031840','#593905','#3a0c09','#4d1b40','#1a5459','#305415','#4a4712','#dadada']
: ['#f5d1cf','#ffe8c8','#d6eaf9','#d7eed8','#e2e3e4','#acd0fc','#fddaab','#ffb0a9','#fdbfff','#b1ffff','#affad1','#fff88f','#222222'])
const relative = ref(false)
const size = ref(props.baseSize)
watch(() => props.styles['font-size'], value => {
relative.value = value?.endsWith('em') ?? false
size.value = value ? Math.round(parseFloat(value) * (relative.value ? 100 : 1)) : props.baseSize
}, { immediate: true })
function setSize() {
if (!Number.isFinite(size.value) || size.value < (relative.value ? 56 : 9) || size.value > (relative.value ? 450 : 72)) return
emit('change', { 'font-size': relative.value ? `${size.value / 100}em` : `${size.value}px` })
}
function switchSize() {
size.value = Math.max(relative.value ? 56 : 9, Math.min(relative.value ? 450 : 72, Math.round(relative.value ? size.value / props.baseSize * 100 : size.value / 100 * props.baseSize)))
setSize()
}
</script>
<template>
<div class="rm-inline-appearance" role="region" aria-label="文字外观">
<fieldset><legend>配色</legend><div class="rm-inline-swatches">
<button type="button" title="默认配色" aria-label="默认配色" @mousedown.prevent @click="emit('change', { color: undefined, 'background-color': undefined })">A</button>
<button v-for="(color, index) in foreground" :key="index" type="button" :title="'配色 ' + (index + 1)" :aria-label="'配色 ' + (index + 1)" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">A</button>
</div></fieldset>
<fieldset v-for="group in [{ key: 'color', title: '字体颜色', colors: foreground }, { key: 'background-color', title: '背景色', colors: background }]" :key="group.key"><legend>{{ group.title }}</legend><div class="rm-inline-swatches">
<button type="button" :title="'默认' + group.title" :aria-label="'默认' + group.title" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">↺</button>
<button v-for="(color, index) in group.colors" :key="color" type="button" :title="group.title + ' ' + (index + 1)" :aria-label="group.title + ' ' + (index + 1)" :aria-pressed="styles[group.key as keyof InlineStyles] === color" :style="group.key === 'color' ? { color } : { backgroundColor: color, color: foreground[index] }" @mousedown.prevent @click="emit('change', { [group.key]: color })">A</button>
<label class="rm-inline-custom" :title="'自定义' + group.title"><span>自定义</span><input type="color" :aria-label="'自定义' + group.title" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })" /></label>
</div></fieldset>
<fieldset><legend>字体效果</legend><div class="rm-inline-effects">
<button type="button" title="空心" aria-label="空心" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">A</button>
<button type="button" title="投影" aria-label="投影" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">A</button>
</div></fieldset>
<label class="rm-inline-control">字体<select aria-label="局部字体" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">继承正文</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
<div class="rm-inline-control"><span>字号</span><label><input v-model="relative" type="checkbox" aria-label="相对字号" @change="switchSize" /> 相对字号</label><button type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">默认</button></div>
<form class="rm-inline-size" @submit.prevent="setSize"><input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号滑块" @change="setSize" /><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号" @change="setSize" /><span>{{ relative ? '%' : 'px' }}</span></form>
<button type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')">清除样式</button>
</div>
</template>
-104
View File
@@ -1,104 +0,0 @@
<script setup lang="ts">
import { redo, undo } from '@codemirror/commands'
import type { EditorView } from '@codemirror/view'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleInline } from '../editor/format'
import Icon, { type IconName } from './Icon.vue'
import { settings } from './settings'
import { activeTab, onEditorUpdate, ws, editorView, insertImagesFromPicker } from './workspace'
// 底部格式栏与选区工具条;触屏设备仅在编辑器获得焦点时显示底栏。
// 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起
// (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click)
const visible = ref(false)
const floatingToolbar = ref<HTMLDivElement>()
const floating = ref<{ left: number; top: number } | null>(null)
let unsubscribe: (() => void) | undefined
const coarse = matchMedia('(pointer: coarse)')
const FORMAT_BUTTONS: { icon: IconName; title: string; run: (v: EditorView) => unknown }[] = [
{ icon: 'task', title: '任务列表', run: toggleBlock('task') },
{ icon: 'quote', title: '引用', run: toggleBlock('quote') },
{ icon: 'link', title: '链接', run: insertLink },
{ icon: 'image', title: '插入图片', run: () => insertImagesFromPicker() },
{ icon: 'undo', title: '撤销', run: undo },
{ icon: 'redo', title: '重做', run: redo },
]
const SELECTION_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: 'M', title: '高亮', run: toggleInline('==') },
{ label: '`', title: '行内代码', run: toggleCode, cls: 'mono' },
{ label: '↗', title: '链接', run: insertLink },
]
function run(fn: (v: EditorView) => unknown) {
const v = editorView()
if (v) fn(v)
}
// 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断
function sync() {
requestAnimationFrame(async () => {
const v = editorView()
visible.value = !!ws.active && activeTab()?.status === 'ready' && (!coarse.matches || !!v?.hasFocus)
floating.value = null
if (!v || coarse.matches || !settings.selectionToolbar || !v.hasFocus || v.state.selection.main.empty || ws.settings || ws.images !== null) return
const selection = v.state.selection.main
const coords = v.coordsAtPos(selection.head)
const rect = v.scrollDOM.getBoundingClientRect()
if (!coords || coords.bottom < rect.top || coords.top > rect.bottom) return
floating.value = { left: 0, top: 0 }
await nextTick()
const toolbar = floatingToolbar.value
if (!toolbar || !floating.value) return
const { width, height } = toolbar.getBoundingClientRect()
floating.value = {
left: Math.max(12, Math.min(innerWidth - width - 12, coords.left - width / 2)),
top: Math.max(8, Math.min(innerHeight - height - 8, coords.top - height - 8 > rect.top ? coords.top - height - 8 : coords.bottom + 8)),
}
})
}
onMounted(() => {
unsubscribe = onEditorUpdate(sync)
document.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
document.addEventListener('focusin', sync)
document.addEventListener('focusout', sync)
})
onBeforeUnmount(() => {
unsubscribe?.()
document.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
document.removeEventListener('focusin', sync)
document.removeEventListener('focusout', sync)
})
watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, settings.uiFontSize, ws.settings, ws.images], 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 FORMAT_BUTTONS"
:key="b.title"
type="button"
tabindex="-1"
:aria-label="b.title"
@mousedown.prevent
@click="run(b.run)"
>
<Icon :name="b.icon" class="rm-format-icon" />
<span class="rm-format-label" aria-hidden="true">{{ b.title }}</span>
</button>
</div>
</div>
<div ref="floatingToolbar" v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }">
<button v-for="b in SELECTION_BUTTONS" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button>
</div>
</template>
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { EditorSelection } from '@codemirror/state'
import { insertLink, type FormatCommand } from '../editor/format'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from '../editor/inlineStyles'
import type { InlineStyles } from '../markdown/inlineStyles'
import Icon from './Icon.vue'
import InlineAppearance from './InlineAppearance.vue'
import { settings } from './settings'
import { activeTab, onEditorUpdate, ws, editorView } from './workspace'
const root = ref<HTMLElement>()
const floating = ref<{ left: number; top: number; maxHeight: number; maxWidth: number } | null>(null)
const appearanceOpen = ref(false)
const baseSize = ref(settings.fontSize)
const appearance = ref<ReturnType<typeof selectionAppearance>>({ styles: {}, underline: false, bold: false, italic: false, strike: false, highlight: false, link: false })
let unsubscribe: (() => void) | undefined
let frame = 0
let dismissed: EditorSelection | undefined
const buttons = [
{ title: '加粗', label: 'B', cls: 'bold', key: 'bold', run: toggleSelectionMark('StrongEmphasis') },
{ title: '斜体', label: 'I', cls: 'italic', key: 'italic', run: toggleSelectionMark('Emphasis') },
{ title: '下划线', label: 'U', cls: 'underline', key: 'underline', run: toggleUnderline },
{ title: '删除线', label: 'S', cls: 'strike', key: 'strike', run: toggleSelectionMark('Strikethrough') },
{ title: '高亮', label: 'M', cls: 'highlight', key: 'highlight', run: toggleSelectionMark('Highlight') },
] as const
function close(restoreFocus = false) {
dismissed = editorView()?.state.selection
floating.value = null
appearanceOpen.value = false
if (restoreFocus) editorView()?.focus()
}
function run(command: FormatCommand, keepPanel = false) {
const view = editorView()
if (!view || !canFormatSelection(view.state)) return
command(view)
if (!keepPanel) { appearanceOpen.value = false; view.focus() }
sync()
}
function change(patch: InlineStyles) { run(applyInlineStyles(patch), true) }
// 保留 CM 选区,允许面板输入框获得焦点;不用 pointerdown.prevent,以免 WebKit 丢 click。
function sync() {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(async () => {
const view = editorView()
if (view && dismissed && !dismissed.eq(view.state.selection)) dismissed = undefined
const inToolbar = !!root.value?.contains(document.activeElement)
if (!view || !settings.selectionToolbar || !ws.active || activeTab()?.status !== 'ready' || ws.view !== 'notes' || ws.settings || ws.images !== null || (!view.hasFocus && !inToolbar) || !canFormatSelection(view.state) || dismissed?.eq(view.state.selection)) {
floating.value = null
appearanceOpen.value = false
return
}
dismissed = undefined
const coords = view.coordsAtPos(view.state.selection.main.head)
const rect = view.scrollDOM.getBoundingClientRect()
if (!coords || coords.bottom < rect.top || coords.top > rect.bottom) { floating.value = null; appearanceOpen.value = false; return }
appearance.value = selectionAppearance(view.state)
const position = view.domAtPos(view.state.selection.main.from).node
const line = (position instanceof Element ? position : position.parentElement)!.closest('.cm-line')!
baseSize.value = parseFloat(getComputedStyle(line).fontSize)
const viewport = window.visualViewport
const left = viewport?.offsetLeft ?? 0, top = viewport?.offsetTop ?? 0
const width = viewport?.width ?? innerWidth, height = viewport?.height ?? innerHeight
floating.value ??= { left, top, maxHeight: height - 16, maxWidth: width - 16 }
floating.value.maxHeight = Math.max(44, height - 16)
floating.value.maxWidth = width - 16
await nextTick()
if (!root.value || !floating.value) return
const box = root.value.getBoundingClientRect()
const above = coords.top - box.height - 8
floating.value = {
left: Math.max(left + 8, Math.min(left + width - box.width - 8, coords.left - box.width / 2)),
top: Math.max(top + 8, Math.min(top + height - box.height - 8, above >= Math.max(top, rect.top) ? above : coords.bottom + 8)),
maxHeight: Math.max(44, height - 16),
maxWidth: width - 16,
}
})
}
function outside(event: PointerEvent) {
if (event.target instanceof Node && root.value?.contains(event.target)) return
if (floating.value) close()
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && floating.value) { event.preventDefault(); event.stopPropagation(); close(true) }
}
onMounted(() => {
unsubscribe = onEditorUpdate(sync)
document.addEventListener('scroll', sync, true)
document.addEventListener('selectionchange', sync)
document.addEventListener('focusin', sync)
document.addEventListener('focusout', sync)
document.addEventListener('pointerdown', outside, true)
document.addEventListener('keydown', onKey, true)
window.addEventListener('resize', sync)
window.visualViewport?.addEventListener('resize', sync)
window.visualViewport?.addEventListener('scroll', sync)
})
onBeforeUnmount(() => {
unsubscribe?.()
cancelAnimationFrame(frame)
document.removeEventListener('scroll', sync, true)
document.removeEventListener('selectionchange', sync)
document.removeEventListener('focusin', sync)
document.removeEventListener('focusout', sync)
document.removeEventListener('pointerdown', outside, true)
document.removeEventListener('keydown', onKey, true)
window.removeEventListener('resize', sync)
window.visualViewport?.removeEventListener('resize', sync)
window.visualViewport?.removeEventListener('scroll', sync)
})
watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, settings.uiFontSize, ws.settings, ws.images, ws.view], () => { dismissed = undefined; sync() })
watch(appearanceOpen, sync)
watch(() => ws.active, () => { floating.value = null; appearanceOpen.value = false })
</script>
<template>
<Teleport to="body">
<div v-if="floating" ref="root" class="rm-selection-popover" :style="{ left: floating.left + 'px', top: floating.top + 'px', maxHeight: floating.maxHeight + 'px', maxWidth: floating.maxWidth + 'px' }">
<div class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式">
<button type="button" title="外观" aria-label="外观" :aria-expanded="appearanceOpen" aria-controls="rm-inline-appearance" @mousedown.prevent @click="appearanceOpen = !appearanceOpen"><Icon name="palette" /></button>
<button v-for="button in buttons" :key="button.key" type="button" :title="button.title" :aria-label="button.title" :aria-pressed="appearance[button.key]" :class="button.cls" @mousedown.prevent @click="run(button.run)">{{ button.label }}</button>
<button type="button" title="链接" aria-label="链接" :aria-pressed="appearance.link" @mousedown.prevent @click="run(insertLink)"><Icon name="link" /></button>
</div>
<InlineAppearance v-if="appearanceOpen" id="rm-inline-appearance" :styles="appearance.styles" :base-size="baseSize" @change="change" @clear="run(clearInlineStyles, true)" />
</div>
</Teleport>
</template>
+2 -2
View File
@@ -7,7 +7,7 @@ import KnowledgePanel from './KnowledgePanel.vue'
import TemplateDialog from './TemplateDialog.vue'
import EditorChrome from './EditorChrome.vue'
import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.vue'
import SelectionToolbar from './SelectionToolbar.vue'
import MoveDialog from './MoveDialog.vue'
import ImportDialog from './ImportDialog.vue'
import ImageGallery from './ImageGallery.vue'
@@ -124,7 +124,7 @@ onBeforeUnmount(() => {
<EditorChrome />
<NoteBanner />
<EditorPane />
<MobileToolbar />
<SelectionToolbar />
</div>
<KnowledgePanel v-if="ws.view !== 'notes' && ws.images === null" />
<ImageGallery v-if="ws.images !== null" :dir="ws.images" />
+27 -22
View File
@@ -412,24 +412,6 @@ 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: var(--rm-ui-font-small); user-select: all; }
/* ---- 底部格式工具条 ---- */
.rm-format-bar { flex: none; display: flex; justify-content: center; height: var(--rm-control-size); border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-scroll { display: flex; align-items: center; }
.rm-format-bar button { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; width: var(--rm-control-compact); height: var(--rm-control-size); padding: 0; border: 0; border-radius: 5px; background: none; color: var(--rm-text); cursor: pointer; touch-action: manipulation; }
.rm-format-bar button:active { background: var(--rm-hover); }
.rm-format-icon { transform-origin: center bottom; transition: transform 160ms ease; }
.rm-format-label { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translate(-50%, 4px); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-small); line-height: 1.5; white-space: nowrap; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease, visibility 160ms; }
@media (hover: hover) and (pointer: fine) {
.rm-format-bar button:hover .rm-format-icon { transform: translateY(-4px) scale(1.3); }
.rm-format-bar button:hover .rm-format-label { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
}
.rm-format-bar button:focus-visible .rm-format-icon { transform: translateY(-4px) scale(1.3); }
.rm-format-bar button:focus-visible .rm-format-label { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
.rm-format-icon, .rm-format-label { transition: none; }
.rm-format-bar button:is(:hover, :focus-visible) .rm-format-icon { transform: none; }
}
/* ---- 设置 ---- */
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; }
.rm-setting-label { font-size: var(--rm-ui-font-size); }
@@ -683,10 +665,33 @@ button {
.rm-auth-card { padding: 28px 24px; }
}
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
.rm-selection-toolbar { position: fixed; display: flex; align-items: center; box-sizing: border-box; max-width: calc(100vw - 24px); overflow-x: auto; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar button { width: var(--rm-control-compact); height: var(--rm-control-compact); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; cursor: pointer; font: var(--rm-ui-font-size) var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; }
@media(min-width:768px) { .rm-format-bar { box-sizing: content-box; position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 18px); padding: 4px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } }
@media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } }
/* 选区工具栏与局部外观面板 */
.rm-selection-popover { position: fixed; display: flex; flex-direction: column; box-sizing: border-box; max-width: calc(100vw - 16px); padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); color: var(--rm-text); font: var(--rm-ui-font-size)/var(--rm-ui-line-height) var(--rm-font-text); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar { display: flex; flex: none; align-items: center; max-width: 100%; overflow-x: auto; }
.rm-selection-toolbar > button { display: inline-flex; align-items: center; justify-content: center; width: var(--rm-control-size); height: var(--rm-control-size); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; color: inherit; cursor: pointer; font: inherit; touch-action: manipulation; }
.rm-selection-toolbar > button:hover, .rm-selection-toolbar > button[aria-pressed=true], .rm-selection-toolbar > button[aria-expanded=true] { background: var(--rm-selection); color: var(--rm-accent-strong); }
.rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; } .rm-selection-toolbar .underline { text-decoration: underline; } .rm-selection-toolbar .highlight { text-decoration: underline; text-decoration-color: var(--rm-accent); text-decoration-thickness: 3px; }
.rm-inline-appearance { box-sizing: border-box; width: 328px; max-width: 100%; min-height: 0; padding: 4px 8px 8px; overflow-y: auto; overscroll-behavior: contain; border-top: 1px solid var(--rm-divider); }
.rm-inline-appearance fieldset { min-width: 0; margin: 8px 0; padding: 0 0 10px; border: 0; border-bottom: 1px solid var(--rm-divider); }
.rm-inline-appearance legend { padding: 0 0 6px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-inline-swatches { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
.rm-inline-swatches button, .rm-inline-effects button { box-sizing: border-box; min-height: 28px; padding: 2px; border: 1px solid var(--rm-divider); border-radius: 5px; background: var(--rm-bg); color: var(--rm-text); font: inherit; cursor: pointer; }
.rm-inline-swatches button:hover, .rm-inline-effects button:hover, .rm-inline-appearance button[aria-pressed=true] { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-inline-custom { grid-column: span 3; display: flex; align-items: center; justify-content: space-between; gap: 4px; font-size: var(--rm-ui-font-small); }
.rm-inline-custom input { box-sizing: border-box; width: 32px; height: 28px; padding: 2px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-bg); cursor: pointer; }
.rm-inline-effects { display: flex; gap: 8px; } .rm-inline-effects button { width: 36px; font-size: 20px; }
.rm-inline-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0; font-size: var(--rm-ui-font-small); }
.rm-inline-control select { width: min(190px, 100%); min-width: 0; }
.rm-inline-control select, .rm-inline-size input[type=number] { box-sizing: border-box; min-height: var(--rm-control-compact); padding: 2px 4px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-bg); color: inherit; font: inherit; }
.rm-inline-size { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .rm-inline-size input[type=range] { min-width: 0; width: 100%; flex: 1; accent-color: var(--rm-accent); } .rm-inline-size input[type=number] { width: 64px; } .rm-inline-size > span { min-width: 18px; }
.rm-inline-clear { width: 100%; }
@media (pointer: coarse) {
.rm-selection-toolbar > button { width: 44px; height: 44px; }
.rm-inline-swatches { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.rm-inline-effects button, .rm-inline-custom input { min-width: 44px; }
.rm-inline-swatches button, .rm-inline-effects button, .rm-inline-appearance select, .rm-inline-appearance input, .rm-inline-appearance .rm-btn { min-height: 44px; }
.rm-inline-appearance select, .rm-inline-size input[type=number] { font-size: max(16px, var(--rm-ui-font-size)); }
}
.rm-shell { position: relative; }
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); } }
+1 -1
View File
@@ -1,4 +1,4 @@
// Markdown 格式命令:电脑上的快捷键与手机键盘上方的工具条共用
// Markdown 格式命令:快捷键与选区工具栏共用
import { EditorSelection, type ChangeSpec, type EditorState, type TransactionSpec } from '@codemirror/state'
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
+125
View File
@@ -0,0 +1,125 @@
import { EditorSelection, EditorState, Transaction, type TransactionSpec } from '@codemirror/state'
import { undo, redo } from '@codemirror/commands'
import { describe, expect, it } from 'vitest'
import { createEditorState } from './index'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inlineStyles'
import { toggleBold, type FormatCommand } from './format'
import { renderMarkdown } from '../markdown/html'
function editor(doc: string, text: string = doc) {
let state = createEditorState({ doc, extensions: [EditorState.allowMultipleSelections.of(true)] })
const select = (text: string) => {
const from = state.doc.toString().indexOf(text)
if (from < 0) throw new Error('找不到测试选区')
state = state.update({ selection: EditorSelection.range(from, from + text.length) }).state
}
select(text)
const target = { get state() { return state }, dispatch(spec: TransactionSpec | Transaction) { state = spec instanceof Transaction ? spec.state : state.update(spec).state } }
return { target, select, run(command: FormatCommand) { return command(target) }, get doc() { return state.doc.toString() } }
}
describe('局部外观', () => {
it('下划线可切换,并能和 Markdown 粗体叠加', () => {
const e = editor('hello')
e.run(toggleUnderline)
expect(e.doc).toBe('<u>hello</u>')
expect(selectionAppearance(e.target.state).underline).toBe(true)
e.run(toggleBold)
expect(renderMarkdown(e.doc)).toBe('<p><u><strong>hello</strong></u></p>\n')
e.run(toggleUnderline)
expect(e.doc).toBe('**hello**')
})
it('重复修改同一选区会更新标签,撤销和重做保留选区', () => {
const e = editor('hello')
e.run(applyInlineStyles({ color: '#ff0000' }))
e.run(applyInlineStyles({ color: '#0000ff' }))
e.run(applyInlineStyles({ 'font-size': '1.5em' }))
expect(e.doc).toBe('<span style="color: #0000ff; font-size: 1.5em">hello</span>')
expect(e.target.state.sliceDoc(e.target.state.selection.main.from, e.target.state.selection.main.to)).toBe('hello')
expect(undo(e.target)).toBe(true)
expect(redo(e.target)).toBe(true)
expect(e.doc).toContain('font-size: 1.5em')
})
it('修改部分文字,不影响同一标签内其余内容', () => {
const e = editor('<span style="color: #ff0000">hello world</span>', 'world')
e.run(applyInlineStyles({ color: '#0000ff' }))
expect(e.doc).toBe('<span style="color: #ff0000">hello </span><span style="color: #0000ff">world</span>')
e.run(clearInlineStyles)
expect(e.doc).toBe('<span style="color: #ff0000">hello </span>world')
})
it('跨列表、引用和链接操作保留 Markdown 结构与地址', () => {
const e = editor('- **one**\n- [two](https://example.com)\n\n> three')
e.run(applyInlineStyles({ color: '#ff0000' }))
expect(e.doc).toBe('- <span style="color: #ff0000">**one**</span>\n- [<span style="color: #ff0000">two</span>](https://example.com)\n\n> <span style="color: #ff0000">three</span>')
e.run(clearInlineStyles)
expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three')
})
it('清除嵌套外观时保留下划线、高亮和链接', () => {
const e = editor('<span style="color: #ff0000"><u>==one== <span style="font-size: 20px">[two](https://example.com)</span></u></span>')
e.run(clearInlineStyles)
expect(renderMarkdown(e.doc)).toBe('<p><u><mark>one</mark></u><u> </u><a href="https://example.com"><u>two</u></a></p>\n')
})
it('代码、公式及 YAML 选区不允许套用样式', () => {
for (const doc of ['```js\nvalue\n```', '`value`', '$value$', '---\nvalue: true\n---']) {
const e = editor(doc, 'value')
expect(canFormatSelection(e.target.state)).toBe(false)
expect(e.run(toggleUnderline)).toBe(false)
expect(e.doc).toBe(doc)
}
})
it('反向和多选区保留方向与相互独立的样式', () => {
const e = editor('one two three')
e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(3, 0), EditorSelection.range(8, 13)]) })
e.run(toggleUnderline)
expect(e.doc).toBe('<u>one</u> two <u>three</u>')
expect(e.target.state.selection.main.anchor).toBeGreaterThan(e.target.state.selection.main.head)
})
it('未渲染的长文末尾也能格式化;默认外观不制造无效编辑', () => {
const doc = 'paragraph\n\n'.repeat(3000) + 'lastword'
const e = editor(doc, 'lastword')
expect(e.run(clearInlineStyles)).toBe(false)
e.run(toggleUnderline)
expect(e.doc).toBe(doc.slice(0, -8) + '<u>lastword</u>')
})
it('跨段落的 Markdown 格式逐项切换,保留列表和链接', () => {
for (const mark of ['StrongEmphasis', 'Emphasis', 'Strikethrough', 'Highlight'] as const) {
const e = editor('- one\n- [two](https://example.com)\n\n> three')
e.run(toggleSelectionMark(mark))
const html = renderMarkdown(e.doc)
const tag = { StrongEmphasis: 'strong', Emphasis: 'em', Strikethrough: 'del', Highlight: 'mark' }[mark]
expect(html).toContain(`<${tag}>one</${tag}>`)
expect(html).toContain(`<a href="https://example.com"><${tag}>two</${tag}></a>`)
e.run(toggleSelectionMark(mark))
expect(e.doc).toBe('- one\n- [two](https://example.com)\n\n> three')
}
})
it('混合格式选区统一加粗,局部取消不影响剩余文字', () => {
const e = editor('**one** two')
e.run(toggleSelectionMark('StrongEmphasis'))
expect(renderMarkdown(e.doc)).toBe('<p><strong>one</strong> <strong>two</strong></p>\n')
e.select('two')
e.run(toggleSelectionMark('StrongEmphasis'))
expect(renderMarkdown(e.doc)).toBe('<p><strong>one</strong> two</p>\n')
const whitespace = editor(' alpha ')
whitespace.run(toggleSelectionMark('StrongEmphasis'))
expect(whitespace.doc).toBe(' **alpha** ')
expect(whitespace.target.state.sliceDoc(whitespace.target.state.selection.main.from, whitespace.target.state.selection.main.to)).toBe(' **alpha** ')
})
it('多行列表缩进、引用前缀与硬换行保持不变', () => {
const e = editor('- first \n continued\n\n> quote\n> next')
e.run(applyInlineStyles({ color: '#ff0000' }))
expect(e.doc).toBe('- <span style="color: #ff0000">first</span> \n <span style="color: #ff0000">continued</span>\n\n> <span style="color: #ff0000">quote</span>\n> <span style="color: #ff0000">next</span>')
const html = renderMarkdown(e.doc)
expect(html).toContain('<br />')
expect((html.match(/<li>/g) ?? []).length).toBe(1)
expect((html.match(/<blockquote>/g) ?? []).length).toBe(1)
})
it('局部修改保留同一外层格式内未选代码的样式', () => {
const bold = editor('**first `code` last**', 'first')
bold.run(applyInlineStyles({ color: '#0000ff' }))
expect(renderMarkdown(bold.doc)).toContain('<strong><code>code</code></strong>')
const color = editor('<span style="color: #ff0000">first `code` last</span>', 'first')
color.run(applyInlineStyles({ color: '#0000ff' }))
expect(renderMarkdown(color.doc)).toContain('<span style="color: #ff0000"><code>code</code></span>')
})
})
+177
View File
@@ -0,0 +1,177 @@
import { ensureSyntaxTree, language } from '@codemirror/language'
import { EditorSelection, type EditorState } from '@codemirror/state'
import type { SyntaxNode } from '@lezer/common'
import { inlineStyleText, stylesFromTag, type InlineStyles } from '../markdown/inlineStyles'
import type { FormatCommand } from './format'
interface Wrapper { marks: { from: number; to: number }[] }
interface Segment {
from: number; to: number; styles: InlineStyles; underline: boolean; formats: string[]; wrappers: Wrapper[]
}
const TEXT_CONTAINERS = new Set(['Paragraph', 'TableCell', 'Emphasis', 'StrongEmphasis', 'Strikethrough', 'Highlight', 'Superscript', 'Subscript', 'Underline', 'InlineStyle'])
const PROTECTED = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'InlineMath', 'BlockMath', 'FrontMatter', 'HTMLBlock', 'Image'])
type InlineMark = 'StrongEmphasis' | 'Emphasis' | 'Strikethrough' | 'Highlight'
const MARKS: Record<InlineMark, string> = { StrongEmphasis: '**', Emphasis: '*', Strikethrough: '~~', Highlight: '==' }
const MARK_NODES: Record<InlineMark, string> = { StrongEmphasis: 'EmphasisMark', Emphasis: 'EmphasisMark', Strikethrough: 'StrikethroughMark', Highlight: 'HighlightMark' }
function formattingTree(state: EditorState) {
const to = state.selection.ranges.at(-1)!.to
// 后台标签页或长文末尾的选区可能尚未完成增量解析;操作不能悄悄漏掉文字。
return ensureSyntaxTree(state, to, 50) ?? state.facet(language)!.parser.parse(state.doc.toString())
}
/** 从当前语法树取文字片段,保留 Markdown 的标记、链接地址和块级结构。 */
function segments(state: EditorState): Segment[] {
const result: Segment[] = []
function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) {
if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink'].includes(node.name)) {
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
result.push({ from: node.from, to: node.to, styles, underline, formats, wrappers })
return
}
if (PROTECTED.has(node.name)) return
if (node.name === 'InlineStyle' || node.name === 'Underline') {
const marks = node.getChildren(node.name + 'Mark')
if (node.name === 'InlineStyle') styles = { ...styles, ...stylesFromTag(state.sliceDoc(marks[0].from, marks[0].to)) }
else underline = true
wrappers = [...wrappers, { marks }]
}
if (node.name in MARKS) {
formats = [...formats, node.name]
wrappers = [...wrappers, { marks: node.getChildren(MARK_NODES[node.name as InlineMark]) }]
}
if (node.name === 'Link') formats = [...formats, 'Link']
const textContainer = TEXT_CONTAINERS.has(node.name) || /^(ATX|Setext)Heading\d$/.test(node.name) || node.name === 'Link'
const add = (from: number, to: number) => {
for (const match of state.sliceDoc(from, to).matchAll(/[^\n]+/g)) {
const start = from + match.index, end = start + match[0].length
const line = state.doc.lineAt(start)
// 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。
const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length
const contentFrom = Math.max(start, prefixEnd)
if (contentFrom < end) result.push({ from: contentFrom, to: end, styles, underline, formats, wrappers })
}
}
let pos = node.from
let end = node.to
if (node.name === 'Link') {
const marks = node.getChildren('LinkMark')
pos = marks[0].to
end = marks.find(mark => state.sliceDoc(mark.from, mark.to) === ']')!.from
}
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.to <= pos || child.from >= end) continue
if (textContainer && child.from > pos) add(pos, child.from)
if (child.name === 'Escape' || child.name === 'Entity') add(child.from, child.to)
else walk(child, styles, underline, formats, wrappers)
pos = child.to
}
if (textContainer && pos < end) add(pos, end)
}
walk(formattingTree(state).topNode)
return result
}
export function canFormatSelection(state: EditorState): boolean {
if (state.selection.main.empty) return false
let allowed = true
for (const range of state.selection.ranges) formattingTree(state).iterate({ from: range.from, to: range.to,
enter(node) { if (range.from < node.to && range.to > node.from && PROTECTED.has(node.name)) { allowed = false; return false } },
})
return allowed
}
function selected(state: EditorState, segment: Segment) {
return state.selection.ranges.some(range => range.from < segment.to && range.to > segment.from)
}
export function selectionAppearance(state: EditorState) {
const runs = segments(state).filter(segment => selected(state, segment))
const styles: InlineStyles = { ...runs[0]?.styles }
for (const key of Object.keys(styles) as (keyof InlineStyles)[]) {
if (runs.some(run => run.styles[key] !== styles[key])) delete styles[key]
}
const all = (format: string) => runs.length > 0 && runs.every(run => run.formats.includes(format))
return { styles, underline: runs.length > 0 && runs.every(run => run.underline), bold: all('StrongEmphasis'), italic: all('Emphasis'), strike: all('Strikethrough'), highlight: all('Highlight'), link: all('Link') }
}
function wrap(text: string, styles: InlineStyles, underline: boolean, formats: string[]) {
const css = inlineStyleText(styles)
const leading = text.match(/^\s*/u)![0], trailing = text.match(/\s*$/u)![0]
const content = text.slice(leading.length, text.length - trailing.length)
const marks = content ? (['Strikethrough', 'Highlight', 'StrongEmphasis', 'Emphasis'] as const).filter(mark => formats.includes(mark)).map(mark => MARKS[mark]) : []
const open = marks.join(''), close = marks.reverse().join('')
const prefix = (css ? `<span style="${css}">` : '') + (underline ? '<u>' : '')
return { prefix, leading: leading.length, trailing: trailing.length, marks: open.length, text: prefix + (content ? leading + open + content + close + trailing : text) + (underline ? '</u>' : '') + (css ? '</span>' : '') }
}
/** 只重写选中文字及包围它的外观标签;被拆开的其余文字保留原样式。 */
function editAppearance(update: (styles: InlineStyles, underline: boolean, formats: string[]) => { styles: InlineStyles; underline: boolean; formats: string[] }): FormatCommand {
return ({ state, dispatch }) => {
if (!canFormatSelection(state)) return false
const runs = segments(state)
const affected = runs.filter(run => selected(state, run))
const wrappers = new Set(affected.flatMap(run => run.wrappers))
const changes: { from: number; to: number; insert: string }[] = []
const offsets: { from: number; to: number; start: number; leading: number; trailing: number; marks: number; changeFrom: number }[] = []
for (const wrapper of wrappers) for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
for (const run of runs) {
if (!selected(state, run) && !run.wrappers.some(wrapper => wrappers.has(wrapper))) continue
// 同一外层标签内的兄弟标签也要展开,重新写回合并后的有效样式。
for (const wrapper of run.wrappers) if (!wrappers.has(wrapper)) {
wrappers.add(wrapper)
for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
}
const cuts = [...new Set([run.from, run.to, ...state.selection.ranges.flatMap(range => [Math.max(run.from, Math.min(run.to, range.from)), Math.max(run.from, Math.min(run.to, range.to))])])].sort((a, b) => a - b)
let insert = ''
for (let i = 0; i < cuts.length - 1; i++) {
const from = cuts[i], to = cuts[i + 1]
const next = selected(state, { ...run, from, to }) ? update({ ...run.styles }, run.underline, run.formats) : run
const part = wrap(state.sliceDoc(from, to), next.styles, next.underline, next.formats)
offsets.push({ from, to, start: insert.length + part.prefix.length, leading: part.leading, trailing: part.trailing, marks: part.marks, changeFrom: run.from })
insert += part.text
}
if (insert !== state.sliceDoc(run.from, run.to)) changes.push({ from: run.from, to: run.to, insert })
}
if (!changes.length) return false
const changeSet = state.changes(changes.sort((a, b) => a.from - b.from))
const mapped = (pos: number, end: boolean) => {
const piece = offsets.find(piece => end ? piece.from < pos && pos <= piece.to : piece.from <= pos && pos < piece.to)
if (!piece) return changeSet.mapPos(pos, end ? -1 : 1)
const relative = pos - piece.from, bodyEnd = piece.to - piece.from - piece.trailing
const marks = relative < piece.leading || end && relative === piece.leading ? 0
: relative > bodyEnd || !end && relative === bodyEnd ? piece.marks * 2 : piece.marks
return changeSet.mapPos(piece.changeFrom, -1) + piece.start + relative + marks
}
const selection = EditorSelection.create(state.selection.ranges.map(range => {
const from = mapped(range.from, false), to = mapped(range.to, true)
return range.anchor <= range.head ? EditorSelection.range(from, to) : EditorSelection.range(to, from)
}), state.selection.mainIndex)
dispatch({ changes: changeSet, selection, scrollIntoView: true, userEvent: 'input.format' })
return true
}
}
export function applyInlineStyles(patch: InlineStyles): FormatCommand {
return editAppearance((styles, underline, formats) => {
for (const [key, value] of Object.entries(patch) as [keyof InlineStyles, string | undefined][]) {
if (value) styles[key] = value
else delete styles[key]
}
return { styles, underline, formats }
})
}
export const clearInlineStyles = editAppearance((_styles, underline, formats) => ({ styles: {}, underline, formats }))
export const toggleUnderline: FormatCommand = target => {
const remove = selectionAppearance(target.state).underline
return editAppearance((styles, _underline, formats) => ({ styles, underline: !remove, formats }))(target)
}
/** 跨段落格式化按语法片段处理,不把列表标记或链接地址包进 Markdown 定界符。 */
export function toggleSelectionMark(mark: InlineMark): FormatCommand {
return target => {
const runs = segments(target.state).filter(run => selected(target.state, run))
const remove = runs.length > 0 && runs.every(run => run.formats.includes(mark))
return editAppearance((styles, underline, formats) => ({ styles, underline, formats: remove ? formats.filter(value => value !== mark) : [...new Set([...formats, mark])] }))(target)
}
}
+12
View File
@@ -3,6 +3,7 @@ import { StateEffect, Facet, type EditorState, type Range } from '@codemirror/st
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
import { inlineStyleText, stylesFromTag } from '../markdown/inlineStyles'
import {
BulletWidget,
CheckboxWidget,
@@ -140,8 +141,19 @@ function buildDecorations(view: EditorView): DecorationSet {
case 'SuperscriptMark':
case 'SubscriptMark':
case 'HighlightMark':
case 'UnderlineMark':
case 'InlineStyleMark':
if (!touches(node.parent!)) hide(node.from, node.to)
return
case 'Underline':
out.push(Decoration.mark({ class: 'cm-rm-underline' }).range(node.from, node.to))
return
case 'InlineStyle': {
const mark = node.getChild('InlineStyleMark')!
const styles = stylesFromTag(state.sliceDoc(mark.from, mark.to))!
out.push(Decoration.mark({ attributes: { style: inlineStyleText(styles) } }).range(mark.to, node.getChildren('InlineStyleMark').at(-1)!.from))
return
}
case 'Highlight':
out.push(highlightText.range(node.from, node.to))
return
+2 -1
View File
@@ -2,6 +2,7 @@
// 上标 ^x^、下标 ~x~、:emoji: 由 @lezer/markdown 自带的扩展解析
import { tags } from '@lezer/highlight'
import type { BlockContext, LeafBlockParser, Line, MarkdownConfig } from '@lezer/markdown'
import { inlineStyleSyntax } from './inlineStyles'
const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\xA1‐-‧]/
@@ -195,4 +196,4 @@ const FrontMatter: MarkdownConfig = {
],
}
export const typoraExtensions: MarkdownConfig[] = [FrontMatter, Highlight, InlineMath, BlockMath, Footnote, TableOfContents]
export const typoraExtensions: MarkdownConfig[] = [FrontMatter, Highlight, InlineMath, BlockMath, Footnote, TableOfContents, inlineStyleSyntax]
+8
View File
@@ -1,6 +1,7 @@
import type { SyntaxNode } from '@lezer/common'
import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown'
import { typoraExtensions } from './extensions'
import { inlineStyleText, stylesFromTag } from './inlineStyles'
// 与编辑器一致的语法集(lang-markdown 的 markdownLanguage 加 Typora 扩展)
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji, ...typoraExtensions])
@@ -278,6 +279,13 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
return `<sub>${innerOf(node, 'SubscriptMark')}</sub>`
case 'Highlight':
return `<mark>${innerOf(node, 'HighlightMark')}</mark>`
case 'Underline':
return `<u>${innerOf(node, 'UnderlineMark')}</u>`
case 'InlineStyle': {
const open = node.getChild('InlineStyleMark')!
const styles = stylesFromTag(text(open.from, open.to))!
return `<span style="${escapeAttr(inlineStyleText(styles))}">${innerOf(node, 'InlineStyleMark')}</span>`
}
// 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充
case 'InlineMath': {
const tex = text(node.from + 1, node.to - 1)
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from './html'
import { INLINE_FONTS, parseInlineStyles } from './inlineStyles'
describe('行内外观安全渲染', () => {
it('下划线和白名单样式支持嵌套格式和表格', () => {
expect(renderMarkdown('<u>**bold**</u> <span style="color: #AABBCC; font-size: 20px">[link](https://example.com)</span>')).toBe('<p><u><strong>bold</strong></u> <span style="color: #aabbcc; font-size: 20px"><a href="https://example.com">link</a></span></p>\n')
expect(renderMarkdown('| value |\n| --- |\n| <u>text</u> |')).toContain('<td><u>text</u></td>')
expect(parseInlineStyles('font-family: ' + INLINE_FONTS.serif.css)).not.toBeNull()
})
it('拒绝事件、额外属性、外部资源及任意 CSS', () => {
for (const tag of ['<u onclick="alert(1)">', '<span style="color: #aabbcc" onclick="alert(1)">', '<span style="background: url(https://example.com)">', '<span style="position: fixed; color: #aabbcc">', '<span style="font-size: 999px">']) {
const html = renderMarkdown('text ' + tag + 'unsafe</span>')
expect(html).toContain('&lt;')
expect(html).not.toContain(tag)
}
expect(renderMarkdown('<script>alert(1)</script>')).not.toContain('<script>')
expect(renderMarkdown('<u>[x](javascript:alert(1))</u>')).toContain('href="#"')
})
})
+71
View File
@@ -0,0 +1,71 @@
import type { MarkdownConfig } from '@lezer/markdown'
import { CODE_FONTS, TEXT_FONTS } from '../themes/typography'
export const INLINE_FONTS = { ...TEXT_FONTS, mono: CODE_FONTS.jetbrains }
export const HOLLOW = '0.2px currentColor'
export const SHADOW = '1px 1px 2px currentColor'
export type InlineStyles = Partial<Record<'color' | 'background-color' | 'font-family' | 'font-size' | '-webkit-text-stroke' | '-webkit-text-fill-color' | 'text-shadow', string>>
/** 笔记是外部输入;仅允许工具栏实际生成的样式,不开放通用 HTML/CSS。 */
export function parseInlineStyles(value: string): InlineStyles | null {
const result: InlineStyles = {}
for (const declaration of value.split(';')) {
if (!declaration.trim()) continue
const colon = declaration.indexOf(':')
if (colon < 0) return null
const key = declaration.slice(0, colon).trim()
const val = declaration.slice(colon + 1).trim()
switch (key) {
case 'color': case 'background-color':
if (!/^#[\da-f]{6}$/i.test(val)) return null
result[key] = val.toLowerCase(); break
case 'font-family':
if (!Object.values(INLINE_FONTS).some(font => font.css === val)) return null
result[key] = val; break
case 'font-size': {
const match = /^(\d+(?:\.\d+)?)(px|em)$/.exec(val)
if (!match || !(match[2] === 'px' ? +match[1] >= 9 && +match[1] <= 72 : +match[1] >= .56 && +match[1] <= 4.5)) return null
result[key] = val; break
}
case '-webkit-text-stroke':
if (val !== HOLLOW) return null
result[key] = val; break
case '-webkit-text-fill-color':
if (val !== 'transparent') return null
result[key] = val; break
case 'text-shadow':
if (val !== SHADOW) return null
result[key] = val; break
default: return null
}
}
return Object.keys(result).length ? result : null
}
export function inlineStyleText(styles: InlineStyles): string {
return Object.entries(styles).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}: ${value}`).join('; ')
}
export function stylesFromTag(tag: string): InlineStyles | null {
const match = /^<span\s+style="([^"<>]*)"\s*>$/.exec(tag)
return match ? parseInlineStyles(match[1]) : null
}
const underline = { resolve: 'Underline', mark: 'UnderlineMark' }
const appearance = { resolve: 'InlineStyle', mark: 'InlineStyleMark' }
export const inlineStyleSyntax: MarkdownConfig = {
defineNodes: ['Underline', 'UnderlineMark', 'InlineStyle', 'InlineStyleMark'],
parseInline: [{
name: 'InlineAppearance', before: 'HTMLTag',
parse(cx, next, pos) {
if (next !== 60) return -1
const tag = /^<[^<>\n]*>/.exec(cx.slice(pos, cx.end))?.[0]
if (!tag) return -1
if (tag === '<u>') return cx.addDelimiter(underline, pos, pos + tag.length, true, false)
if (tag === '</u>') return cx.addDelimiter(underline, pos, pos + tag.length, false, true)
if (stylesFromTag(tag)) return cx.addDelimiter(appearance, pos, pos + tag.length, true, false)
if (tag === '</span>') return cx.addDelimiter(appearance, pos, pos + tag.length, false, true)
return -1
},
}],
}
+1
View File
@@ -354,6 +354,7 @@ button, input, select, textarea { font: inherit; }
.cm-line:is(.cm-rm-h1,.cm-rm-h2,.cm-rm-h3,.cm-rm-h4,.cm-rm-h5,.cm-rm-h6) { font-family: var(--rm-font-heading); font-weight: 600; }
.cm-line.cm-rm-h1 { font-size: 2.125em; line-height: 1.4; }
.cm-line.cm-rm-h2 { font-size: 1.5em; line-height: 1.5; }
.cm-rm-underline { text-decoration: underline; }
.cm-editor .cm-content { padding-top: 16px; }
.cm-rm-code-lang { font-family: var(--rm-font-mono); }
.cm-line.cm-rm-code-begin::before { border-top: 1px solid var(--rm-divider); }