Skip to content

Commit deff589

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, and no-ops at the document edges. Pure UI interaction, no schema change. Covered by tests (up/down, edge no-op, list stays intact).
1 parent 55fce05 commit deff589

4 files changed

Lines changed: 138 additions & 1 deletion

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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. After the swap the moved
17+
* block begins one token into the span (up) or just after the neighbour it leapfrogged (down), so
18+
* `movedStart` re-anchors the caret at its original offset 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 movedStart = (up ? spanFrom : spanFrom + neighbour.size) + 1
48+
const offset = state.selection.from - from
49+
tr.setSelection(
50+
TextSelection.near(tr.doc.resolve(Math.min(movedStart + offset, movedStart - 1 + moving.size)))
51+
)
52+
dispatch(tr.scrollIntoView())
53+
return true
54+
}
55+
56+
/**
57+
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
58+
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
59+
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
60+
*/
61+
export const BlockMover = Extension.create({
62+
name: 'blockMover',
63+
64+
addKeyboardShortcuts() {
65+
return {
66+
'Mod-Shift-ArrowUp': ({ editor }) => moveBlock(editor.state, editor.view.dispatch, 'up'),
67+
'Mod-Shift-ArrowDown': ({ editor }) => moveBlock(editor.state, editor.view.dispatch, 'down'),
68+
}
69+
},
70+
})

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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,67 @@ 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+
/** Fire a Mod-Shift-Arrow chord (both meta+ctrl set so it matches Mod on either platform). */
267+
function pressMoveBlock(editor: Editor, direction: 'ArrowUp' | 'ArrowDown'): void {
268+
editor.view.dom.dispatchEvent(
269+
new KeyboardEvent('keydown', {
270+
key: direction,
271+
shiftKey: true,
272+
metaKey: true,
273+
ctrlKey: true,
274+
bubbles: true,
275+
cancelable: true,
276+
})
277+
)
278+
}
279+
280+
function caretInto(editor: Editor, word: string): void {
281+
editor.state.doc.descendants((node, pos) => {
282+
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
283+
})
284+
}
285+
286+
it('moves the current top-level block up, carrying the caret', () => {
287+
const editor = editorWith('')
288+
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
289+
editor.commands.focus()
290+
caretInto(editor, 'Two')
291+
pressMoveBlock(editor, 'ArrowUp')
292+
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
293+
editor.destroy()
294+
})
295+
296+
it('moves the current top-level block down', () => {
297+
const editor = editorWith('')
298+
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
299+
editor.commands.focus()
300+
caretInto(editor, 'One')
301+
pressMoveBlock(editor, 'ArrowDown')
302+
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
303+
editor.destroy()
304+
})
305+
306+
it('is a no-op at the top edge and keeps a moved list intact', () => {
307+
const top = editorWith('')
308+
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
309+
top.commands.focus()
310+
caretInto(top, 'One')
311+
pressMoveBlock(top, 'ArrowUp')
312+
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
313+
top.destroy()
314+
315+
const list = editorWith('')
316+
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
317+
list.commands.focus()
318+
caretInto(list, 'para')
319+
pressMoveBlock(list, 'ArrowUp')
320+
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
321+
list.destroy()
322+
})
323+
})

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)