mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(rich-markdown-editor): round-trip gate, VSCode/style paste, highlight, block reorder (#5539)
* fix(rich-markdown-editor): tighten read-only gate for uppercase entities and orphan ref-defs Two silent-corruption cases the idempotency probe can't see: - The HTML-entity safe-list used a case-insensitive regex, so `&`/`<`/`>` were treated as the round-trippable canonical entities and let through as editable, but the serializer only round-trips the lowercase forms and mangles the uppercase ones. Make the safe-list case-sensitive. - An unused link/image reference definition (`[x]: url` with no `[x]` reference) is dropped entirely on serialize, a deletion the idempotency probe misses. Detect orphan definitions and open read-only; used definitions still inline losslessly and stay editable. The use check tolerates bracket-internal padding (`[ x ]`), and GFM footnote definitions (`[^id]: …`) are excluded since they round-trip verbatim. * feat(rich-markdown-editor): VSCode code paste and strip <style>/<script> from pasted HTML - Code copied from VSCode carries a `vscode-editor-data` payload with the source language, but its text/html is per-token colored spans that ProseMirror flattens into plain paragraphs. Read the payload and paste a real fenced code block with the mapped language. markdown/plaintext modes resolve to no language and fall through, so markdown copied from VSCode still parses as rich content rather than a fenced block. - Google Sheets and Word prepend a `<style>` block of CSS that PM's DOM parser walks into the document as literal text. Strip <style>/<script> in transformPastedHTML before parsing. - Add a `==…==` inline-mark hint (allowing a lone interior `=`) so a plain-text paste of `==highlight==` routes through the markdown parser and becomes a highlight mark. * feat(rich-markdown-editor): add highlight (==mark==) support Adds a highlight mark rendered as <mark> and serialized to/from ==text== (Pandoc/Obsidian syntax). A custom inline tokenizer parses ==text== (inner text parsed as inline markdown so nested marks like ==**bold**== survive; the body allows a lone `=` so ==a=b== round-trips). `==` cannot be encoded in the delimiter, so an appendTransaction guard strips the mark from any text that ends up containing `==` (e.g. a toolbar highlight over a==b), keeping the text and never emitting the corrupting ==a==b==. Comparison operators (x == y) stay literal. Wired with an input rule, paste rule, Mod-Shift-H, a bubble-menu button, and themed <mark> styling. Also locks in mark-stacking round-trips across contexts. * feat(rich-markdown-editor): keyboard block reordering (Mod-Shift-Arrow) Mod-Shift-ArrowUp/ArrowDown swaps the current top-level block with its neighbour, carrying the caret at its original offset (newBefore + offset, no off-by-one), and no-ops at the document edges. Exposed as moveBlockUp/moveBlockDown commands (the keyboard shortcuts call them). Pure UI interaction, no schema change. Covered by tests (order, caret offset, edge no-op, list stays intact).
This commit is contained in:
+94
@@ -0,0 +1,94 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
|
||||
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
|
||||
const { $from } = state.selection
|
||||
if ($from.depth === 0) return null
|
||||
return { from: $from.before(1), to: $from.after(1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
|
||||
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
|
||||
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
|
||||
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
|
||||
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
|
||||
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
|
||||
*/
|
||||
function moveBlock(
|
||||
state: EditorState,
|
||||
dispatch: ((tr: Transaction) => void) | undefined,
|
||||
direction: 'up' | 'down'
|
||||
): boolean {
|
||||
const block = currentTopLevelBlock(state)
|
||||
if (!block) return false
|
||||
const { from, to } = block
|
||||
const up = direction === 'up'
|
||||
|
||||
if (up ? from === 0 : to >= state.doc.content.size) return false
|
||||
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
|
||||
if ($neighbour.depth === 0) return false
|
||||
if (!dispatch) return true
|
||||
|
||||
const spanFrom = up ? $neighbour.before(1) : from
|
||||
const spanTo = up ? to : $neighbour.after(1)
|
||||
const moving = state.doc.slice(from, to).content
|
||||
const neighbour = up
|
||||
? state.doc.slice(spanFrom, from).content
|
||||
: state.doc.slice(to, spanTo).content
|
||||
const tr = state.tr.replaceWith(
|
||||
spanFrom,
|
||||
spanTo,
|
||||
up ? moving.append(neighbour) : neighbour.append(moving)
|
||||
)
|
||||
|
||||
const newBefore = up ? spanFrom : spanFrom + neighbour.size
|
||||
const offset = state.selection.from - from
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
|
||||
)
|
||||
dispatch(tr.scrollIntoView())
|
||||
return true
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
blockMover: {
|
||||
/** Move the current top-level block up one position, carrying the caret. */
|
||||
moveBlockUp: () => ReturnType
|
||||
/** Move the current top-level block down one position, carrying the caret. */
|
||||
moveBlockDown: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
|
||||
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
|
||||
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
|
||||
*/
|
||||
export const BlockMover = Extension.create({
|
||||
name: 'blockMover',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
moveBlockUp:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
moveBlock(state, dispatch, 'up'),
|
||||
moveBlockDown:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
moveBlock(state, dispatch, 'down'),
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(),
|
||||
'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(),
|
||||
}
|
||||
},
|
||||
})
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import type { Extensions } from '@tiptap/core'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { BlockMover } from './block-mover'
|
||||
import { CodeBlockWithLanguage } from './code-block'
|
||||
import { CodeBlockHighlight } from './code-highlight'
|
||||
import { LinkEmbed } from './embed/link-embed'
|
||||
@@ -44,6 +45,7 @@ export function createMarkdownEditorExtensions({
|
||||
SlashCommand,
|
||||
Mention,
|
||||
RichMarkdownKeymap,
|
||||
BlockMover,
|
||||
MarkdownPaste,
|
||||
Placeholder.configure({ placeholder }),
|
||||
...(embeds ? [LinkEmbed] : []),
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ import {
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { MarkdownCodeBlock } from './code-block'
|
||||
import { Highlight } from './highlight'
|
||||
import { MarkdownImage } from './image'
|
||||
import { MarkdownLinkInputRule } from './link-input-rule'
|
||||
import { MarkdownMention } from './mention/mention-node'
|
||||
@@ -130,6 +131,7 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
|
||||
}),
|
||||
BlockSafeParagraph,
|
||||
InlineCode,
|
||||
Highlight,
|
||||
codeBlock,
|
||||
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
|
||||
nodeViews.mention ?? MarkdownMention,
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
JSONContent,
|
||||
MarkdownParseHelpers,
|
||||
MarkdownRendererHelpers,
|
||||
MarkdownToken,
|
||||
} from '@tiptap/core'
|
||||
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
/**
|
||||
* `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=`
|
||||
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
|
||||
* the closing `==` still terminates the run.
|
||||
*/
|
||||
const HIGHLIGHT_BODY = String.raw`(?:[^=]|=(?!=))+?`
|
||||
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
|
||||
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
|
||||
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)
|
||||
const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g')
|
||||
|
||||
/**
|
||||
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
|
||||
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
|
||||
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
|
||||
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
|
||||
*
|
||||
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
|
||||
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
|
||||
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
|
||||
*
|
||||
* A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the
|
||||
* `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The
|
||||
* tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any
|
||||
* text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an
|
||||
* unrepresentable highlight and serialization stays lossless.
|
||||
*/
|
||||
export const Highlight = Mark.create({
|
||||
name: 'highlight',
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'mark' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['mark', mergeAttributes(HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
|
||||
},
|
||||
|
||||
markdownTokenName: 'highlight',
|
||||
markdownTokenizer: {
|
||||
name: 'highlight',
|
||||
level: 'inline' as const,
|
||||
start: (src: string) => src.indexOf('=='),
|
||||
tokenize(src: string): MarkdownToken | undefined {
|
||||
const match = HIGHLIGHT_TOKEN.exec(src)
|
||||
if (!match) return undefined
|
||||
return { type: 'highlight', raw: match[0], text: match[1] }
|
||||
},
|
||||
},
|
||||
|
||||
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
|
||||
const inner = token.text ?? ''
|
||||
const tokens = helpers.tokenizeInline?.(inner)
|
||||
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
|
||||
return { mark: 'highlight', content }
|
||||
},
|
||||
|
||||
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
|
||||
return `==${h.renderChildren(node.content ?? [])}==`
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const markType = this.type
|
||||
return [
|
||||
new Plugin({
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
||||
let tr: Transaction | null = null
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.isText &&
|
||||
node.text?.includes('==') &&
|
||||
node.marks.some((mark) => mark.type === markType)
|
||||
) {
|
||||
tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType)
|
||||
}
|
||||
})
|
||||
return tr
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
+70
@@ -257,3 +257,73 @@ describe('verbatim block boundary (isolating)', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('block reordering (Mod-Shift-Arrow)', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
function caretInto(editor: Editor, word: string): void {
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
|
||||
})
|
||||
}
|
||||
|
||||
it('moves the current top-level block up, carrying the caret', () => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
caretInto(editor, 'Two')
|
||||
editor.commands.moveBlockUp()
|
||||
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('moves the current top-level block down', () => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
caretInto(editor, 'One')
|
||||
editor.commands.moveBlockDown()
|
||||
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['up', '# One\n\nabcdef'],
|
||||
['down', 'abcdef\n\n# Two'],
|
||||
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
|
||||
const editor = editorWith('')
|
||||
editor.commands.setContent(md, { contentType: 'markdown' })
|
||||
editor.commands.focus()
|
||||
let textPos = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text === 'abcdef') textPos = pos
|
||||
})
|
||||
editor.commands.setTextSelection(textPos + 3)
|
||||
if (direction === 'up') editor.commands.moveBlockUp()
|
||||
else editor.commands.moveBlockDown()
|
||||
const at = editor.state.selection.from
|
||||
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
|
||||
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('is a no-op at the top edge and keeps a moved list intact', () => {
|
||||
const top = editorWith('')
|
||||
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
|
||||
top.commands.focus()
|
||||
caretInto(top, 'One')
|
||||
top.commands.moveBlockUp()
|
||||
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
|
||||
top.destroy()
|
||||
|
||||
const list = editorWith('')
|
||||
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
|
||||
list.commands.focus()
|
||||
caretInto(list, 'para')
|
||||
list.commands.moveBlockUp()
|
||||
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
|
||||
list.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
+2
-1
@@ -128,7 +128,8 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
|
||||
* same scoped behavior as a code editor.
|
||||
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
|
||||
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
|
||||
* ({@link selectAdjacentSelectedLeaf}).
|
||||
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
|
||||
* in `./block-mover.ts`.)
|
||||
*
|
||||
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
|
||||
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
|
||||
|
||||
+64
-2
@@ -21,10 +21,11 @@ function mount(editable = true): Editor {
|
||||
}
|
||||
|
||||
/** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */
|
||||
function paste(ed: Editor, text: string, html = ''): boolean {
|
||||
function paste(ed: Editor, text: string, html = '', extra: Record<string, string> = {}): boolean {
|
||||
const event = {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : ''),
|
||||
getData: (type: string) =>
|
||||
type === 'text/plain' ? text : type === 'text/html' ? html : (extra[type] ?? ''),
|
||||
},
|
||||
} as unknown as ClipboardEvent
|
||||
for (const plugin of ed.view.state.plugins) {
|
||||
@@ -35,6 +36,16 @@ function paste(ed: Editor, text: string, html = ''): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Run the plugin `transformPastedHTML` chain the way ProseMirror would. */
|
||||
function transformHtml(ed: Editor, html: string): string {
|
||||
let out = html
|
||||
for (const plugin of ed.view.state.plugins) {
|
||||
const fn = plugin.props?.transformPastedHTML
|
||||
if (fn) out = fn.call(plugin.props, out, ed.view)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('markdown paste', () => {
|
||||
it('renders a pasted inline link as a link mark', () => {
|
||||
editor = mount()
|
||||
@@ -124,6 +135,8 @@ describe('markdown paste', () => {
|
||||
['underscore italic', 'an _italic_ word', 'italic'],
|
||||
['underscore bold', 'a __bold__ word', 'bold'],
|
||||
['strikethrough', 'a ~~struck~~ word', 'strike'],
|
||||
['highlight', 'a ==marked== word', 'highlight'],
|
||||
['highlight with interior equals', 'x ==a=b== y', 'highlight'],
|
||||
['inline code', 'some `code` here', 'code'],
|
||||
['bullet list', '- one\n- two', 'bulletList'],
|
||||
['ordered list', '1. one\n2. two', 'orderedList'],
|
||||
@@ -178,4 +191,53 @@ describe('markdown paste', () => {
|
||||
.filter((type) => type !== 'paragraph')
|
||||
expect(structural).toEqual(['heading', 'bulletList', 'blockquote'])
|
||||
})
|
||||
|
||||
it('pastes VSCode code (vscode-editor-data) as a fenced code block with its language', () => {
|
||||
editor = mount()
|
||||
const code = 'const x: number = 1\nreturn x'
|
||||
const handled = paste(editor, code, '<div><span>const</span></div>', {
|
||||
'vscode-editor-data': JSON.stringify({ mode: 'typescript' }),
|
||||
})
|
||||
expect(handled).toBe(true)
|
||||
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
|
||||
expect(block).toBeDefined()
|
||||
expect(block?.attrs?.language).toBe('typescript')
|
||||
expect(block?.content?.[0]?.text).toBe(code)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['html', 'markup'],
|
||||
['shellscript', 'bash'],
|
||||
])('maps VSCode language id %s to our code-block value %s', (mode, expected) => {
|
||||
editor = mount()
|
||||
paste(editor, 'code', '', { 'vscode-editor-data': JSON.stringify({ mode }) })
|
||||
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
|
||||
expect(block?.attrs?.language).toBe(expected)
|
||||
})
|
||||
|
||||
it.each(['markdown', 'md', 'mdx', 'plaintext'])(
|
||||
'does NOT force a code block for VSCode %s copies (parses as markdown instead)',
|
||||
(mode) => {
|
||||
editor = mount()
|
||||
const handled = paste(editor, '# Title\n\n- item', '', {
|
||||
'vscode-editor-data': JSON.stringify({ mode }),
|
||||
})
|
||||
expect(handled).toBe(true)
|
||||
const types = (editor.getJSON().content ?? []).map((n) => n.type)
|
||||
expect(types).not.toContain('codeBlock')
|
||||
expect(types).toContain('heading')
|
||||
expect(types).toContain('bulletList')
|
||||
}
|
||||
)
|
||||
|
||||
it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
|
||||
editor = mount()
|
||||
const gsheets =
|
||||
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
|
||||
const cleaned = transformHtml(editor, gsheets)
|
||||
expect(cleaned).not.toContain('<style>')
|
||||
expect(cleaned).not.toContain('mso-1')
|
||||
expect(cleaned).toContain('<td>a</td>')
|
||||
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
|
||||
})
|
||||
})
|
||||
|
||||
+58
@@ -31,12 +31,61 @@ const INLINE_MARK_HINTS: ReadonlyArray<RegExp> = [
|
||||
/_[^_\n]+_/,
|
||||
/~~[^~\n]+~~/,
|
||||
/`[^`\n]+`/,
|
||||
/==(?:[^=\n]|=(?!=))+==/,
|
||||
]
|
||||
|
||||
function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
|
||||
return hints.some((hint) => hint.test(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* VSCode language ids that differ from our code-block language values. `markdown`/`plaintext` map to
|
||||
* the empty string so they are NOT forced into a code block — markdown copied from VSCode should parse
|
||||
* as markdown, and plain text should paste as text; other ids pass through as-is.
|
||||
*/
|
||||
const VSCODE_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
|
||||
html: 'markup',
|
||||
shellscript: 'bash',
|
||||
shell: 'bash',
|
||||
jsonc: 'json',
|
||||
plaintext: '',
|
||||
markdown: '',
|
||||
md: '',
|
||||
mdx: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the source language from VSCode's `vscode-editor-data` clipboard payload (a JSON blob with a
|
||||
* `mode` field), mapping the few ids that differ from our code-block values. Returns `''` when the
|
||||
* payload is absent, unparseable, or a non-code mode (plaintext/markdown). A real code language makes
|
||||
* the paste handler emit a fenced code block — otherwise VSCode's per-token colored-span HTML would
|
||||
* fall through to ProseMirror's default parser and flatten into plain paragraphs — while an empty
|
||||
* result falls through so markdown copied from VSCode still parses as markdown.
|
||||
*/
|
||||
function parseVscodeLanguage(data: string | undefined): string {
|
||||
if (!data) return ''
|
||||
try {
|
||||
const mode = (JSON.parse(data) as { mode?: unknown }).mode
|
||||
if (typeof mode !== 'string') return ''
|
||||
return VSCODE_LANGUAGE_ALIASES[mode] ?? mode
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** `<style>`/`<script>` elements (with their content), matched as a pair via the tag backreference. */
|
||||
const NON_CONTENT_HTML = /<(style|script)\b[\s\S]*?<\/\1>/gi
|
||||
|
||||
/**
|
||||
* Strips `<style>`/`<script>` elements from pasted HTML. Google Sheets and Word prepend a `<style>`
|
||||
* block of CSS (and Sheets a `<google-sheets-html-origin>` wrapper); ProseMirror's DOM parser has no
|
||||
* rule for `<style>`, so it would walk the element's CSS text into the document as literal paragraphs.
|
||||
* Removing these before parsing keeps the pasted content clean (PM already discards unknown wrappers).
|
||||
*/
|
||||
function stripNonContentHtml(html: string): string {
|
||||
return html.replace(NON_CONTENT_HTML, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
|
||||
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
|
||||
@@ -62,11 +111,20 @@ export const MarkdownPaste = Extension.create({
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
transformPastedHTML: (html) => stripNonContentHtml(html),
|
||||
handlePaste: (_view, event) => {
|
||||
if (!editor.isEditable) return false
|
||||
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (!text) return false
|
||||
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
|
||||
if (language) {
|
||||
return editor.commands.insertContent({
|
||||
type: 'codeBlock',
|
||||
attrs: { language },
|
||||
content: [{ type: 'text', text }],
|
||||
})
|
||||
}
|
||||
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
|
||||
if (!hasAny(INLINE_MARK_HINTS, text)) return false
|
||||
if (event.clipboardData?.getData('text/html')) return false
|
||||
|
||||
+9
@@ -10,6 +10,7 @@ import {
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Highlighter,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
@@ -76,6 +77,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
|
||||
bold: e.isActive('bold'),
|
||||
italic: e.isActive('italic'),
|
||||
strike: e.isActive('strike'),
|
||||
highlight: e.isActive('highlight'),
|
||||
code: e.isActive('code'),
|
||||
link: e.isActive('link'),
|
||||
heading1: e.isActive('heading', { level: 1 }),
|
||||
@@ -262,6 +264,13 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
|
||||
isActive={active.strike}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Highlighter}
|
||||
label='Highlight'
|
||||
shortcut='⌘⇧H'
|
||||
isActive={active.highlight}
|
||||
onClick={() => editor.chain().focus().toggleMark('highlight').run()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Code}
|
||||
label='Code'
|
||||
|
||||
+14
@@ -397,6 +397,20 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Highlight mark (`==text==`). An opacity-based amber tint so it reads on both light and dark
|
||||
* surfaces without a theme override; `color: inherit` keeps the text at the surrounding body color
|
||||
* and `box-decoration-break: clone` keeps the tint clean where a highlight wraps across lines.
|
||||
*/
|
||||
.rich-markdown-prose mark {
|
||||
background-color: rgba(255, 212, 0, 0.4);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 0.1em;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
/*
|
||||
* Field variant (modal embed): match the surrounding chip fields' typography exactly —
|
||||
* body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the
|
||||
|
||||
+12
@@ -55,6 +55,18 @@ describe('isRoundTripSafe', () => {
|
||||
expect(isRoundTripSafe('a b')).toBe(false)
|
||||
expect(isRoundTripSafe('a & b < c > d')).toBe(true)
|
||||
expect(isRoundTripSafe('AT&T and R&D')).toBe(true)
|
||||
expect(isRoundTripSafe('a & b')).toBe(false)
|
||||
expect(isRoundTripSafe('a < b > c')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an orphan reference definition (serializer drops it) but allows used ones', () => {
|
||||
expect(isRoundTripSafe('Some text.\n\n[unused]: https://example.com "title"')).toBe(false)
|
||||
expect(isRoundTripSafe('[a]: u1\n[b]: u2\n\nuse only [a]')).toBe(false)
|
||||
expect(isRoundTripSafe('See [x][1].\n\n[1]: https://example.com "T"')).toBe(true)
|
||||
expect(isRoundTripSafe('A [shortcut] ref.\n\n[shortcut]: https://example.com')).toBe(true)
|
||||
expect(isRoundTripSafe('Case [Foo] insensitive.\n\n[foo]: https://example.com')).toBe(true)
|
||||
expect(isRoundTripSafe('A note.\n\n[^x]: the footnote body')).toBe(true)
|
||||
expect(isRoundTripSafe('See [ foo ] here.\n\n[foo]: https://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag HTML/comments/entities inside tilde or nested code fences', () => {
|
||||
|
||||
+46
-4
@@ -24,14 +24,16 @@ const PROBE_SIZE_LIMIT = 256 * 1024
|
||||
* flattens `one<br>two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `<br>`.
|
||||
* - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits
|
||||
* the heading, ejecting the second line into a separate paragraph.
|
||||
* - **HTML entity** other than `&`/`<`/`>` (e.g. `©`, `'`, ` `) — the
|
||||
* serializer escapes the `&`, turning the rendered character into literal entity source. A bare
|
||||
* `&` with no `;` is left alone (it re-renders identically, so it's harmless churn).
|
||||
* - **HTML entity** other than the lowercase canonical `&`/`<`/`>` (e.g. `©`, `'`,
|
||||
* ` `, or the uppercase `&`) — the serializer escapes the `&`, turning the rendered character
|
||||
* into literal entity source. The safe-list is deliberately case-*sensitive*: `@tiptap/markdown` only
|
||||
* round-trips the lowercase forms, so `&`/`<`/`>` must fall through to read-only rather than
|
||||
* be treated as safe. A bare `&` with no matching `;`-terminated name is left alone (harmless churn).
|
||||
*/
|
||||
const STABLE_LOSS_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^(?=(?:[^\n]*\|){2})[^\n]*<br\s*\/?>/im,
|
||||
/^#{1,6}\s.*(?: {2,}|\\)$/m,
|
||||
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-f]+|[a-z][a-z0-9]*);/i,
|
||||
/&(?!(?:amp|lt|gt);)(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,45 @@ function linkedImageCount(content: string): number {
|
||||
return content.match(LINKED_IMAGE_PATTERN)?.length ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A link/image reference definition line: `[label]: destination "optional title"` (up to 3 leading
|
||||
* spaces). The `(?!\^)` excludes GFM footnote definitions (`[^id]: …`) — those are preserved verbatim
|
||||
* by the footnote node and round-trip regardless of whether their reference is present, so they must
|
||||
* not be treated as droppable orphan definitions.
|
||||
*/
|
||||
const REFERENCE_DEFINITION = /^ {0,3}\[(?!\^)([^\]]+)]:[ \t]+\S[^\n]*$/gm
|
||||
|
||||
/** CommonMark reference labels match case-insensitively with internal whitespace collapsed. */
|
||||
function normalizeReferenceLabel(label: string): string {
|
||||
return label.trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `content` defines a link/image reference that nothing uses. A *used* reference inlines
|
||||
* losslessly on serialize (`[x][id]` + `[id]: url` → `[x](url)`), but an *unused* definition is dropped
|
||||
* entirely — a silent deletion the idempotency probe can't see (the drop happens on the first pass,
|
||||
* which is then stable). We open such a file read-only rather than lose the definition on first edit.
|
||||
* Conservative: a label counts as used if it appears bracketed anywhere in the body, so the rare
|
||||
* inline-text collision errs toward editable, never toward a false read-only.
|
||||
*/
|
||||
function hasOrphanReferenceDefinition(content: string): boolean {
|
||||
const labels = new Set<string>()
|
||||
for (const match of content.matchAll(REFERENCE_DEFINITION)) {
|
||||
labels.add(normalizeReferenceLabel(match[1]))
|
||||
}
|
||||
if (labels.size === 0) return false
|
||||
const body = content
|
||||
.replace(REFERENCE_DEFINITION, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\[\s+/g, '[')
|
||||
.replace(/\s+\]/g, ']')
|
||||
.toLowerCase()
|
||||
for (const label of labels) {
|
||||
if (!body.includes(`[${label}]`)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `content` survives the editor's markdown round-trip without data loss or autosave
|
||||
* churn. The editor opens the content read-only when this is false, so the probe is deliberately
|
||||
@@ -75,6 +116,7 @@ export function isRoundTripSafe(content: string): boolean {
|
||||
if (content.length > PROBE_SIZE_LIMIT) return false
|
||||
const stripped = stripCode(content)
|
||||
if (STABLE_LOSS_PATTERNS.some((pattern) => pattern.test(stripped))) return false
|
||||
if (hasOrphanReferenceDefinition(stripped)) return false
|
||||
try {
|
||||
const once = serializeMarkdownDocument(content)
|
||||
if (linkedImageCount(stripped) !== linkedImageCount(stripCode(once))) return false
|
||||
|
||||
+67
@@ -158,6 +158,23 @@ describe('editor markdown round-trip', () => {
|
||||
'bold code': '**`x`**',
|
||||
'heading strike code': '# ~~`x`~~',
|
||||
'table with pipe': '| x \\| y | 2 |\n| --- | --- |\n| a | b |',
|
||||
'bold italic nested': '**bold _italic_ word**',
|
||||
'strike bold nested': '~~**struck bold**~~',
|
||||
'bold code inline': '**bold `code` here**',
|
||||
'triple nested marks': '*i **b ~~s~~** i*',
|
||||
'all marks in heading': '# **b** ~~s~~ *i* `c`',
|
||||
'marks in bullet': '- **a** ~~b~~ `c`',
|
||||
'marks in quote': '> **a** ~~b~~ *c*',
|
||||
'nested list marks': '- **a**\n - ~~b~~\n - *c*',
|
||||
'bold link': '[**bold link**](https://x.com)',
|
||||
'link inside bold': '**see [x](https://x.com)**',
|
||||
'table with marks': '| **b** | ~~s~~ | `c` |\n| --- | --- | --- |\n| *i* | a | b |',
|
||||
'bold across code boundary': '**a** `b` **c**',
|
||||
highlight: 'a ==marked== word',
|
||||
'highlight in heading': '# a ==mark== b',
|
||||
'highlight nested in bold': '**bold ==mark== here**',
|
||||
'highlight in list': '- ==a== item',
|
||||
'highlight with interior equals': 'x ==a=b== y',
|
||||
}
|
||||
|
||||
for (const [name, input] of Object.entries(cases)) {
|
||||
@@ -434,3 +451,53 @@ describe('consecutive empty paragraphs', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('highlight ==mark==', () => {
|
||||
function markPresent(src: string): boolean {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent(src, { contentType: 'markdown' })
|
||||
const has = JSON.stringify(editor.getJSON()).includes('"type":"highlight"')
|
||||
editor.destroy()
|
||||
editor = null
|
||||
return has
|
||||
}
|
||||
|
||||
it('parses ==text== into a highlight mark, including mid-line and in headings', () => {
|
||||
expect(markPresent('a ==marked== word')).toBe(true)
|
||||
expect(markPresent('# a ==mark== b')).toBe(true)
|
||||
expect(markPresent('**bold ==mark== here**')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses a highlight body containing a lone `=` (so ==a=b== round-trips)', () => {
|
||||
expect(markPresent('x ==a=b== y')).toBe(true)
|
||||
})
|
||||
|
||||
it('strips a highlight whose text contains `==` (unrepresentable), keeping the text', () => {
|
||||
editor = new Editor({ extensions: createMarkdownContentExtensions() })
|
||||
editor.commands.setContent('x a==b y', { contentType: 'markdown' })
|
||||
let from = -1
|
||||
let to = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
const i = node.text?.indexOf('a==b') ?? -1
|
||||
if (i >= 0) {
|
||||
from = pos + i
|
||||
to = from + 4
|
||||
}
|
||||
}
|
||||
})
|
||||
editor.commands.setTextSelection({ from, to })
|
||||
editor.commands.toggleMark('highlight')
|
||||
const md = postProcessSerializedMarkdown(editor.getMarkdown())
|
||||
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"highlight"')
|
||||
expect(md).not.toContain('==a==b==')
|
||||
expect(editor.getText().trim()).toBe('x a==b y')
|
||||
editor.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
it('leaves comparison / spaced == operators as literal text', () => {
|
||||
expect(markPresent('if x == y then z')).toBe(false)
|
||||
expect(markPresent('a == b == c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user