diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 1f29545bebb..bf91dfd2cd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -1,6 +1,7 @@ import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core' import { Code } from '@tiptap/extension-code' import { TaskItem, TaskList } from '@tiptap/extension-list' +import { Paragraph } from '@tiptap/extension-paragraph' import { renderTableToMarkdown, Table, @@ -57,6 +58,39 @@ const PipeSafeTable = Table.extend({ .replace(/\n+$/, ''), }) +/** + * Guards a paragraph's serialized text so its leading characters don't re-parse it into a different + * block on the next load: + * + * - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three + * leading spaces, and four or more would re-parse the paragraph as an indented code block), so + * removing it is lossless and makes the round-trip idempotent. + * - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the + * paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline + * delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these + * block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored + * ProseMirror text never carries it and re-serialization is stable. + */ +function guardParagraphLeading(text: string): string { + const stripped = text.replace(/^[ \t]+/, '') + if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) { + return `\\${stripped}` + } + const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped) + return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped +} + +/** + * Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) — + * otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list / + * thematic break / code block on the next load. Block separators are owned by the parent joiner, so a + * paragraph renders as just its inline children; this override wraps that with the leading guard. + */ +const BlockSafeParagraph = Paragraph.extend({ + renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => + guardParagraphLeading(h.renderChildren(node.content ?? [])), +}) + /** * Node-view variants the live editor injects in place of the headless defaults — the code-block * language picker, the resizable image, and the mention chip. The mention chip pulls the block registry @@ -92,7 +126,9 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {} underline: false, codeBlock: false, code: false, + paragraph: false, }), + BlockSafeParagraph, InlineCode, codeBlock, (nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index c7c8f425417..1f12ca3b7ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -33,12 +33,37 @@ function firstPosOf(editor: Editor, type: string): number { return pos } -function pressBackspace(editor: Editor): void { +function pressKey(editor: Editor, key: string): void { editor.view.dom.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }) + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) ) } +function pressBackspace(editor: Editor): void { + pressKey(editor, 'Backspace') +} + +/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */ +function emptyItem(editor: Editor, word: string): void { + let from = -1 + let to = -1 + editor.state.doc.descendants((node, pos) => { + if (from < 0 && node.isText && node.text === word) { + from = pos + to = pos + word.length + } + }) + editor.commands.setTextSelection({ from, to }) + editor.commands.deleteSelection() +} + +/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */ +function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } { + const md = editor.getMarkdown() + editor.commands.setContent(md, { contentType: 'markdown' }) + return { md, reparsed: editor.getMarkdown() } +} + describe('suggestion-aware arrow keymap', () => { beforeEach(() => { // The suggestion render lifecycle uses these; jsdom lacks them. @@ -141,3 +166,94 @@ describe('divider Backspace', () => { editor.destroy() }) }) + +describe('empty wrapped-block Backspace', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + it.each([ + ['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'], + ['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'], + ['bullet last', '- one\n- two', 'two', '- one'], + ['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'], + ['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'], + ['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'], + ['nested item', '- one\n - two\n- three', 'two', '- one\n- three'], + ])( + 'removes the emptied %s cleanly — one container, no stray paragraph, round-trips', + (_label, markdown, word, expected) => { + const editor = editorWith('') + editor.commands.setContent(markdown, { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, word) + pressBackspace(editor) + + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe(expected) + expect(reparsed).toBe(md) + editor.destroy() + } + ) +}) + +describe('empty list-item Enter', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressKey(editor, 'Enter') + + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n- three') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('leaves an empty TRAILING item to the default (exits the list)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressKey(editor, 'Enter') + + const list = editor.getJSON().content?.find((node) => node.type === 'bulletList') + expect(list?.content).toHaveLength(1) + expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true) + editor.destroy() + }) +}) + +describe('verbatim block boundary (isolating)', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + function caretIntoNode(editor: Editor, nodeType: string): void { + editor.state.doc.descendants((node, pos) => { + if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1) + }) + } + + it.each([ + ['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'], + ['raw HTML block', 'body\n\n
\nhello\n
', 'rawHtmlBlock'], + ])( + 'Backspace at the start of a %s does not merge across its boundary and destroy it', + (_label, markdown, nodeType) => { + const editor = editorWith('') + editor.commands.setContent(markdown, { contentType: 'markdown' }) + editor.commands.focus() + expect(blockShape(editor)).toContain(nodeType) + caretIntoNode(editor, nodeType) + pressBackspace(editor) + expect(blockShape(editor)).toContain(nodeType) + editor.destroy() + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 2c51d43646f..6fb6ad4ac66 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -1,7 +1,8 @@ import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' import { GapCursor } from '@tiptap/pm/gapcursor' -import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state' +import type { ResolvedPos } from '@tiptap/pm/model' +import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import { MENTION_PLUGIN_KEY } from './mention' import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' @@ -9,6 +10,47 @@ import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' /** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */ const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image']) +/** + * Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty + * block out of one of these splits the container in two and strands an empty paragraph — a visible gap + * that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}). + */ +const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote']) + +/** Item node types a list is built from, used to detect an empty item's position within its list. */ +const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) + +/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */ +function isInsideWrapper($from: ResolvedPos): boolean { + for (let depth = $from.depth - 1; depth >= 1; depth--) { + if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true + } + return false +} + +/** + * Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole + * child of, then places the caret at the end of the preceding block. This keeps a list or blockquote + * whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the + * container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different + * document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list + * item, not just its paragraph) so no orphan `
  • ` or empty continuation line is left behind. + */ +function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean { + let depth = $from.depth + while (depth > 1 && $from.node(depth - 1).childCount === 1) depth-- + const start = $from.before(depth) + const end = $from.after(depth) + return editor.commands.command(({ tr, dispatch }) => { + if (dispatch) { + tr.delete(start, end) + tr.setSelection(Selection.near(tr.doc.resolve(start), -1)) + dispatch(tr.scrollIntoView()) + } + return true + }) +} + /** * True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so * the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider. @@ -66,12 +108,21 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * Editor-specific keyboard behavior layered on top of StarterKit's defaults: * * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or - * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a - * block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross - * the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below dividers - * without touching the divider itself), while a *non-empty* block selects the leaf — so a first - * Backspace highlights what a second deletes, the same highlight-before-delete affordance as clicking - * it and parity with the arrow-key leaf selection. + * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an + * *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via + * {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of + * the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap + * that also re-parses to a different markdown document), while the default `joinBackward` alternately + * no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the + * previous item. At the start of a block whose previous sibling is a divider or image, where + * ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing + * the blank line between/below dividers without touching the divider itself), while a *non-empty* + * block selects the leaf — so a first Backspace highlights what a second deletes, the same + * highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection. + * - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link + * removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded + * empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the + * default, which exits the list — the standard "press Enter on a blank bullet to leave the list". * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the * same scoped behavior as a code editor. @@ -97,6 +148,9 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() } + if ($from.parent.content.size === 0 && isInsideWrapper($from)) { + return removeEmptyWrappedBlock(editor, $from) + } const blockStart = $from.before($from.depth) const nodeBefore = doc.resolve(blockStart).nodeBefore if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false @@ -113,6 +167,18 @@ export const RichMarkdownKeymap = Extension.create({ } return editor.commands.setNodeSelection(leafStart) }, + Enter: ({ editor }) => { + const { selection } = editor.state + if (!selection.empty || selection.$from.parentOffset !== 0) return false + const { $from } = selection + if ($from.parent.content.size !== 0) return false + const itemDepth = $from.depth - 1 + if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false + const listDepth = itemDepth - 1 + const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1 + if (isTrailingItem) return false + return removeEmptyWrappedBlock(editor, $from) + }, 'Mod-a': ({ editor }) => { const { $from } = editor.state.selection if ($from.parent.type.name !== 'codeBlock') return false diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index f2e27209ce7..1aad444de29 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -167,6 +167,10 @@ function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { marks: '', code: true, defining: !inline, + // Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from + // joining across their edge, which would otherwise merge their raw markdown into an adjacent + // paragraph as HTML-escaped prose and destroy the node (silent data loss on save). + isolating: !inline, selectable: true, atom: false, parseHTML() { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index 34db32f85c5..1f8a9df6a1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts @@ -330,3 +330,107 @@ describe('link href sanitization — dangerous schemes from file content are neu expect(hrefs).toContain('mailto:x@y.com') }) }) + +describe('paragraph leading guard (marker escaping + indent stripping)', () => { + /** Serialize a doc whose first paragraph literally starts with `text`, then re-parse its first node. */ + function serializeParagraph(text: string): { + md: string + reparsedType: string + idempotent: boolean + } { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent( + { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }, + { contentType: 'json' } + ) + const md = postProcessSerializedMarkdown(editor.getMarkdown()) + editor.commands.setContent(md, { contentType: 'markdown' }) + const reparsedType = editor.getJSON().content?.[0]?.type ?? '' + const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md + editor.destroy() + editor = null + return { md, reparsedType, idempotent } + } + + it.each([ + ['# note', '\\# note'], + ['###### note', '\\###### note'], + ['#', '\\#'], + ['- item', '\\- item'], + ['+ item', '\\+ item'], + ['1. step', '1\\. step'], + ['1) step', '1\\) step'], + ['---', '\\---'], + ['- - -', '\\- - -'], + ])('escapes a paragraph starting with %j so it stays a paragraph', (text, expectedMd) => { + const { md, reparsedType, idempotent } = serializeParagraph(text) + expect(md.trim()).toBe(expectedMd) + expect(reparsedType).toBe('paragraph') + expect(idempotent).toBe(true) + }) + + it.each([ + ['#hashtag'], // no space after # → not a heading + ['-5 degrees'], // no space after - → not a bullet + ['plain text'], + ])('does not over-escape %j', (text) => { + const { md, reparsedType, idempotent } = serializeParagraph(text) + expect(md.trim()).toBe(text) + expect(reparsedType).toBe('paragraph') + expect(idempotent).toBe(true) + }) + + it.each([ + [' four spaces', 'four spaces'], + ['\ttab indent', 'tab indent'], + [' eight spaces', 'eight spaces'], + [' # indented marker', '\\# indented marker'], + ])( + 'strips leading indent so %j stays a paragraph instead of an indented code block', + (text, expectedMd) => { + const { md, reparsedType, idempotent } = serializeParagraph(text) + expect(md.trim()).toBe(expectedMd) + expect(reparsedType).toBe('paragraph') + expect(idempotent).toBe(true) + } + ) +}) + +describe('consecutive empty paragraphs', () => { + /** Doc with `a`, then `count` empty paragraphs, then `b`; serialized and round-tripped. */ + function serializeEmpties(count: number) { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + const emptyParas = Array.from({ length: count }, () => ({ type: 'paragraph', content: [] })) + editor.commands.setContent( + { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, + ...emptyParas, + { type: 'paragraph', content: [{ type: 'text', text: 'b' }] }, + ], + }, + { contentType: 'json' } + ) + const md = postProcessSerializedMarkdown(editor.getMarkdown()) + editor.commands.setContent(md, { contentType: 'markdown' }) + const emptyCount = (editor.getJSON().content ?? []).filter( + (n) => n.type === 'paragraph' && !n.content?.length + ).length + const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md + editor.destroy() + editor = null + return { md, emptyCount, idempotent } + } + + it.each([[1], [2], [3], [4]])( + 'preserves %i empty paragraph(s) via blank lines (no  , idempotent, no read-only trigger)', + (count) => { + const { md, emptyCount, idempotent } = serializeEmpties(count) + expect(md).not.toContain(' ') + expect(md).not.toContain(String.fromCharCode(0x00a0)) + expect(emptyCount).toBe(count) + expect(idempotent).toBe(true) + } + ) +})