Skip to content

Commit 3eeb718

Browse files
committed
Merge branch 'staging' into docs/forking-ws
2 parents 0f5e518 + 07c026a commit 3eeb718

10 files changed

Lines changed: 657 additions & 38 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
22
import { Code } from '@tiptap/extension-code'
33
import { TaskItem, TaskList } from '@tiptap/extension-list'
4+
import { Paragraph } from '@tiptap/extension-paragraph'
45
import {
56
renderTableToMarkdown,
67
Table,
@@ -57,6 +58,39 @@ const PipeSafeTable = Table.extend({
5758
.replace(/\n+$/, ''),
5859
})
5960

61+
/**
62+
* Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
63+
* block on the next load:
64+
*
65+
* - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
66+
* leading spaces, and four or more would re-parse the paragraph as an indented code block), so
67+
* removing it is lossless and makes the round-trip idempotent.
68+
* - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
69+
* paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
70+
* delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
71+
* block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
72+
* ProseMirror text never carries it and re-serialization is stable.
73+
*/
74+
function guardParagraphLeading(text: string): string {
75+
const stripped = text.replace(/^[ \t]+/, '')
76+
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
77+
return `\\${stripped}`
78+
}
79+
const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
80+
return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
81+
}
82+
83+
/**
84+
* Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
85+
* otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
86+
* thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
87+
* paragraph renders as just its inline children; this override wraps that with the leading guard.
88+
*/
89+
const BlockSafeParagraph = Paragraph.extend({
90+
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
91+
guardParagraphLeading(h.renderChildren(node.content ?? [])),
92+
})
93+
6094
/**
6195
* Node-view variants the live editor injects in place of the headless defaults — the code-block
6296
* 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 = {}
92126
underline: false,
93127
codeBlock: false,
94128
code: false,
129+
paragraph: false,
95130
}),
131+
BlockSafeParagraph,
96132
InlineCode,
97133
codeBlock,
98134
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,37 @@ function firstPosOf(editor: Editor, type: string): number {
3333
return pos
3434
}
3535

36-
function pressBackspace(editor: Editor): void {
36+
function pressKey(editor: Editor, key: string): void {
3737
editor.view.dom.dispatchEvent(
38-
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true })
38+
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
3939
)
4040
}
4141

42+
function pressBackspace(editor: Editor): void {
43+
pressKey(editor, 'Backspace')
44+
}
45+
46+
/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */
47+
function emptyItem(editor: Editor, word: string): void {
48+
let from = -1
49+
let to = -1
50+
editor.state.doc.descendants((node, pos) => {
51+
if (from < 0 && node.isText && node.text === word) {
52+
from = pos
53+
to = pos + word.length
54+
}
55+
})
56+
editor.commands.setTextSelection({ from, to })
57+
editor.commands.deleteSelection()
58+
}
59+
60+
/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */
61+
function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } {
62+
const md = editor.getMarkdown()
63+
editor.commands.setContent(md, { contentType: 'markdown' })
64+
return { md, reparsed: editor.getMarkdown() }
65+
}
66+
4267
describe('suggestion-aware arrow keymap', () => {
4368
beforeEach(() => {
4469
// The suggestion render lifecycle uses these; jsdom lacks them.
@@ -141,3 +166,94 @@ describe('divider Backspace', () => {
141166
editor.destroy()
142167
})
143168
})
169+
170+
describe('empty wrapped-block Backspace', () => {
171+
beforeEach(() => {
172+
Element.prototype.scrollIntoView = vi.fn()
173+
})
174+
175+
it.each([
176+
['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
177+
['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
178+
['bullet last', '- one\n- two', 'two', '- one'],
179+
['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
180+
['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
181+
['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
182+
['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
183+
])(
184+
'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
185+
(_label, markdown, word, expected) => {
186+
const editor = editorWith('')
187+
editor.commands.setContent(markdown, { contentType: 'markdown' })
188+
editor.commands.focus()
189+
emptyItem(editor, word)
190+
pressBackspace(editor)
191+
192+
const { md, reparsed } = markdownRoundTrip(editor)
193+
expect(md.trim()).toBe(expected)
194+
expect(reparsed).toBe(md)
195+
editor.destroy()
196+
}
197+
)
198+
})
199+
200+
describe('empty list-item Enter', () => {
201+
beforeEach(() => {
202+
Element.prototype.scrollIntoView = vi.fn()
203+
})
204+
205+
it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => {
206+
const editor = editorWith('')
207+
editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' })
208+
editor.commands.focus()
209+
emptyItem(editor, 'two')
210+
pressKey(editor, 'Enter')
211+
212+
const { md, reparsed } = markdownRoundTrip(editor)
213+
expect(md.trim()).toBe('- one\n- three')
214+
expect(reparsed).toBe(md)
215+
editor.destroy()
216+
})
217+
218+
it('leaves an empty TRAILING item to the default (exits the list)', () => {
219+
const editor = editorWith('')
220+
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
221+
editor.commands.focus()
222+
emptyItem(editor, 'two')
223+
pressKey(editor, 'Enter')
224+
225+
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
226+
expect(list?.content).toHaveLength(1)
227+
expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
228+
editor.destroy()
229+
})
230+
})
231+
232+
describe('verbatim block boundary (isolating)', () => {
233+
beforeEach(() => {
234+
Element.prototype.scrollIntoView = vi.fn()
235+
})
236+
237+
function caretIntoNode(editor: Editor, nodeType: string): void {
238+
editor.state.doc.descendants((node, pos) => {
239+
if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1)
240+
})
241+
}
242+
243+
it.each([
244+
['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'],
245+
['raw HTML block', 'body\n\n<div>\nhello\n</div>', 'rawHtmlBlock'],
246+
])(
247+
'Backspace at the start of a %s does not merge across its boundary and destroy it',
248+
(_label, markdown, nodeType) => {
249+
const editor = editorWith('')
250+
editor.commands.setContent(markdown, { contentType: 'markdown' })
251+
editor.commands.focus()
252+
expect(blockShape(editor)).toContain(nodeType)
253+
caretIntoNode(editor, nodeType)
254+
pressBackspace(editor)
255+
expect(blockShape(editor)).toContain(nodeType)
256+
editor.destroy()
257+
}
258+
)
259+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,56 @@
11
import type { Editor } from '@tiptap/core'
22
import { Extension } from '@tiptap/core'
33
import { GapCursor } from '@tiptap/pm/gapcursor'
4-
import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
4+
import type { ResolvedPos } from '@tiptap/pm/model'
5+
import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
56
import { Decoration, DecorationSet } from '@tiptap/pm/view'
67
import { MENTION_PLUGIN_KEY } from './mention'
78
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
89

910
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
1011
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
1112

13+
/**
14+
* Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
15+
* block out of one of these splits the container in two and strands an empty paragraph — a visible gap
16+
* that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
17+
*/
18+
const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
19+
20+
/** Item node types a list is built from, used to detect an empty item's position within its list. */
21+
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
22+
23+
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
24+
function isInsideWrapper($from: ResolvedPos): boolean {
25+
for (let depth = $from.depth - 1; depth >= 1; depth--) {
26+
if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true
27+
}
28+
return false
29+
}
30+
31+
/**
32+
* Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
33+
* child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
34+
* whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
35+
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
36+
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
37+
* item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
38+
*/
39+
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
40+
let depth = $from.depth
41+
while (depth > 1 && $from.node(depth - 1).childCount === 1) depth--
42+
const start = $from.before(depth)
43+
const end = $from.after(depth)
44+
return editor.commands.command(({ tr, dispatch }) => {
45+
if (dispatch) {
46+
tr.delete(start, end)
47+
tr.setSelection(Selection.near(tr.doc.resolve(start), -1))
48+
dispatch(tr.scrollIntoView())
49+
}
50+
return true
51+
})
52+
}
53+
1254
/**
1355
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
1456
* 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
66108
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
67109
*
68110
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
69-
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a
70-
* block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross
71-
* the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below dividers
72-
* without touching the divider itself), while a *non-empty* block selects the leaf — so a first
73-
* Backspace highlights what a second deletes, the same highlight-before-delete affordance as clicking
74-
* it and parity with the arrow-key leaf selection.
111+
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
112+
* *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
113+
* {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
114+
* the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
115+
* that also re-parses to a different markdown document), while the default `joinBackward` alternately
116+
* no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
117+
* previous item. At the start of a block whose previous sibling is a divider or image, where
118+
* ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
119+
* the blank line between/below dividers without touching the divider itself), while a *non-empty*
120+
* block selects the leaf — so a first Backspace highlights what a second deletes, the same
121+
* highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
122+
* - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
123+
* removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
124+
* empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
125+
* default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
75126
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
76127
* block is already fully selected) falls through to the default whole-document select-all, the
77128
* same scoped behavior as a code editor.
@@ -97,6 +148,9 @@ export const RichMarkdownKeymap = Extension.create({
97148
if ($from.parent.type.name === 'heading') {
98149
return editor.commands.setParagraph()
99150
}
151+
if ($from.parent.content.size === 0 && isInsideWrapper($from)) {
152+
return removeEmptyWrappedBlock(editor, $from)
153+
}
100154
const blockStart = $from.before($from.depth)
101155
const nodeBefore = doc.resolve(blockStart).nodeBefore
102156
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false
@@ -113,6 +167,18 @@ export const RichMarkdownKeymap = Extension.create({
113167
}
114168
return editor.commands.setNodeSelection(leafStart)
115169
},
170+
Enter: ({ editor }) => {
171+
const { selection } = editor.state
172+
if (!selection.empty || selection.$from.parentOffset !== 0) return false
173+
const { $from } = selection
174+
if ($from.parent.content.size !== 0) return false
175+
const itemDepth = $from.depth - 1
176+
if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false
177+
const listDepth = itemDepth - 1
178+
const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1
179+
if (isTrailingItem) return false
180+
return removeEmptyWrappedBlock(editor, $from)
181+
},
116182
'Mod-a': ({ editor }) => {
117183
const { $from } = editor.state.selection
118184
if ($from.parent.type.name !== 'codeBlock') return false

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) {
167167
marks: '',
168168
code: true,
169169
defining: !inline,
170+
// Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from
171+
// joining across their edge, which would otherwise merge their raw markdown into an adjacent
172+
// paragraph as HTML-escaped prose and destroy the node (silent data loss on save).
173+
isolating: !inline,
170174
selectable: true,
171175
atom: false,
172176
parseHTML() {

0 commit comments

Comments
 (0)