diff --git a/apps/client/src/features/editor/components/tabs/tabs-view.tsx b/apps/client/src/features/editor/components/tabs/tabs-view.tsx
index a02edca6c..712cfa852 100644
--- a/apps/client/src/features/editor/components/tabs/tabs-view.tsx
+++ b/apps/client/src/features/editor/components/tabs/tabs-view.tsx
@@ -134,7 +134,7 @@ export default function TabsView(props: NodeViewProps) {
}
const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => {
- const parsed = typeof value === "number" ? value : Number(value ?? 0);
+ const parsed = Number(value ?? 0);
if (!Number.isFinite(parsed) || length <= 0) return 0;
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
};
diff --git a/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts b/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts
index 9a999d156..2691a27a4 100644
--- a/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/tabs.marked.ts
@@ -32,9 +32,8 @@ export const tabsExtension = {
}> = [];
HEADER_RE.lastIndex = 0;
- let m: RegExpExecArray | null = null;
- while ((m = HEADER_RE.exec(src)) !== null) {
+ for (let m = HEADER_RE.exec(src); m !== null; m = HEADER_RE.exec(src)) {
headers.push({
index: m.index,
raw: m[0],
@@ -47,6 +46,7 @@ export const tabsExtension = {
const tabs: MarkdownTab[] = [];
let consumed = 0;
+ let breakOutOfTabSet = false;
for (let i = 0; i < headers.length; i++) {
const h = headers[i];
@@ -56,15 +56,38 @@ export const tabsExtension = {
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;
+ const bodyLimit = next ? next.index : src.length;
+ let bodyEnd = bodyLimit;
+ let lineStart = bodyStart;
+
+ while (lineStart < bodyLimit) {
+ const newlineIndex = src.indexOf('\n', lineStart);
+ const lineEnd =
+ newlineIndex === -1 || newlineIndex > bodyLimit
+ ? bodyLimit
+ : newlineIndex;
+
+ const line = src.slice(lineStart, lineEnd);
+ const isBlank = line.trim() === '';
+ const isIndented = /^( {2,4}|\t)/.test(line);
+
+ if (!isBlank && !isIndented) {
+ bodyEnd = lineStart;
+ breakOutOfTabSet = true;
+ break;
+ }
+
+ lineStart = lineEnd < bodyLimit ? lineEnd + 1 : bodyLimit;
+ }
let body = src.slice(bodyStart, bodyEnd).replace(/\n+$/, '');
if (body.length > 0) {
body = body
.split('\n')
- .map((line) => line.replace(/^\s{0,4}/, ''))
+ .map((line) => line.replace(/^\s{2,4}/, ''))
.join('\n');
}
@@ -75,6 +98,8 @@ export const tabsExtension = {
});
consumed = bodyEnd;
+
+ if (breakOutOfTabSet) break;
}
if (tabs.length < 1) return;
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 9bd742a47..d48a560da 100644
--- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
@@ -39,6 +39,24 @@ export function htmlToMarkdown(html: string): string {
return turndownService.turndown(html).replaceAll('
', ' ');
}
+const hasPreviousTabs = (node: HTMLElement) => {
+ // we want to make this as cheap as reasonable since it
+ // would be preferable to return a false positive
+ // than to make the editor noticably slower
+ let el = node.previousElementSibling;
+ let checks = 0;
+
+ while (el) {
+ if (el.getAttribute('data-type') === 'tabs') return true;
+ el = el.previousElementSibling;
+ checks += 1;
+
+ if (checks === 100) return true;
+ }
+
+ return false;
+};
+
function tabs(turndownService: _TurndownService) {
turndownService.addRule('tabs', {
filter: (node: HTMLInputElement) =>
@@ -49,7 +67,10 @@ function tabs(turndownService: _TurndownService) {
);
if (tabNodes.length === 0) return content;
- const tabBlocks = tabNodes.map((tabNode) => {
+ const isNestedTabsNode =
+ node.parentElement?.closest('div[data-type="tabs"]') !== null;
+
+ const tabBlocks = tabNodes.map((tabNode, index) => {
const labelNode = tabNode.querySelector(
':scope > div[data-type="tabLabel"]',
);
@@ -62,7 +83,13 @@ function tabs(turndownService: _TurndownService) {
? turndownService.turndown(panelNode.innerHTML).trim()
: '';
- return `=== "${label}"\n${indentMarkdownBlock(panelMarkdown)}`;
+ const isFirstTabInSet = index === 0;
+ const marker =
+ isFirstTabInSet && !isNestedTabsNode && hasPreviousTabs(node)
+ ? '!'
+ : '';
+
+ return `===${marker} "${label}"\n${indentMarkdownBlock(panelMarkdown)}`;
});
return `\n\n${tabBlocks.join('\n\n')}\n\n`;
@@ -85,7 +112,9 @@ function listParagraph(turndownService: _TurndownService) {
function orderedListItem(turndownService: _TurndownService) {
turndownService.addRule('orderedListItem', {
filter: function (node: HTMLInputElement) {
- return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
+ return (
+ node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem'
+ );
},
replacement: (content: string, node: HTMLInputElement, options: any) => {
const parent = node.parentNode as HTMLElement;
@@ -146,9 +175,7 @@ function taskList(turndownService: _TurndownService) {
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return (
- prefix +
- text +
- (node.nextSibling && !/\n$/.test(text) ? '\n' : '')
+ prefix + text + (node.nextSibling && !/\n$/.test(text) ? '\n' : '')
);
},
});
@@ -243,9 +270,7 @@ function video(turndownService: _TurndownService) {
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
const ariaLabel = node.getAttribute('aria-label');
- const name = sanitizeMdLinkText(
- ariaLabel || getBasename(src) || src,
- );
+ const name = sanitizeMdLinkText(ariaLabel || getBasename(src) || src);
return '[' + name + '](' + src + ')';
},
});
diff --git a/packages/editor-ext/src/lib/tabs/tabs.ts b/packages/editor-ext/src/lib/tabs/tabs.ts
index 3c9daee3c..6c8c3c9fa 100644
--- a/packages/editor-ext/src/lib/tabs/tabs.ts
+++ b/packages/editor-ext/src/lib/tabs/tabs.ts
@@ -1,9 +1,4 @@
-import {
- InputRule,
- Node,
- Range,
- mergeAttributes,
-} from '@tiptap/core';
+import { InputRule, Node, Range, mergeAttributes } from '@tiptap/core';
import { Fragment, type Node as PMNode } from '@tiptap/pm/model';
import {
TextSelection,
@@ -13,6 +8,7 @@ import {
import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react';
import type { ComponentType } from 'react';
import { generateNodeId } from '../utils';
+import { findParentNode } from '../table/utils';
export interface TabsOptions {
HTMLAttributes: Record;
@@ -114,24 +110,6 @@ export const Tabs = Node.create({
]);
};
- const resolveTabs = (state: EditorState) => {
- const { $from } = state.selection;
- let depth = $from.depth;
-
- while (depth >= 0) {
- const node = $from.node(depth);
- if (node.type.name === 'tabs') {
- return {
- node,
- pos: $from.before(depth),
- };
- }
- depth--;
- }
-
- return null;
- };
-
const getTabPos = (doc: PMNode, tabsPos: number, tabIndex: number) => {
const pos = doc.resolve(tabsPos + 1);
return pos.posAtIndex(tabIndex, pos.depth);
@@ -243,7 +221,8 @@ export const Tabs = Node.create({
insertTab:
(pos: 'left' | 'right') =>
({ state, tr, dispatch }) => {
- const tabs = resolveTabs(state);
+ const { $from } = state.selection;
+ const tabs = findParentNode((node) => node.type.name === this.name, $from);
if (!tabs || tabs.node.childCount <= 0) return false;
const currentTabIndex = clampIndex(
@@ -285,7 +264,8 @@ export const Tabs = Node.create({
moveTab:
(pos: 'left' | 'right') =>
({ state, tr, dispatch }) => {
- const tabs = resolveTabs(state);
+ const { $from } = state.selection;
+ const tabs = findParentNode((node) => node.type.name === this.name, $from);
if (!tabs || tabs.node.childCount <= 1) return false;
const currentTabIndex = clampIndex(
@@ -375,7 +355,8 @@ export const Tabs = Node.create({
deleteTab:
() =>
({ state, tr, dispatch }) => {
- const tabs = resolveTabs(state);
+ const { $from } = state.selection;
+ const tabs = findParentNode((node) => node.type.name === this.name, $from);
if (!tabs) return false;
if (tabs.node.childCount < 2) {
@@ -416,7 +397,8 @@ export const Tabs = Node.create({
deleteTabs:
() =>
({ state, tr, dispatch }) => {
- const tabs = resolveTabs(state);
+ const { $from } = state.selection;
+ const tabs = findParentNode((node) => node.type.name === this.name, $from);
if (!tabs || tabs.node.childCount < 0) return false;
tr.delete(tabs.pos, tabs.pos + tabs.node.nodeSize);
@@ -426,10 +408,44 @@ export const Tabs = Node.create({
},
};
},
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: ({ editor }) => {
+ const { state } = editor;
+ const { $from, empty } = state.selection;
+
+ if (!empty) return false;
+ if ($from.parent.content.size > 0) return false;
+
+ const tabsNode = findParentNode(
+ (node) => node.type.name === this.name,
+ $from,
+ );
+
+ if (!tabsNode) return false;
+ return editor
+ .chain()
+ .command(({ tr, state }) => {
+ const posAfter = $from.after(tabsNode.depth);
+ tr.delete($from.before(), $from.after());
+
+ const targetPos = tr.mapping.map(posAfter);
+ const paragraph = state.schema.nodes.paragraph.create();
+
+ tr.insert(targetPos, paragraph);
+ tr.setSelection(TextSelection.create(tr.doc, targetPos + 1));
+ return true;
+ })
+ .scrollIntoView()
+ .run();
+ },
+ };
+ },
});
const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => {
- const parsed = typeof value === 'number' ? value : Number(value ?? 0);
+ const parsed = Number(value ?? 0);
if (!Number.isFinite(parsed) || length <= 0) return 0;
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
};