diff --git a/packages/nc-gui/components/cell/RichText.vue b/packages/nc-gui/components/cell/RichText.vue index d997e0b77b..848cca97b2 100644 --- a/packages/nc-gui/components/cell/RichText.vue +++ b/packages/nc-gui/components/cell/RichText.vue @@ -2,10 +2,18 @@ import StarterKit from '@tiptap/starter-kit' import TaskList from '@tiptap/extension-task-list' import { EditorContent, useEditor } from '@tiptap/vue-3' -import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' import { Markdown } from 'tiptap-markdown' -import { HardBreak, Link, Strike, TaskItem, UserMention, UserMentionList, suggestion } from '~/helpers/tiptap/extensions' +import { + HardBreak, + Link, + Strike, + Underline, + TaskItem, + UserMention, + UserMentionList, + suggestion, +} from '~/helpers/tiptap/extensions' const props = withDefaults( defineProps<{ diff --git a/packages/nc-gui/helpers/tiptap/extensions/functionality/markdown/md-strike-ext.ts b/packages/nc-gui/helpers/tiptap/extensions/functionality/markdown/md-strike-ext.ts index 9f4c90dc56..47b78e6ce0 100644 --- a/packages/nc-gui/helpers/tiptap/extensions/functionality/markdown/md-strike-ext.ts +++ b/packages/nc-gui/helpers/tiptap/extensions/functionality/markdown/md-strike-ext.ts @@ -5,13 +5,14 @@ function mdStrikeExt(md: MarkdownIt) { const originalStrike = md.renderer.rules.del_open const originalStrikeClose = md.renderer.rules.del_close + // Custom rule for handling ~ and ~~ strike-through with raw HTML md.inline.ruler.before('emphasis', 'custom_strike', (state, silent) => { const marker = state.src.charAt(state.pos) if (marker !== '~') { return false } - const match = state.src.slice(state.pos).match(/^~{1,2}/) + const match = state.src.slice(state.pos).match(/^~{1,2}/) // Match ~ or ~~ if (!match) { return false } @@ -22,6 +23,7 @@ function mdStrikeExt(md: MarkdownIt) { return true } + // Handle opening tag based on single or double tilde if (markerCount === 2) { state.push('del_open', 's', 1) } else if (markerCount === 1) { @@ -35,8 +37,16 @@ function mdStrikeExt(md: MarkdownIt) { return false } - state.push('text', '', 0).content = state.src.slice(contentStart, contentEnd) + // Handle raw HTML content inside strike-through + let content = state.src.slice(contentStart, contentEnd) + // Check if the content contains raw HTML tags and prevent escaping + if (/<[^>]+>/g.test(content)) { + state.push('text', '', 0).content = content // Leave HTML tags as is + } else { + state.push('text', '', 0).content = state.src.slice(contentStart, contentEnd) + } + // Handle closing tag based on single or double tilde if (markerCount === 2) { state.push('del_close', 's', -1) } else if (markerCount === 1) { @@ -47,6 +57,7 @@ function mdStrikeExt(md: MarkdownIt) { return true }) + // Modify the rendering to output for both ~text~ and ~~text~~ md.renderer.rules.strike_open = () => { return `` } @@ -55,7 +66,18 @@ function mdStrikeExt(md: MarkdownIt) { return `` } - // Retain default behavior for `~~` if necessary + // Custom serialize function to handle raw HTML inside strike-through + md.renderer.rules.text = (tokens, idx) => { + const token = tokens[idx] + // If the token contains HTML content (like or other HTML tags), return it as raw HTML + if (/<[^>]+>/g.test(token.content)) { + return token.content // Preserve the raw HTML content + } else { + return md.utils.escapeHtml(token.content) // Escape non-HTML content + } + } + + // Retain default behavior for `~~` strike-through if necessary if (originalStrike) { md.renderer.rules.del_open = originalStrike md.renderer.rules.del_close = originalStrikeClose diff --git a/packages/nc-gui/helpers/tiptap/extensions/marks/html.ts b/packages/nc-gui/helpers/tiptap/extensions/marks/html.ts new file mode 100644 index 0000000000..c643dc8bef --- /dev/null +++ b/packages/nc-gui/helpers/tiptap/extensions/marks/html.ts @@ -0,0 +1,38 @@ +import { Fragment } from '@tiptap/pm/model' +import { getHTMLFromFragment, Mark } from '@tiptap/core' + +export const HTMLMark = Mark.create({ + name: 'markdownHTMLMark', + addStorage() { + return { + markdown: { + serialize: { + open(state, mark) { + if (!this.editor.storage.markdown.options.html) { + console.warn(`Tiptap Markdown: "${mark.type.name}" mark is only available in html mode`) + return '' + } + return getMarkTags(mark)?.[0] ?? '' + }, + close(state, mark) { + if (!this.editor.storage.markdown.options.html) { + return '' + } + return getMarkTags(mark)?.[1] ?? '' + }, + }, + parse: { + // handled by markdown-it + }, + }, + } + }, +}) + +function getMarkTags(mark) { + const schema = mark.type.schema + const node = schema.text(' ', [mark]) + const html = getHTMLFromFragment(Fragment.from(node), schema) + const match = html.match(/^(<.*?>) (<\/.*?>)$/) + return match ? [match[1], match[2]] : null +} diff --git a/packages/nc-gui/helpers/tiptap/extensions/marks/index.ts b/packages/nc-gui/helpers/tiptap/extensions/marks/index.ts index 4c4b13fa86..9426823fc6 100644 --- a/packages/nc-gui/helpers/tiptap/extensions/marks/index.ts +++ b/packages/nc-gui/helpers/tiptap/extensions/marks/index.ts @@ -1,2 +1,4 @@ export * from './strike' -export * from './links' +export * from './link' +export * from './underline' +export * from './html' diff --git a/packages/nc-gui/helpers/tiptap/extensions/marks/links.ts b/packages/nc-gui/helpers/tiptap/extensions/marks/link.ts similarity index 100% rename from packages/nc-gui/helpers/tiptap/extensions/marks/links.ts rename to packages/nc-gui/helpers/tiptap/extensions/marks/link.ts diff --git a/packages/nc-gui/helpers/tiptap/extensions/marks/strike.ts b/packages/nc-gui/helpers/tiptap/extensions/marks/strike.ts index c0e1224ed1..197bb456e7 100644 --- a/packages/nc-gui/helpers/tiptap/extensions/marks/strike.ts +++ b/packages/nc-gui/helpers/tiptap/extensions/marks/strike.ts @@ -6,7 +6,7 @@ export const Strike = TiptapStrike.extend({ addStorage() { return { markdown: { - serialize: { open: '~', close: '~', expelEnclosingWhitespace: true }, + serialize: { open: '~', close: '~', mixable: true, expelEnclosingWhitespace: true }, parse: { setup(markdownit: MarkdownIt) { markdownit.use(mdStrikeExt) diff --git a/packages/nc-gui/helpers/tiptap/extensions/marks/underline.ts b/packages/nc-gui/helpers/tiptap/extensions/marks/underline.ts new file mode 100644 index 0000000000..b3b08f01da --- /dev/null +++ b/packages/nc-gui/helpers/tiptap/extensions/marks/underline.ts @@ -0,0 +1,21 @@ +import TiptapUnderline from '@tiptap/extension-underline' + +export const Underline = TiptapUnderline.extend({ + // addStorage() { + // return { + // ...this.parent?.(), // Retain parent storage if any + // markdown: { + // serialize(state, node, parent) { + // console.log('Serializing underline mark:', node) + // // Implement your custom logic + // const text = state.textBetween(node.from, node.to) // Get the text content + // state.write(`${text}`) // Serialize as ... tags + // return () => undefined + // }, + // parse: { + // // handled by markdown-it + // }, + // }, + // } + // }, +}) diff --git a/packages/nc-gui/helpers/tiptap/extensions/nodes/hard-break.ts b/packages/nc-gui/helpers/tiptap/extensions/nodes/hard-break.ts index 37962ed0fa..707f8def7d 100644 --- a/packages/nc-gui/helpers/tiptap/extensions/nodes/hard-break.ts +++ b/packages/nc-gui/helpers/tiptap/extensions/nodes/hard-break.ts @@ -1,20 +1,21 @@ import TiptapHardBreak from '@tiptap/extension-hard-break' +// import { HTMLNode } from './html' export const HardBreak = TiptapHardBreak.extend({ - addStorage() { - return { - markdown: { - serialize(state, node, parent, index) { - for (let i = index + 1; i < parent.childCount; i++) - if (parent.child(i).type !== node.type) { - state.write(state.inTable ? HTMLNode.storage.markdown.serialize.call(this, state, node, parent) : '
') - return - } - }, - parse: { - // handled by markdown-it - }, - }, - } - }, + // addStorage() { + // return { + // markdown: { + // serialize(state, node, parent, index) { + // for (let i = index + 1; i < parent.childCount; i++) + // if (parent.child(i).type !== node.type) { + // state.write(state.inTable ? HTMLNode.storage.markdown.serialize.call(this, state, node, parent) : '
') + // return + // } + // }, + // parse: { + // // handled by markdown-it + // }, + // }, + // } + // }, }) diff --git a/packages/nc-gui/helpers/tiptap/extensions/nodes/html.ts b/packages/nc-gui/helpers/tiptap/extensions/nodes/html.ts new file mode 100644 index 0000000000..dccf6d132c --- /dev/null +++ b/packages/nc-gui/helpers/tiptap/extensions/nodes/html.ts @@ -0,0 +1,50 @@ +import { Fragment } from '@tiptap/pm/model' +import { getHTMLFromFragment, Node } from '@tiptap/core' +import { elementFromString } from '../../utils/dom' + +export const HTMLNode = Node.create({ + name: 'markdownHTMLNode', + addStorage() { + return { + markdown: { + serialize(state, node, parent) { + if (this.editor.storage.markdown.options.html) { + state.write(serializeHTML(node, parent)) + } else { + console.warn(`Tiptap Markdown: "${node.type.name}" node is only available in html mode`) + state.write(`[${node.type.name}]`) + } + if (node.isBlock) { + state.closeBlock(node) + } + }, + parse: { + // handled by markdown-it + }, + }, + } + }, +}) + +function serializeHTML(node, parent) { + const schema = node.type.schema + const html = getHTMLFromFragment(Fragment.from(node), schema) + + if (node.isBlock && (parent instanceof Fragment || parent.type.name === schema.topNodeType.name)) { + return formatBlock(html) + } + + return html +} + +/** + * format html block as per the commonmark spec + */ +function formatBlock(html) { + const dom = elementFromString(html) + const element = dom.firstElementChild + + element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : `\n` + + return element.outerHTML +} diff --git a/packages/nc-gui/helpers/tiptap/extensions/nodes/index.ts b/packages/nc-gui/helpers/tiptap/extensions/nodes/index.ts index f667754b1d..840d37f658 100644 --- a/packages/nc-gui/helpers/tiptap/extensions/nodes/index.ts +++ b/packages/nc-gui/helpers/tiptap/extensions/nodes/index.ts @@ -1,3 +1,4 @@ export * from './mention' export * from './task-item' export * from './hard-break' +export * from './html' diff --git a/packages/nc-gui/helpers/tiptap/utils/dom.ts b/packages/nc-gui/helpers/tiptap/utils/dom.ts new file mode 100644 index 0000000000..6c786a5ac6 --- /dev/null +++ b/packages/nc-gui/helpers/tiptap/utils/dom.ts @@ -0,0 +1,35 @@ +export function elementFromString(value) { + // add a wrapper to preserve leading and trailing whitespace + const wrappedValue = `${value}` + + return new window.DOMParser().parseFromString(wrappedValue, 'text/html').body +} + +export function escapeHTML(value) { + return value?.replace(//g, '>') +} + +export function extractElement(node) { + const parent = node.parentElement + const prepend = parent.cloneNode() + + while (parent.firstChild && parent.firstChild !== node) { + prepend.appendChild(parent.firstChild) + } + + if (prepend.childNodes.length > 0) { + parent.parentElement.insertBefore(prepend, parent) + } + parent.parentElement.insertBefore(node, parent) + if (parent.childNodes.length === 0) { + parent.remove() + } +} + +export function unwrapElement(node) { + const parent = node.parentNode + + while (node.firstChild) parent.insertBefore(node.firstChild, node) + + parent.removeChild(node) +}