markdown import

This commit is contained in:
Salihu
2026-08-09 12:16:48 +01:00
parent add1d7bf61
commit ea012f9430
6 changed files with 240 additions and 20 deletions
@@ -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 (
@@ -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 });
@@ -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 `<div data-type="tab" aria-hidden="true" ${activeAttrs}><div data-type="tabLabel">${label}</div><div data-type="tabPanel" >${panel}</div></div>`;
});
return `<div data-type="tabs" data-active-tab="${activeTabIndex}">${sections.join('')}</div>`;
},
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@@ -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('<br>', ' ');
}
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');
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { generateNodeId } from "../utils";
export interface TabOptions {
HTMLAttributes: Record<string, unknown>;
@@ -21,7 +22,7 @@ export const Tab = Node.create<TabOptions>({
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 ?? '',
}),
+59 -17
View File
@@ -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<ReactNodeViewProps<HTMLElement>> | null;
}
const TAB_INPUT_REGEX = /^\s*===\s*["'“”‘’]([^"'“”‘’\n]+)["'“”‘’]\s+$/;
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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<TabsOptions>({
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<TabsOptions>({
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<TabsOptions>({
() =>
({ 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<TabsOptions>({
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);