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 @@
+
+
+
+
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${tag}>`) + expect(html).toContain(`<${tag}>two${tag}>`) + 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('/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('