From ea012f9430f392f41bb4f5c879a1006db82e4f27 Mon Sep 17 00:00:00 2001 From: Salihu Date: Sun, 9 Aug 2026 12:16:48 +0100 Subject: [PATCH] markdown import --- .../editor/components/tabs/tabs-menu.tsx | 3 +- .../src/lib/markdown/utils/marked.utils.ts | 8 +- .../src/lib/markdown/utils/tabs.marked.ts | 122 ++++++++++++++++++ .../src/lib/markdown/utils/turndown.utils.ts | 48 +++++++ packages/editor-ext/src/lib/tabs/tab.ts | 3 +- packages/editor-ext/src/lib/tabs/tabs.ts | 76 ++++++++--- 6 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts diff --git a/apps/client/src/features/editor/components/tabs/tabs-menu.tsx b/apps/client/src/features/editor/components/tabs/tabs-menu.tsx index 39d174af5..f09631917 100644 --- a/apps/client/src/features/editor/components/tabs/tabs-menu.tsx +++ b/apps/client/src/features/editor/components/tabs/tabs-menu.tsx @@ -18,6 +18,7 @@ import { const TabsMenu = React.memo(({ editor }: EditorMenuProps) => { const { t } = useTranslation(); + const shouldShow = useCallback( ({ state }: ShouldShowProps) => { if (!state) { @@ -74,7 +75,7 @@ const TabsMenu = React.memo(({ editor }: EditorMenuProps) => { }, [editor]); const handleDelete = useCallback(() => { - editor.chain().focus().delete().run(); + editor.chain().focus().deleteTabs().run(); }, [editor]); return ( diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 7556aa4f0..e0c409ed1 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -2,6 +2,7 @@ import { marked } from "marked"; import { calloutExtension } from "./callout.marked"; import { mathBlockExtension } from "./math-block.marked"; import { mathInlineExtension } from "./math-inline.marked"; +import { tabsExtension } from "./tabs.marked"; marked.use({ renderer: { @@ -34,7 +35,12 @@ marked.use({ }); marked.use({ - extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], + extensions: [ + calloutExtension, + mathBlockExtension, + mathInlineExtension, + tabsExtension, + ], }); marked.setOptions({ breaks: true }); diff --git a/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts b/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts new file mode 100644 index 000000000..9a999d156 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts @@ -0,0 +1,122 @@ +import { marked, type Token } from 'marked'; + +interface MarkdownTab { + label: string; + text: string; + forceActive: boolean; +} + +interface TabbedToken { + type: 'tabbed'; + raw: string; + tabs: MarkdownTab[]; + activeTabIndex: number; +} + +const HEADER_RE = /^===([!+])?\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s*$/gm; + +export const tabsExtension = { + name: 'tabbed', + level: 'block', + start(src: string) { + return src.search(/^===(?:[!+])?\s*["'“”‘’]/m); + }, + tokenizer(src: string): TabbedToken | undefined { + if (src.indexOf('===') === -1) return; + + const headers: Array<{ + index: number; + raw: string; + marker: string; + label: string; + }> = []; + + HEADER_RE.lastIndex = 0; + let m: RegExpExecArray | null = null; + + while ((m = HEADER_RE.exec(src)) !== null) { + headers.push({ + index: m.index, + raw: m[0], + marker: m[1] ?? '', + label: m[2].trim(), + }); + } + + if (headers.length < 1 || headers[0].index !== 0) return; + + const tabs: MarkdownTab[] = []; + let consumed = 0; + + for (let i = 0; i < headers.length; i++) { + const h = headers[i]; + + if (i > 0 && h.marker === '!') break; + + const headerEnd = h.index + h.raw.length; + const bodyStart = + src.charCodeAt(headerEnd) === 10 ? headerEnd + 1 : headerEnd; + const next = headers[i + 1]; + const bodyEnd = next ? next.index : src.length; + + let body = src.slice(bodyStart, bodyEnd).replace(/\n+$/, ''); + + if (body.length > 0) { + body = body + .split('\n') + .map((line) => line.replace(/^\s{0,4}/, '')) + .join('\n'); + } + + tabs.push({ + label: h.label, + text: body, + forceActive: h.marker === '+', + }); + + consumed = bodyEnd; + } + + if (tabs.length < 1) return; + + const forcedActiveIndex = tabs.findIndex((t) => t.forceActive); + + return { + type: 'tabbed', + raw: src.slice(0, consumed), + tabs, + activeTabIndex: Math.max(forcedActiveIndex, 0), + }; + }, + renderer(token: Token) { + const tabbedToken = token as TabbedToken; + + const activeTabIndex = Math.max( + 0, + Math.min(tabbedToken.activeTabIndex ?? 0, tabbedToken.tabs.length - 1), + ); + + const sections = tabbedToken.tabs.map((tab, index) => { + const label = escapeHtml(tab.label); + const panel = marked.parse(tab.text || '').toString(); + const isActive = index === activeTabIndex; + + const activeAttrs = isActive + ? 'data-tab-active="true"' + : 'data-tab-active="false"'; + + return ``; + }); + + return `
${sections.join('')}
`; + }, +}; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index ebfc3423e..9bd742a47 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -24,6 +24,7 @@ export function htmlToMarkdown(html: string): string { TurndownPluginGfm.tables, TurndownPluginGfm.strikethrough, TurndownPluginGfm.highlightedCodeBlock, + tabs, taskList, callout, preserveDetail, @@ -38,6 +39,37 @@ export function htmlToMarkdown(html: string): string { return turndownService.turndown(html).replaceAll('
', ' '); } +function tabs(turndownService: _TurndownService) { + turndownService.addRule('tabs', { + filter: (node: HTMLInputElement) => + node.nodeName === 'DIV' && node.getAttribute('data-type') === 'tabs', + replacement: (content: string, node: HTMLInputElement) => { + const tabNodes = Array.from( + node.querySelectorAll(':scope > div[data-type="tab"]'), + ); + if (tabNodes.length === 0) return content; + + const tabBlocks = tabNodes.map((tabNode) => { + const labelNode = tabNode.querySelector( + ':scope > div[data-type="tabLabel"]', + ); + const panelNode = tabNode.querySelector( + ':scope > div[data-type="tabPanel"]', + ); + + const label = sanitizeTabLabel(labelNode?.textContent || 'Tab'); + const panelMarkdown = panelNode + ? turndownService.turndown(panelNode.innerHTML).trim() + : ''; + + return `=== "${label}"\n${indentMarkdownBlock(panelMarkdown)}`; + }); + + return `\n\n${tabBlocks.join('\n\n')}\n\n`; + }, + }); +} + function listParagraph(turndownService: _TurndownService) { turndownService.addRule('paragraph', { filter: ['p'], @@ -218,3 +250,19 @@ function video(turndownService: _TurndownService) { }, }); } + +function sanitizeTabLabel(value: string): string { + return value + .replace(/[\r\n]+/g, ' ') + .replace(/"/g, '\\"') + .trim(); +} + +function indentMarkdownBlock(content: string): string { + if (!content) return ' '; + + return content + .split('\n') + .map((line) => (line.trim() ? ` ${line}` : '')) + .join('\n'); +} diff --git a/packages/editor-ext/src/lib/tabs/tab.ts b/packages/editor-ext/src/lib/tabs/tab.ts index ed2c79cfa..29e95a1c5 100644 --- a/packages/editor-ext/src/lib/tabs/tab.ts +++ b/packages/editor-ext/src/lib/tabs/tab.ts @@ -1,4 +1,5 @@ import { mergeAttributes, Node } from '@tiptap/core'; +import { generateNodeId } from "../utils"; export interface TabOptions { HTMLAttributes: Record; @@ -21,7 +22,7 @@ export const Tab = Node.create({ id: { default: '', parseHTML: (element: HTMLElement) => - element.getAttribute('data-tab-id') ?? '', + element.getAttribute('data-tab-id') ?? generateNodeId(), renderHTML: (attributes: { id?: string }) => ({ 'data-tab-id': attributes.id ?? '', }), diff --git a/packages/editor-ext/src/lib/tabs/tabs.ts b/packages/editor-ext/src/lib/tabs/tabs.ts index 2f36147e8..3c9daee3c 100644 --- a/packages/editor-ext/src/lib/tabs/tabs.ts +++ b/packages/editor-ext/src/lib/tabs/tabs.ts @@ -1,6 +1,15 @@ -import { Node, mergeAttributes } from '@tiptap/core'; +import { + InputRule, + Node, + Range, + mergeAttributes, +} from '@tiptap/core'; import { Fragment, type Node as PMNode } from '@tiptap/pm/model'; -import { TextSelection, Transaction, type EditorState } from '@tiptap/pm/state'; +import { + TextSelection, + type Transaction, + type EditorState, +} from '@tiptap/pm/state'; import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'; import type { ComponentType } from 'react'; import { generateNodeId } from '../utils'; @@ -10,13 +19,16 @@ export interface TabsOptions { view: ComponentType> | null; } +const TAB_INPUT_REGEX = /^\s*===\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s+$/; + declare module '@tiptap/core' { interface Commands { tabs: { - insertTabs: () => ReturnType; + insertTabs: (tabName?: string, range?: Range) => ReturnType; insertTab: (pos: 'right' | 'left') => ReturnType; moveTab: (pos: 'right' | 'left') => ReturnType; setActiveTab: (index: number, tabsPos: number) => ReturnType; + deleteTabs: () => ReturnType; updateTabLabel: ( index: number, label: string, @@ -72,6 +84,21 @@ export const Tabs = Node.create({ return ReactNodeViewRenderer(this.options.view); }, + addInputRules() { + return [ + new InputRule({ + find: TAB_INPUT_REGEX, + handler: ({ range, match }) => { + const label = ( + typeof match[1] === 'string' ? match[1] : 'Tab 1' + ).trim(); + + this.editor.commands.insertTabs(label, range); + }, + }), + ]; + }, + addCommands() { const createTab = ( schema: EditorState['schema'], @@ -172,32 +199,42 @@ export const Tabs = Node.create({ return { insertTabs: - () => + (tabName?: string, range?: Range) => ({ tr, state, dispatch }) => { - const firstTab = createTab(state.schema, 'Tab 1', true); - const secondTab = createTab(state.schema, 'Tab 2', false); - if (!firstTab || !secondTab) return false; + const firstTab = createTab(state.schema, tabName ?? 'Tab 1', true); + if (!firstTab) return false; const tabsNode = this.type.create( { activeTab: 0, }, - Fragment.fromArray([firstTab, secondTab]), + Fragment.fromArray([firstTab]), ); const insertionPos = tr.selection.from; - tr.replaceSelectionWith(tabsNode).scrollIntoView(); + + if (range) { + tr.replaceRangeWith( + range.from, + range.to, + tabsNode, + ).scrollIntoView(); + } else { + tr.replaceSelectionWith(tabsNode).scrollIntoView(); + } const firstTabPos = getTabPos(tr.doc, insertionPos, 0); const firstTabNode = tr.doc.nodeAt(firstTabPos); if (!firstTabNode) return false; - const labelSize = firstTabNode.child(0)?.nodeSize ?? 0; - const panelContentPos = firstTabPos + 2 + labelSize + 2; + if (!range) { + const labelSize = firstTabNode.child(0)?.nodeSize ?? 0; + const panelContentPos = firstTabPos + 2 + labelSize + 2; - tr.setSelection( - TextSelection.near(tr.doc.resolve(panelContentPos), 1), - ); + tr.setSelection( + TextSelection.near(tr.doc.resolve(panelContentPos), 1), + ); + } if (dispatch) dispatch(tr); return true; @@ -339,7 +376,12 @@ export const Tabs = Node.create({ () => ({ state, tr, dispatch }) => { const tabs = resolveTabs(state); - if (!tabs || tabs.node.childCount <= 1) return false; + if (!tabs) return false; + + if (tabs.node.childCount < 2) { + this.editor.commands.deleteTabs(); + return true; + } const currentTabIndex = clampIndex( tabs.node.attrs.activeTab, @@ -371,11 +413,11 @@ export const Tabs = Node.create({ return true; }, - delete: + deleteTabs: () => ({ state, tr, dispatch }) => { const tabs = resolveTabs(state); - if (!tabs || tabs.node.childCount <= 1) return false; + if (!tabs || tabs.node.childCount < 0) return false; tr.delete(tabs.pos, tabs.pos + tabs.node.nodeSize);