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