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