From 1f28dfc985185eed502fec3bb5b235a790b8f50f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 12:42:56 -0700 Subject: [PATCH 1/5] fix(rich-markdown-editor): don't split a list when deleting an empty item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backspacing an emptied list item in the middle of a list used ProseMirror's default lift, which pulls the item out into a top-level paragraph — splitting one list into two and stranding an empty paragraph (a visible gap). Backspace at the start of an empty list item now joins into the previous block instead, removing the item and keeping a single list. Covers bullet, ordered, and task lists. --- .../rich-markdown-editor/keymap.test.ts | 38 +++++++++++++++++++ .../rich-markdown-editor/keymap.ts | 24 +++++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) 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..e84bbd34040 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 @@ -141,3 +141,41 @@ describe('divider Backspace', () => { editor.destroy() }) }) + +describe('empty list-item Backspace', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + it.each([ + ['bullet list', '- one\n- two\n- three', 'bulletList'], + ['ordered list', '1. one\n2. two\n3. three', 'orderedList'], + ['task list', '- [ ] one\n- [ ] two\n- [ ] three', 'taskList'], + ])( + 'joins an empty middle %s item into the previous item instead of splitting the list', + (_label, markdown, listType) => { + const editor = editorWith('') + editor.commands.setContent(markdown, { contentType: 'markdown' }) + editor.commands.focus() + let from = -1 + let to = -1 + editor.state.doc.descendants((node, pos) => { + if (from < 0 && node.isText && node.text === 'two') { + from = pos + to = pos + node.text.length + } + }) + editor.commands.setTextSelection({ from, to }) + editor.commands.deleteSelection() + pressBackspace(editor) + + expect(blockShape(editor).filter((type) => type === listType)).toEqual([listType]) + const items: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'paragraph' && node.textContent) items.push(node.textContent) + }) + expect(items).toEqual(['one', 'three']) + 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..2f430ff450b 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 @@ -9,6 +9,9 @@ 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']) +/** List-item node types whose empty-item Backspace must join rather than lift (see the Backspace docs). */ +const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) + /** * 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 +69,15 @@ 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 list item* it joins into the previous block instead of ProseMirror's default lift — lifting + * an empty item out of the middle of a list splits it into two lists and strands an empty paragraph, + * a visible gap; `joinBackward` removes the item and keeps one list. 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. * - **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 +103,12 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() } + if ( + $from.parent.content.size === 0 && + LIST_ITEM_TYPES.has($from.node($from.depth - 1).type.name) + ) { + return editor.commands.joinBackward() + } const blockStart = $from.before($from.depth) const nodeBefore = doc.resolve(blockStart).nodeBefore if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false From 9acc75c2ba185703447403558f70f7e8bb726345 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:32:09 -0700 Subject: [PATCH 2/5] fix(rich-markdown-editor): generalize empty-wrapped-block boundary keys Replace the joinBackward empty-list-item Backspace branch with a walk-up-and-delete that removes the whole emptied wrapper, and extend it to task items and blockquotes. joinBackward left a stray empty paragraph inside the previous item (flat lists) or no-op'd entirely on nested items (leaving them stuck); blockquotes were never handled and split in two. Add an Enter handler so an empty non-trailing list/task item is removed rather than split into two lists around a stranded, non-round-trippable empty paragraph; a trailing empty item still exits the list via the default. Covers bullet/ordered/task/blockquote at every position; non-empty items and the double-Enter list-exit are unaffected. --- .../rich-markdown-editor/keymap.test.ts | 97 ++++++++++++++----- .../rich-markdown-editor/keymap.ts | 84 +++++++++++++--- 2 files changed, 142 insertions(+), 39 deletions(-) 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 e84bbd34040..f6a11f47a7b 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. @@ -142,40 +167,64 @@ describe('divider Backspace', () => { }) }) -describe('empty list-item Backspace', () => { +describe('empty wrapped-block Backspace', () => { beforeEach(() => { Element.prototype.scrollIntoView = vi.fn() }) it.each([ - ['bullet list', '- one\n- two\n- three', 'bulletList'], - ['ordered list', '1. one\n2. two\n3. three', 'orderedList'], - ['task list', '- [ ] one\n- [ ] two\n- [ ] three', 'taskList'], + ['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'], ])( - 'joins an empty middle %s item into the previous item instead of splitting the list', - (_label, markdown, listType) => { + '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() - let from = -1 - let to = -1 - editor.state.doc.descendants((node, pos) => { - if (from < 0 && node.isText && node.text === 'two') { - from = pos - to = pos + node.text.length - } - }) - editor.commands.setTextSelection({ from, to }) - editor.commands.deleteSelection() + emptyItem(editor, word) pressBackspace(editor) - expect(blockShape(editor).filter((type) => type === listType)).toEqual([listType]) - const items: string[] = [] - editor.state.doc.descendants((node) => { - if (node.type.name === 'paragraph' && node.textContent) items.push(node.textContent) - }) - expect(items).toEqual(['one', 'three']) + 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() + }) +}) 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 2f430ff450b..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,9 +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']) -/** List-item node types whose empty-item Backspace must join rather than lift (see the Backspace docs). */ +/** + * 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. @@ -70,14 +109,20 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * * - **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 an - * *empty list item* it joins into the previous block instead of ProseMirror's default lift — lifting - * an empty item out of the middle of a list splits it into two lists and strands an empty paragraph, - * a visible gap; `joinBackward` removes the item and keeps one list. 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. + * *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. @@ -103,11 +148,8 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() } - if ( - $from.parent.content.size === 0 && - LIST_ITEM_TYPES.has($from.node($from.depth - 1).type.name) - ) { - return editor.commands.joinBackward() + if ($from.parent.content.size === 0 && isInsideWrapper($from)) { + return removeEmptyWrappedBlock(editor, $from) } const blockStart = $from.before($from.depth) const nodeBefore = doc.resolve(blockStart).nodeBefore @@ -125,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 From 02cdbdc07a329b588b2c75344274b67c939eb2fc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:34:46 -0700 Subject: [PATCH 3/5] fix(rich-markdown-editor): isolate verbatim block nodes from boundary joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit footnoteDef and rawHtmlBlock hold exact source text but were neither isolating nor atom, so a single Backspace/Delete at their boundary let ProseMirror's default join merge their raw markdown into an adjacent paragraph as HTML-escaped prose — silently destroying the node and corrupting saved markdown. Mark the block variants isolating so boundary keys can't cross their edge. Round-trip and in-place editing are unchanged. --- .../rich-markdown-editor/keymap.test.ts | 29 +++++++++++++++++++ .../raw-markdown-snippet.tsx | 4 +++ 2 files changed, 33 insertions(+) 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 f6a11f47a7b..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 @@ -228,3 +228,32 @@ describe('empty list-item Enter', () => { 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/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() { From 70bfe19320ffbb50c56aacb7d1d8b97f2db40c8c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:43:13 -0700 Subject: [PATCH 4/5] fix(rich-markdown-editor): escape leading block markers in paragraph serialization A paragraph beginning with #, -, +, 1., 1), or a bare --- serialized unescaped, so it silently re-parsed into a heading / list / thematic break on the next load (reachable via type-a-marker then undo). The upstream serializer escapes inline delimiters (* _ ` [ ] ~, so * bullets and > quotes already round-trip) but not these block-starting markers. BlockSafeParagraph wraps the paragraph renderer with a leading-marker guard; escaping is idempotent (parsing consumes the backslash) and never over-escapes non-markers like #hashtag or -5. --- .../rich-markdown-editor/extensions.ts | 29 +++++++++++ .../rich-markdown-editor/round-trip.test.ts | 50 +++++++++++++++++++ 2 files changed, 79 insertions(+) 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..ebdd1bbb7de 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,32 @@ const PipeSafeTable = Table.extend({ .replace(/\n+$/, ''), }) +/** + * Backslash-escapes a leading block marker so paragraph text like `# note`, `- item`, `1. step`, or a + * bare `---` serializes as a paragraph rather than re-parsing into a heading / list / thematic break on + * the next load. 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 escapeLeadingBlockMarker(text: string): string { + if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(text)) { + return `\\${text}` + } + const ordered = /^(\d{1,9})([.)][ \t])/.exec(text) + return ordered ? `${ordered[1]}\\${text.slice(ordered[1].length)}` : text +} + +/** + * Paragraph that escapes a leading block marker on serialize (see {@link escapeLeadingBlockMarker}) — + * otherwise a paragraph beginning with `#`/`-`/`+`/`1.`/`1)`/`---` silently becomes that 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-marker guard. + */ +const BlockSafeParagraph = Paragraph.extend({ + renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => + escapeLeadingBlockMarker(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 +119,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/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index 34db32f85c5..21c86d22bde 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,53 @@ describe('link href sanitization — dangerous schemes from file content are neu expect(hrefs).toContain('mailto:x@y.com') }) }) + +describe('paragraph leading block-marker escaping', () => { + /** 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) + }) +}) From 3b3cd1aa8bce3f8236f1fd33c3d431adecb08064 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 14:59:37 -0700 Subject: [PATCH 5/5] fix(rich-markdown-editor): strip leading paragraph indent; keep empty paragraphs  -free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paragraph beginning with a 4-space/tab indent re-parsed as an indented code block on the next load. Leading whitespace never renders in a paragraph (CommonMark strips up to three leading spaces; four or more become code), so BlockSafeParagraph now strips it — lossless and idempotent. Composes with the existing leading-marker escaping (e.g. ' # x' → '\# x'). Also locks in that consecutive empty paragraphs serialize via blank lines rather than the upstream ' ' marker: replacing StarterKit's Paragraph already dropped the   path, and the round-trip preserves the empty-paragraph count without tripping the read-only safety gate (which flags   as a stable-loss pattern). Added tests for both. --- .../rich-markdown-editor/extensions.ts | 37 +++++++----- .../rich-markdown-editor/round-trip.test.ts | 56 ++++++++++++++++++- 2 files changed, 77 insertions(+), 16 deletions(-) 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 ebdd1bbb7de..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 @@ -59,29 +59,36 @@ const PipeSafeTable = Table.extend({ }) /** - * Backslash-escapes a leading block marker so paragraph text like `# note`, `- item`, `1. step`, or a - * bare `---` serializes as a paragraph rather than re-parsing into a heading / list / thematic break on - * the next load. 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. + * 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 escapeLeadingBlockMarker(text: string): string { - if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(text)) { - return `\\${text}` +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(text) - return ordered ? `${ordered[1]}\\${text.slice(ordered[1].length)}` : text + const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped) + return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped } /** - * Paragraph that escapes a leading block marker on serialize (see {@link escapeLeadingBlockMarker}) — - * otherwise a paragraph beginning with `#`/`-`/`+`/`1.`/`1)`/`---` silently becomes that 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-marker guard. + * 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) => - escapeLeadingBlockMarker(h.renderChildren(node.content ?? [])), + guardParagraphLeading(h.renderChildren(node.content ?? [])), }) /** 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 21c86d22bde..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 @@ -331,7 +331,7 @@ describe('link href sanitization — dangerous schemes from file content are neu }) }) -describe('paragraph leading block-marker escaping', () => { +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 @@ -379,4 +379,58 @@ describe('paragraph leading block-marker escaping', () => { 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) + } + ) })