Merge develop into staging-new (#8622)

* fix: append port to s3 endpoint (#8601)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>

* Interpret plain text paste events in the text editor as an opportunity to parse and apply Markdown (#8618)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* Fixed Mermaid support in the Markdown renderer/parser. (#8620)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

---------

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
Co-authored-by: Alexander Onnikov <aonnikov@hardcoreeng.com>
Co-authored-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Artyom Savchenko 2025-04-18 09:13:16 +07:00 committed by GitHub
parent 9e916670e1
commit c820af080f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 188 additions and 8 deletions

View File

@ -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'
}
]
}
]
}
}
]

View File

@ -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> = T | ((tok: Token, state: MarkdownParseState) => T)
function readSpec<T> (rule: SpecRule<T>, 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<MarkupNodeType>
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<MarkupNodeType>
): 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<MarkupNodeType>, 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<string, ParsingBlockRule> = {
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 ?? '' }
},

View File

@ -102,6 +102,13 @@ export const storeNodes: Record<string, NodeProcessor> = {
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) {

View File

@ -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",

View File

@ -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>([
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>([
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
}

View File

@ -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'

View File

@ -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<Extension<EditorKitOptions, any>> {
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])

View File

@ -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