diff --git a/packages/text-markdown/src/__tests__/markdown.test.ts b/packages/text-markdown/src/__tests__/markdown.test.ts index 6c080398ac..749d2a83b2 100644 --- a/packages/text-markdown/src/__tests__/markdown.test.ts +++ b/packages/text-markdown/src/__tests__/markdown.test.ts @@ -751,6 +751,28 @@ Lorem ipsum dolor sit amet. } ] } + }, + { + name: 'mermaid diagram', + markdown: '```mermaid\ngraph TD;\n\tA-->B;\n\tA-->C;\n\tB-->D;\n\tC-->D;\n```', + markup: { + type: 'doc', + content: [ + { + type: 'mermaid', + attrs: { + language: 'mermaid' + }, + content: [ + { + marks: [], + text: 'graph TD;\n\tA-->B;\n\tA-->C;\n\tB-->D;\n\tC-->D;', + type: 'text' + } + ] + } + ] + } } ] diff --git a/packages/text-markdown/src/parser.ts b/packages/text-markdown/src/parser.ts index 9658712bb2..3f590462f2 100644 --- a/packages/text-markdown/src/parser.ts +++ b/packages/text-markdown/src/parser.ts @@ -22,8 +22,17 @@ import type StateCore from 'markdown-it/lib/rules_core/state_core' import { addToSet, removeFromSet, sameSet } from './marks' import { nodeContent } from './node' +type SpecRule = T | ((tok: Token, state: MarkdownParseState) => T) + +function readSpec (rule: SpecRule, tok: Token, state: MarkdownParseState): T { + if (typeof rule === 'function') { + return (rule as (tok: Token, state: MarkdownParseState) => T)(tok, state) + } + return rule +} + interface ParsingBlockRule { - block: MarkupNodeType + block: SpecRule getAttrs?: (tok: Token, state: MarkdownParseState) => Attrs wrapContent?: boolean noCloseToken?: boolean @@ -222,12 +231,17 @@ function withoutTrailingNewline (str: string): string { return str[str.length - 1] === '\n' ? str.slice(0, str.length - 1) : str } -function addSpecBlock (handlers: HandlersRecord, spec: ParsingBlockRule, type: string, specBlock: MarkupNodeType): void { +function addSpecBlock ( + handlers: HandlersRecord, + spec: ParsingBlockRule, + type: string, + specBlock: SpecRule +): void { if (noCloseToken(spec, type)) { handlers[type] = newSimpleBlockHandler(specBlock, spec) } else { handlers[type + '_open'] = (state, tok) => { - state.openNode(specBlock, attrs(spec, tok, state)) + state.openNode(readSpec(specBlock, tok, state), attrs(spec, tok, state)) if (spec.wrapContent === true) { state.openNode(MarkupNodeType.paragraph, {}) } @@ -240,9 +254,9 @@ function addSpecBlock (handlers: HandlersRecord, spec: ParsingBlockRule, type: s } } } -function newSimpleBlockHandler (specBlock: MarkupNodeType, spec: ParsingBlockRule): HandlerRecord { +function newSimpleBlockHandler (specBlock: SpecRule, spec: ParsingBlockRule): HandlerRecord { return (state, tok) => { - state.openNode(specBlock, attrs(spec, tok, state)) + state.openNode(readSpec(specBlock, tok, state), attrs(spec, tok, state)) state.addText(withoutTrailingNewline(tok.content)) state.closeNode() } @@ -448,14 +462,24 @@ const tokensBlock: Record = { getAttrs: (tok: Token) => ({ level: Number(tok.tag.slice(1)), marker: tok.markup }) }, code_block: { - block: MarkupNodeType.code_block, + block: (tok) => { + if (tok.info === 'mermaid') { + return MarkupNodeType.mermaid + } + return MarkupNodeType.code_block + }, getAttrs: (tok: Token) => { return { language: tok.info ?? '' } }, noCloseToken: true }, fence: { - block: MarkupNodeType.code_block, + block: (tok) => { + if (tok.info === 'mermaid') { + return MarkupNodeType.mermaid + } + return MarkupNodeType.code_block + }, getAttrs: (tok: Token) => { return { language: tok.info ?? '' } }, diff --git a/packages/text-markdown/src/serializer.ts b/packages/text-markdown/src/serializer.ts index af7d95cbb6..bcbcb99f41 100644 --- a/packages/text-markdown/src/serializer.ts +++ b/packages/text-markdown/src/serializer.ts @@ -102,6 +102,13 @@ export const storeNodes: Record = { state.write('```') state.closeBlock(node) }, + mermaid: (state, node) => { + state.write('```mermaid\n') + state.renderInline(node) + state.ensureNewLine() + state.write('```') + state.closeBlock(node) + }, heading: (state, node) => { const attrs = nodeAttrs(node) if (attrs.marker === '=' && attrs.level === 1) { diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index 8ce7c75777..f0e655b9d2 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -52,6 +52,7 @@ "@hcengineering/collaborator-client": "^0.6.4", "@hcengineering/contact": "^0.6.24", "@hcengineering/presence": "^0.6.0", + "@hcengineering/text-markdown": "^0.6.0", "@tiptap/core": "^2.11.7", "@tiptap/pm": "^2.11.7", "@tiptap/extension-code-block-lowlight": "^2.11.7", diff --git a/plugins/text-editor-resources/src/components/extension/paste.ts b/plugins/text-editor-resources/src/components/extension/paste.ts new file mode 100644 index 0000000000..84265d96d5 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/paste.ts @@ -0,0 +1,121 @@ +// +// Copyright © 2025 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 { MarkupMarkType, MarkupNodeType, type MarkupNode } from '@hcengineering/text' +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' + +export const TransformPastedContentExtension = Extension.create({ + name: 'transformPastedContent', + + addProseMirrorPlugins () { + return [PasteTextAsMarkdownPlugin()] + } +}) + +function PasteTextAsMarkdownPlugin (): Plugin { + return new Plugin({ + props: { + handlePaste (view, event, slice) { + const clipboardData = event.clipboardData + if (clipboardData === null) return false + + const pastedText = clipboardData.getData('text/plain') + const isPlainPaste = clipboardData.types.length === 1 && clipboardData.types[0] === 'text/plain' + + if (!isPlainPaste) return false + + try { + const markupNode = cleanUnknownContent(view.state.schema, markdownToMarkup(pastedText)) + if (shouldUseMarkdownOutput(markupNode)) { + const content = Node.fromJSON(view.state.schema, markupNode) + const transaction = view.state.tr.replaceSelectionWith(content) + view.dispatch(transaction) + return true + } + } catch (e) { + console.log('Unable to convert plain text to markdown:', e) + } + + return false + } + } + }) +} + +const importantMarkupNodeTypes = new Set([ + MarkupNodeType.code_block, + MarkupNodeType.bullet_list, + MarkupNodeType.list_item, + MarkupNodeType.table, + MarkupNodeType.todoList, + MarkupNodeType.ordered_list, + MarkupNodeType.reference, + MarkupNodeType.image, + MarkupNodeType.heading, + MarkupNodeType.mermaid +]) + +const importantMarkupMarkTypes = new Set([ + MarkupMarkType.bold, + MarkupMarkType.em, + MarkupMarkType.code, + MarkupMarkType.link +]) + +export function cleanUnknownContent (schema: Schema, node: MarkupNode): MarkupNode { + const traverse = (node: MarkupNode): void => { + if (node.content === undefined) { + return + } + node.content = node.content.filter((child) => schema.nodes[child.type] !== undefined) + node.content.forEach((child) => { + traverse(child) + }) + } + + traverse(node) + return node +} + +export function shouldUseMarkdownOutput (node: MarkupNode): boolean { + const counter = { + importantNodes: 0, + importantMarks: 0 + } + + const traverse = (node: MarkupNode): void => { + if (importantMarkupNodeTypes.has(node.type)) { + counter.importantNodes++ + } + + if (node.type === MarkupNodeType.text) { + for (const mark of node.marks ?? []) { + if (importantMarkupMarkTypes.has(mark.type)) { + counter.importantMarks++ + } + } + } + + ;(node.content ?? []).forEach((child) => { + traverse(child) + }) + } + + traverse(node) + return counter.importantNodes > 0 || counter.importantMarks > 0 +} diff --git a/plugins/text-editor-resources/src/index.ts b/plugins/text-editor-resources/src/index.ts index 3f0a19f726..4bf34cff25 100644 --- a/plugins/text-editor-resources/src/index.ts +++ b/plugins/text-editor-resources/src/index.ts @@ -29,6 +29,7 @@ import { createInlineComment, shouldShowCreateInlineCommentAction } from './comp import { isTextStylingEnabled, openBackgroundColorOptions, openTextColorOptions } from './components/extension/colors' export { getTargetObjectFromUrl, getReferenceFromUrl, getReferenceLabel } from './components/extension/reference' export { TodoItemExtension, TodoListExtension } from './components/extension/todo' +export { TransformPastedContentExtension } from './components/extension/paste' export * from '@hcengineering/presentation/src/types' export type { EditorKitOptions } from './kits/editor-kit' diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index 31c0b1051f..92d0ed2ce7 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -49,6 +49,7 @@ import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/e import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent' import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align' import { LinkUtilsExtension } from '../components/extension/link' +import { TransformPastedContentExtension } from '../components/extension/paste' export interface EditorKitOptions extends DefaultKitOptions { history?: false @@ -333,6 +334,8 @@ async function buildEditorKit (): Promise> { staticKitExtensions.push([1000, NoteExtension.configure(this.options.note ?? {})]) } + staticKitExtensions.push([1100, TransformPastedContentExtension.configure({})]) + const allKitExtensions = [...tableKitExtensions, ...modelKitExtensions, ...staticKitExtensions] allKitExtensions.sort((a, b) => a[0] - b[0]) diff --git a/server/s3/src/index.ts b/server/s3/src/index.ts index 74abd57f67..7e514e3d8d 100644 --- a/server/s3/src/index.ts +++ b/server/s3/src/index.ts @@ -71,8 +71,9 @@ export class S3Service implements StorageAdapter { expireTime: number client: S3 constructor (readonly opt: S3Config) { + const endpoint = Number.isInteger(opt.port) ? `${opt.endpoint}:${opt.port}` : opt.endpoint this.client = new S3({ - endpoint: opt.endpoint, + endpoint, credentials: { accessKeyId: opt.accessKey, secretAccessKey: opt.secretKey