diff --git a/README.md b/README.md index e5deaf4..6f66cc9 100644 --- a/README.md +++ b/README.md @@ -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`)。 diff --git a/web/src/app/InlineAppearance.vue b/web/src/app/InlineAppearance.vue new file mode 100644 index 0000000..d3bf7e0 --- /dev/null +++ b/web/src/app/InlineAppearance.vue @@ -0,0 +1,58 @@ + + + diff --git a/web/src/app/MobileToolbar.vue b/web/src/app/MobileToolbar.vue deleted file mode 100644 index 0950982..0000000 --- a/web/src/app/MobileToolbar.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - diff --git a/web/src/app/SelectionToolbar.vue b/web/src/app/SelectionToolbar.vue new file mode 100644 index 0000000..24d3fdb --- /dev/null +++ b/web/src/app/SelectionToolbar.vue @@ -0,0 +1,129 @@ + + + diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue index e4b46c1..d4a25c9 100644 --- a/web/src/app/Workspace.vue +++ b/web/src/app/Workspace.vue @@ -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(() => { - + diff --git a/web/src/app/app.css b/web/src/app/app.css index 37b3853..ef26a3c 100644 --- a/web/src/app/app.css +++ b/web/src/app/app.css @@ -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); } } diff --git a/web/src/editor/format.ts b/web/src/editor/format.ts index 47d301f..d167c7f 100644 --- a/web/src/editor/format.ts +++ b/web/src/editor/format.ts @@ -1,4 +1,4 @@ -// Markdown 格式命令:电脑上的快捷键与手机键盘上方的工具条共用 +// Markdown 格式命令:快捷键与选区工具栏共用 import { EditorSelection, type ChangeSpec, type EditorState, type TransactionSpec } from '@codemirror/state' /** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */ diff --git a/web/src/editor/inlineStyles.test.ts b/web/src/editor/inlineStyles.test.ts new file mode 100644 index 0000000..757bd54 --- /dev/null +++ b/web/src/editor/inlineStyles.test.ts @@ -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('hello') + expect(selectionAppearance(e.target.state).underline).toBe(true) + e.run(toggleBold) + expect(renderMarkdown(e.doc)).toBe('

hello

\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('hello') + 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('hello world', 'world') + e.run(applyInlineStyles({ color: '#0000ff' })) + expect(e.doc).toBe('hello world') + e.run(clearInlineStyles) + expect(e.doc).toBe('hello world') + }) + it('跨列表、引用和链接操作保留 Markdown 结构与地址', () => { + const e = editor('- **one**\n- [two](https://example.com)\n\n> three') + e.run(applyInlineStyles({ color: '#ff0000' })) + expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three') + e.run(clearInlineStyles) + expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three') + }) + it('清除嵌套外观时保留下划线、高亮和链接', () => { + const e = editor('==one== [two](https://example.com)') + e.run(clearInlineStyles) + expect(renderMarkdown(e.doc)).toBe('

one two

\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('one two three') + 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) + 'lastword') + }) + 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`) + expect(html).toContain(`<${tag}>two`) + 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('

one two

\n') + e.select('two') + e.run(toggleSelectionMark('StrongEmphasis')) + expect(renderMarkdown(e.doc)).toBe('

one two

\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('- first \n continued\n\n> quote\n> next') + const html = renderMarkdown(e.doc) + expect(html).toContain('
') + expect((html.match(/
  • /g) ?? []).length).toBe(1) + expect((html.match(/
    /g) ?? []).length).toBe(1) + }) + it('局部修改保留同一外层格式内未选代码的样式', () => { + const bold = editor('**first `code` last**', 'first') + bold.run(applyInlineStyles({ color: '#0000ff' })) + expect(renderMarkdown(bold.doc)).toContain('code') + const color = editor('first `code` last', 'first') + color.run(applyInlineStyles({ color: '#0000ff' })) + expect(renderMarkdown(color.doc)).toContain('code') + }) +}) diff --git a/web/src/editor/inlineStyles.ts b/web/src/editor/inlineStyles.ts new file mode 100644 index 0000000..62da667 --- /dev/null +++ b/web/src/editor/inlineStyles.ts @@ -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 = { StrongEmphasis: '**', Emphasis: '*', Strikethrough: '~~', Highlight: '==' } +const MARK_NODES: Record = { 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 ? `` : '') + (underline ? '' : '') + return { prefix, leading: leading.length, trailing: trailing.length, marks: open.length, text: prefix + (content ? leading + open + content + close + trailing : text) + (underline ? '' : '') + (css ? '' : '') } +} + +/** 只重写选中文字及包围它的外观标签;被拆开的其余文字保留原样式。 */ +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) + } +} diff --git a/web/src/editor/livepreview.ts b/web/src/editor/livepreview.ts index 7918207..69cf251 100644 --- a/web/src/editor/livepreview.ts +++ b/web/src/editor/livepreview.ts @@ -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 diff --git a/web/src/markdown/extensions.ts b/web/src/markdown/extensions.ts index f663c51..c9b575d 100644 --- a/web/src/markdown/extensions.ts +++ b/web/src/markdown/extensions.ts @@ -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] diff --git a/web/src/markdown/html.ts b/web/src/markdown/html.ts index c8d5c70..e093246 100644 --- a/web/src/markdown/html.ts +++ b/web/src/markdown/html.ts @@ -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 `${innerOf(node, 'SubscriptMark')}` case 'Highlight': return `${innerOf(node, 'HighlightMark')}` + case 'Underline': + return `${innerOf(node, 'UnderlineMark')}` + case 'InlineStyle': { + const open = node.getChild('InlineStyleMark')! + const styles = stylesFromTag(text(open.from, open.to))! + return `${innerOf(node, 'InlineStyleMark')}` + } // 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充 case 'InlineMath': { const tex = text(node.from + 1, node.to - 1) diff --git a/web/src/markdown/inlineStyles.test.ts b/web/src/markdown/inlineStyles.test.ts new file mode 100644 index 0000000..2b36780 --- /dev/null +++ b/web/src/markdown/inlineStyles.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { renderMarkdown } from './html' +import { INLINE_FONTS, parseInlineStyles } from './inlineStyles' + +describe('行内外观安全渲染', () => { + it('下划线和白名单样式支持嵌套格式和表格', () => { + expect(renderMarkdown('**bold** [link](https://example.com)')).toBe('

    bold link

    \n') + expect(renderMarkdown('| value |\n| --- |\n| text |')).toContain('text') + expect(parseInlineStyles('font-family: ' + INLINE_FONTS.serif.css)).not.toBeNull() + }) + it('拒绝事件、额外属性、外部资源及任意 CSS', () => { + for (const tag of ['', '', '', '', '']) { + const html = renderMarkdown('text ' + tag + 'unsafe') + expect(html).toContain('<') + expect(html).not.toContain(tag) + } + expect(renderMarkdown('')).not.toContain('