mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-08 18:57:42 +02:00
Fix Mermaid diagram paste (#10714)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -44,6 +44,7 @@ const mermaidMetaTxField = 'mermaid-meta-tx'
|
||||
|
||||
interface TxMetaContainer {
|
||||
nodePatch?: NodePatchSpec
|
||||
nodePatches?: NodePatchSpec[]
|
||||
renderResult?: MermaidRenderResult
|
||||
updateDecorations?: boolean
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
|
||||
|
||||
addProseMirrorPlugins () {
|
||||
const parent = (this.parent?.() ?? []).filter((p) => p.props.handlePaste === undefined)
|
||||
return [...parent, MermaidDecorator(this.options)]
|
||||
return [...parent, MermaidCodeBlockNormalizer(), MermaidDecorator(this.options)]
|
||||
},
|
||||
|
||||
addNodeView () {
|
||||
@@ -358,6 +359,75 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Normalizes pasted/imported content so that Mermaid blocks render.
|
||||
*
|
||||
* There are multiple ways Mermaid content can enter the editor (markdown paste, html paste,
|
||||
* programmatic inserts). Some of those paths produce a regular `codeBlock` with
|
||||
* `attrs.language === 'mermaid'` instead of a dedicated `mermaid` node.
|
||||
*
|
||||
* Our rendering pipeline only targets `mermaid` nodes, so we convert such `codeBlock`s
|
||||
* into `mermaid` nodes on any doc-changing transaction.
|
||||
*/
|
||||
function MermaidCodeBlockNormalizer (): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey('mermaid-codeblock-normalizer'),
|
||||
appendTransaction (transactions, oldState, newState) {
|
||||
if (!transactions.some((tr) => tr.docChanged)) return
|
||||
|
||||
const { schema } = newState
|
||||
const mermaidType = schema.nodes[MermaidExtension.name]
|
||||
const codeBlockType = schema.nodes.codeBlock
|
||||
|
||||
if (mermaidType == null || codeBlockType == null) return
|
||||
|
||||
const targets: Array<{ pos: number, node: ProseMirrorNode }> = []
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (node.type !== codeBlockType) return
|
||||
if ((node.attrs as any)?.language !== 'mermaid') return
|
||||
targets.push({ pos, node })
|
||||
})
|
||||
|
||||
if (targets.length === 0) return
|
||||
|
||||
// Replace from end to start to keep positions stable.
|
||||
const tr = newState.tr
|
||||
const selectionPos = newState.selection.from
|
||||
const nodePatches: NodePatchSpec[] = []
|
||||
let shouldMoveSelection = false
|
||||
let selectionTargetPos = 0
|
||||
for (let i = targets.length - 1; i >= 0; i--) {
|
||||
const { pos, node } = targets[i]
|
||||
const attrs = { ...(node.attrs ?? {}), language: 'mermaid' }
|
||||
tr.replaceRangeWith(pos, pos + node.nodeSize, mermaidType.create(attrs, node.content, node.marks))
|
||||
|
||||
// If the user selection is inside the normalized block, keep it editable (unfolded)
|
||||
// and keep the cursor inside the new node.
|
||||
if (selectionPos >= pos && selectionPos <= pos + node.nodeSize) {
|
||||
nodePatches.push({ pos, folded: false, selected: false })
|
||||
shouldMoveSelection = true
|
||||
selectionTargetPos = pos
|
||||
}
|
||||
}
|
||||
|
||||
if (nodePatches.length > 0) {
|
||||
setTxMeta(tr, { nodePatches })
|
||||
}
|
||||
|
||||
if (shouldMoveSelection) {
|
||||
// Place the cursor into the code content of the mermaid node.
|
||||
// Node content starts at `pos + 1`.
|
||||
const nextSelection =
|
||||
TextSelection.findFrom(tr.doc.resolve(selectionTargetPos + 1), 1) ??
|
||||
TextSelection.create(tr.doc, selectionTargetPos + 1)
|
||||
tr.setSelection(nextSelection)
|
||||
}
|
||||
|
||||
return tr
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface MermaidPluginState {
|
||||
decorationSet: DecorationSet
|
||||
decorationCache: Map<number | string, NodeDecorationState>
|
||||
@@ -554,6 +624,11 @@ function buildState (
|
||||
const lastDecorationSet = tr !== undefined ? prev.decorationSet.map(tr.mapping, tr.doc) : prev.decorationSet
|
||||
|
||||
const nodeStatePatch = getTxMeta(tr)?.nodePatch
|
||||
const nodeStatePatches = getTxMeta(tr)?.nodePatches
|
||||
const nodeStatePatchByPos =
|
||||
nodeStatePatches !== undefined && nodeStatePatches.length > 0
|
||||
? new Map<number, NodePatchSpec>(nodeStatePatches.map((p) => [p.pos, p]))
|
||||
: undefined
|
||||
|
||||
let mIndex = 0
|
||||
doc.descendants((node, pos, parent, index) => {
|
||||
@@ -584,9 +659,12 @@ function buildState (
|
||||
textContent: node.textContent
|
||||
}
|
||||
|
||||
if (nodeStatePatch !== undefined && pos === nodeStatePatch.pos) {
|
||||
newState.folded = nodeStatePatch.folded
|
||||
newState.selected = nodeStatePatch.selected
|
||||
const patch =
|
||||
nodeStatePatchByPos?.get(pos) ??
|
||||
(nodeStatePatch !== undefined && pos === nodeStatePatch.pos ? nodeStatePatch : undefined)
|
||||
if (patch !== undefined) {
|
||||
newState.folded = patch.folded
|
||||
newState.selected = patch.selected
|
||||
}
|
||||
|
||||
if (yid !== undefined) decorationCache.set(yid, newState)
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import { EditorState, NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
import { PasteTextAsMarkdownPlugin } from '../smartPaste'
|
||||
|
||||
jest.mock('@hcengineering/text', () => ({
|
||||
__esModule: true,
|
||||
MarkupNodeType: {
|
||||
doc: 'doc',
|
||||
text: 'text',
|
||||
paragraph: 'paragraph',
|
||||
heading: 'heading',
|
||||
code_block: 'codeBlock',
|
||||
bullet_list: 'bulletList',
|
||||
list_item: 'listItem',
|
||||
table: 'table',
|
||||
todoList: 'todoList',
|
||||
ordered_list: 'orderedList',
|
||||
reference: 'reference',
|
||||
image: 'image',
|
||||
mermaid: 'mermaid'
|
||||
},
|
||||
MarkupMarkType: {
|
||||
bold: 'bold',
|
||||
em: 'em',
|
||||
code: 'code',
|
||||
link: 'link'
|
||||
}
|
||||
}))
|
||||
|
||||
jest.mock('@hcengineering/text-markdown', () => ({
|
||||
__esModule: true,
|
||||
markdownToMarkup: (markdown: string) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: markdown.trim().length > 0 ? markdown.trim() : 'title' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
jest.mock('../../codeSnippets/codeblock', () => ({
|
||||
__esModule: true,
|
||||
CodeBlockHighlighExtension: { name: 'codeBlock' }
|
||||
}))
|
||||
|
||||
function makeSchema (): Schema {
|
||||
return new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
text: { group: 'inline' },
|
||||
heading: {
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
attrs: { level: { default: 1 } },
|
||||
toDOM: (node) => ['h' + node.attrs.level, 0],
|
||||
parseDOM: [
|
||||
{ tag: 'h1', attrs: { level: 1 } },
|
||||
{ tag: 'h2', attrs: { level: 2 } },
|
||||
{ tag: 'h3', attrs: { level: 3 } }
|
||||
]
|
||||
},
|
||||
paragraph: {
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
toDOM: () => ['p', 0],
|
||||
parseDOM: [{ tag: 'p' }]
|
||||
},
|
||||
codeBlock: {
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
attrs: { language: { default: null } },
|
||||
toDOM: () => ['pre', ['code', 0]],
|
||||
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }]
|
||||
},
|
||||
mermaid: {
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
attrs: { language: { default: 'mermaid' } },
|
||||
toDOM: () => ['div', { class: 'mermaid-diagram' }, ['code', 0]],
|
||||
parseDOM: [{ tag: 'div.mermaid-diagram', preserveWhitespace: 'full' }]
|
||||
}
|
||||
},
|
||||
marks: {}
|
||||
})
|
||||
}
|
||||
|
||||
function makeClipboardData (data: { plain?: string, markdown?: string, types?: string[] }): any {
|
||||
const plain = data.plain ?? ''
|
||||
const markdown = data.markdown ?? ''
|
||||
const types = data.types ?? ['text/plain']
|
||||
return {
|
||||
types,
|
||||
getData: (t: string) => {
|
||||
if (t === 'text/plain') return plain
|
||||
if (t === 'text/markdown') return markdown
|
||||
return ''
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('SmartPaste handlePaste ignore contexts', () => {
|
||||
it('ignores smart paste when selection is inside a codeBlock', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [
|
||||
schema.node('codeBlock', { language: 'mermaid' }, schema.text('graph TD\nA-->B'))
|
||||
])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores smart paste when selection is inside a mermaid block', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('graph TD\nA-->B'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores smart paste when NodeSelection is a codeBlock', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('codeBlock', undefined, schema.text('x'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: NodeSelection.create(doc, 0)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores smart paste when NodeSelection is a mermaid node', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('x'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: NodeSelection.create(doc, 0)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SmartPaste handlePaste transform scenarios', () => {
|
||||
it('transforms plain text paste into markdown output in normal text selection', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const dispatch = jest.fn()
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch },
|
||||
{ clipboardData: makeClipboardData({ plain: '# Title', types: ['text/plain'] }) },
|
||||
null
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('transforms when explicit markdown is present even with rich clipboard types', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const dispatch = jest.fn()
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch },
|
||||
{
|
||||
clipboardData: makeClipboardData({
|
||||
plain: 'fallback',
|
||||
markdown: '## Heading',
|
||||
types: ['text/html', 'text/markdown']
|
||||
})
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import { MarkupMarkType, MarkupNodeType, type MarkupNode } from '@hcengineering/
|
||||
import { markdownToMarkup } from '@hcengineering/text-markdown'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Node, type Schema } from '@tiptap/pm/model'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state'
|
||||
import { CodeBlockHighlighExtension } from '../codeSnippets/codeblock'
|
||||
import { hasTableMetadataMarker } from './tableMetadata'
|
||||
|
||||
@@ -29,7 +29,7 @@ export const SmartPasteExtension = Extension.create({
|
||||
}
|
||||
})
|
||||
|
||||
function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
export function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
handlePaste (view, event, slice) {
|
||||
@@ -39,12 +39,16 @@ function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
const pastedText = clipboardData.getData('text/plain')
|
||||
const pastedMarkdown = clipboardData.getData('text/markdown')
|
||||
|
||||
// check if we are in code block
|
||||
const { $from } = view.state.selection
|
||||
// Ignore smart paste inside code blocks / mermaid blocks (keep default paste behavior).
|
||||
const selection = view.state.selection
|
||||
const ignoredNodeTypes = new Set<string>([CodeBlockHighlighExtension.name, 'mermaid'])
|
||||
if (selection instanceof NodeSelection && ignoredNodeTypes.has(selection.node.type.name)) {
|
||||
return false
|
||||
}
|
||||
const { $from } = selection
|
||||
for (let d = $from.depth; d > 0; d--) {
|
||||
const node = $from.node(d)
|
||||
if (node.type.name === CodeBlockHighlighExtension.name) {
|
||||
// paste as plain text in code blocks
|
||||
if (ignoredNodeTypes.has(node.type.name)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user