Skip to content

Commit 17530cd

Browse files
committed
feat(rich-markdown-editor): keyboard block reordering (Mod-Shift-Arrow)
Mod-Shift-ArrowUp/ArrowDown swaps the current top-level block with its neighbour, carrying the caret at its original offset (newBefore + offset, no off-by-one), and no-ops at the document edges. Exposed as moveBlockUp/moveBlockDown commands (the keyboard shortcuts call them). Pure UI interaction, no schema change. Covered by tests (order, caret offset, edge no-op, list stays intact).
1 parent 657235a commit 17530cd

4 files changed

Lines changed: 168 additions & 1 deletion

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Extension } from '@tiptap/core'
2+
import type { EditorState, Transaction } from '@tiptap/pm/state'
3+
import { TextSelection } from '@tiptap/pm/state'
4+
5+
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
6+
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
7+
const { $from } = state.selection
8+
if ($from.depth === 0) return null
9+
return { from: $from.before(1), to: $from.after(1) }
10+
}
11+
12+
/**
13+
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
14+
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
15+
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
16+
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
17+
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
18+
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
19+
*/
20+
function moveBlock(
21+
state: EditorState,
22+
dispatch: ((tr: Transaction) => void) | undefined,
23+
direction: 'up' | 'down'
24+
): boolean {
25+
const block = currentTopLevelBlock(state)
26+
if (!block) return false
27+
const { from, to } = block
28+
const up = direction === 'up'
29+
30+
if (up ? from === 0 : to >= state.doc.content.size) return false
31+
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
32+
if ($neighbour.depth === 0) return false
33+
if (!dispatch) return true
34+
35+
const spanFrom = up ? $neighbour.before(1) : from
36+
const spanTo = up ? to : $neighbour.after(1)
37+
const moving = state.doc.slice(from, to).content
38+
const neighbour = up
39+
? state.doc.slice(spanFrom, from).content
40+
: state.doc.slice(to, spanTo).content
41+
const tr = state.tr.replaceWith(
42+
spanFrom,
43+
spanTo,
44+
up ? moving.append(neighbour) : neighbour.append(moving)
45+
)
46+
47+
const newBefore = up ? spanFrom : spanFrom + neighbour.size
48+
const offset = state.selection.from - from
49+
tr.setSelection(
50+
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
51+
)
52+
dispatch(tr.scrollIntoView())
53+
return true
54+
}
55+
56+
declare module '@tiptap/core' {
57+
interface Commands<ReturnType> {
58+
blockMover: {
59+
/** Move the current top-level block up one position, carrying the caret. */
60+
moveBlockUp: () => ReturnType
61+
/** Move the current top-level block down one position, carrying the caret. */
62+
moveBlockDown: () => ReturnType
63+
}
64+
}
65+
}
66+
67+
/**
68+
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
69+
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
70+
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
71+
*/
72+
export const BlockMover = Extension.create({
73+
name: 'blockMover',
74+
75+
addCommands() {
76+
return {
77+
moveBlockUp:
78+
() =>
79+
({ state, dispatch }) =>
80+
moveBlock(state, dispatch, 'up'),
81+
moveBlockDown:
82+
() =>
83+
({ state, dispatch }) =>
84+
moveBlock(state, dispatch, 'down'),
85+
}
86+
},
87+
88+
addKeyboardShortcuts() {
89+
return {
90+
'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(),
91+
'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(),
92+
}
93+
},
94+
})

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Extensions } from '@tiptap/core'
22
import Placeholder from '@tiptap/extension-placeholder'
3+
import { BlockMover } from './block-mover'
34
import { CodeBlockWithLanguage } from './code-block'
45
import { CodeBlockHighlight } from './code-highlight'
56
import { LinkEmbed } from './embed/link-embed'
@@ -44,6 +45,7 @@ export function createMarkdownEditorExtensions({
4445
SlashCommand,
4546
Mention,
4647
RichMarkdownKeymap,
48+
BlockMover,
4749
MarkdownPaste,
4850
Placeholder.configure({ placeholder }),
4951
...(embeds ? [LinkEmbed] : []),

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,73 @@ describe('verbatim block boundary (isolating)', () => {
257257
}
258258
)
259259
})
260+
261+
describe('block reordering (Mod-Shift-Arrow)', () => {
262+
beforeEach(() => {
263+
Element.prototype.scrollIntoView = vi.fn()
264+
})
265+
266+
function caretInto(editor: Editor, word: string): void {
267+
editor.state.doc.descendants((node, pos) => {
268+
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
269+
})
270+
}
271+
272+
it('moves the current top-level block up, carrying the caret', () => {
273+
const editor = editorWith('')
274+
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
275+
editor.commands.focus()
276+
caretInto(editor, 'Two')
277+
editor.commands.moveBlockUp()
278+
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
279+
editor.destroy()
280+
})
281+
282+
it('moves the current top-level block down', () => {
283+
const editor = editorWith('')
284+
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
285+
editor.commands.focus()
286+
caretInto(editor, 'One')
287+
editor.commands.moveBlockDown()
288+
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
289+
editor.destroy()
290+
})
291+
292+
it.each([
293+
['up', '# One\n\nabcdef'],
294+
['down', 'abcdef\n\n# Two'],
295+
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
296+
const editor = editorWith('')
297+
editor.commands.setContent(md, { contentType: 'markdown' })
298+
editor.commands.focus()
299+
let textPos = -1
300+
editor.state.doc.descendants((node, pos) => {
301+
if (node.isText && node.text === 'abcdef') textPos = pos
302+
})
303+
editor.commands.setTextSelection(textPos + 3)
304+
if (direction === 'up') editor.commands.moveBlockUp()
305+
else editor.commands.moveBlockDown()
306+
const at = editor.state.selection.from
307+
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
308+
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
309+
editor.destroy()
310+
})
311+
312+
it('is a no-op at the top edge and keeps a moved list intact', () => {
313+
const top = editorWith('')
314+
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
315+
top.commands.focus()
316+
caretInto(top, 'One')
317+
top.commands.moveBlockUp()
318+
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
319+
top.destroy()
320+
321+
const list = editorWith('')
322+
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
323+
list.commands.focus()
324+
caretInto(list, 'para')
325+
list.commands.moveBlockUp()
326+
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
327+
list.destroy()
328+
})
329+
})

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
128128
* same scoped behavior as a code editor.
129129
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
130130
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
131-
* ({@link selectAdjacentSelectedLeaf}).
131+
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
132+
* in `./block-mover.ts`.)
132133
*
133134
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
134135
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the

0 commit comments

Comments
 (0)