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);