diff --git a/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts b/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts index 3d546dd98f..c075f12775 100644 --- a/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts +++ b/plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts @@ -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({ 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({ } }) +/** + * 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 @@ -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(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) diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts new file mode 100644 index 0000000000..2505f94b7e --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/__tests__/smartPaste.test.ts @@ -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) + }) +}) diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts index 0ac28afe83..598eb4aa86 100644 --- a/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts @@ -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([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 } }