diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/drag-handle.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/drag-handle.test.ts
new file mode 100644
index 00000000000..d836ef588fd
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/drag-handle.test.ts
@@ -0,0 +1,63 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { Editor } from '@tiptap/core'
+import { NodeSelection } from '@tiptap/pm/state'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createMarkdownContentExtensions } from '../extensions'
+import { insertBlockBelow, selectBlockAt } from './drag-handle'
+
+let editor: Editor | null = null
+
+afterEach(() => {
+ editor?.destroy()
+ editor = null
+})
+
+function mount(markdown: string): Editor {
+ const created = new Editor({ extensions: createMarkdownContentExtensions() })
+ created.commands.setContent(markdown, { contentType: 'markdown' })
+ return created
+}
+
+/** Position before the first top-level block whose text contains `word`, or -1. */
+function blockPos(target: Editor, word: string): number {
+ let pos = -1
+ target.state.doc.forEach((node, offset) => {
+ if (pos < 0 && node.textContent.includes(word)) pos = offset
+ })
+ return pos
+}
+
+describe('drag-handle block operations', () => {
+ it('inserts a paragraph after the hovered block and opens the slash menu', () => {
+ editor = mount('# One\n\nTwo para')
+ insertBlockBelow(editor, blockPos(editor, 'Two para'))
+ const md = editor.getMarkdown().trim()
+ expect(md).toContain('Two para')
+ expect(md.split('Two para')[1]).toContain('/')
+ })
+
+ it('inserts a sibling after a whole list, not a nested list item', () => {
+ editor = mount('- a\n- b')
+ insertBlockBelow(editor, blockPos(editor, 'a'))
+ expect(editor.getJSON().content?.[0]?.type).toBe('bulletList')
+ expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
+ })
+
+ it('selects the block as a NodeSelection', () => {
+ editor = mount('# One\n\nTwo para')
+ selectBlockAt(editor, blockPos(editor, 'Two para'))
+ const { selection } = editor.state
+ expect(selection instanceof NodeSelection).toBe(true)
+ expect((selection as NodeSelection).node.textContent).toBe('Two para')
+ })
+
+ it('is a no-op at an unresolved position', () => {
+ editor = mount('# One')
+ const before = editor.getMarkdown()
+ insertBlockBelow(editor, -1)
+ selectBlockAt(editor, -1)
+ expect(editor.getMarkdown()).toBe(before)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/drag-handle.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/drag-handle.tsx
new file mode 100644
index 00000000000..3bceaca653a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/drag-handle.tsx
@@ -0,0 +1,85 @@
+'use client'
+
+import { useCallback, useRef } from 'react'
+import DragHandle from '@tiptap/extension-drag-handle-react'
+import type { Editor } from '@tiptap/react'
+import { GripVertical, Plus } from 'lucide-react'
+
+interface BlockDragHandleProps {
+ editor: Editor
+}
+
+interface NodeChangeData {
+ pos: number
+}
+
+/**
+ * Inserts an empty paragraph immediately after the top-level block at `pos` and opens the slash menu
+ * there, so the `+` control adds a new block below the hovered one. A no-op if `pos` doesn't resolve to
+ * a node (e.g. the handle hasn't hovered a block yet).
+ */
+export function insertBlockBelow(editor: Editor, pos: number): void {
+ const node = pos >= 0 ? editor.state.doc.nodeAt(pos) : null
+ if (!node) return
+ const insertAt = pos + node.nodeSize
+ editor
+ .chain()
+ .focus()
+ .insertContentAt(insertAt, { type: 'paragraph' })
+ .setTextSelection(insertAt + 1)
+ .insertContent('/')
+ .run()
+}
+
+/** Selects the top-level block at `pos` as a NodeSelection (the grip's click affordance). */
+export function selectBlockAt(editor: Editor, pos: number): void {
+ if (pos < 0) return
+ editor.chain().setNodeSelection(pos).run()
+ editor.view.focus()
+}
+
+/**
+ * Left-margin block controls revealed on block hover: a `+` that inserts a paragraph below the hovered
+ * block and opens the slash menu ({@link insertBlockBelow}), and a `⠿` grip that drags to reorder (via
+ * `@tiptap/extension-drag-handle`) or, on a plain click, selects the block ({@link selectBlockAt}). The
+ * keyboard equivalent of the reorder is `Mod-Shift-Arrow` (see the block-mover extension).
+ */
+export function BlockDragHandle({ editor }: BlockDragHandleProps) {
+ const hoveredPosRef = useRef(-1)
+
+ const handleNodeChange = useCallback((data: NodeChangeData) => {
+ hoveredPosRef.current = data.pos
+ }, [])
+
+ const insertBelow = useCallback(() => {
+ insertBlockBelow(editor, hoveredPosRef.current)
+ }, [editor])
+
+ const selectBlock = useCallback(() => {
+ selectBlockAt(editor, hoveredPosRef.current)
+ }, [editor])
+
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
index a5d5a82eee4..ec8315ff105 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
@@ -400,17 +400,65 @@
/*
* Highlight mark (`==text==`). An opacity-based amber tint so it reads on both light and dark
* surfaces without a theme override; `color: inherit` keeps the text at the surrounding body color
- * and `box-decoration-break: clone` keeps the tint clean where a highlight wraps across lines.
+ * and `box-decoration-break: clone` keeps the tint clean where a highlight wraps across lines. The
+ * horizontal padding is cancelled by an equal negative margin so the tint bleeds slightly past the
+ * text without ever shifting the text (or any following text) as the highlight is applied/removed.
*/
.rich-markdown-prose mark {
background-color: rgba(255, 212, 0, 0.4);
color: inherit;
border-radius: 2px;
padding: 0 0.1em;
+ margin: 0 -0.1em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
+/*
+ * Left-margin block controls (the `+` / `⠿` handle revealed on block hover). `@tiptap/extension-drag-
+ * handle` positions the wrapper; these rules style the two buttons — a subtle icon pair that darkens on
+ * hover, with the grip showing a grab cursor and the add button a pointer.
+ */
+.rich-md-block-controls {
+ display: flex;
+ align-items: center;
+ gap: 1px;
+ padding-right: 4px;
+}
+
+.rich-md-block-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 22px;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ appearance: none;
+ -webkit-appearance: none;
+ color: var(--text-subtle);
+ cursor: pointer;
+ transition:
+ background-color 0.12s ease,
+ color 0.12s ease;
+}
+
+.rich-md-block-btn:hover,
+.rich-md-block-btn:focus-visible {
+ background-color: var(--surface-active);
+ color: var(--text-icon);
+}
+
+.rich-md-block-grip {
+ cursor: grab;
+}
+
+.rich-md-block-grip:active {
+ cursor: grabbing;
+}
+
/*
* Field variant (modal embed): match the surrounding chip fields' typography exactly —
* body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index d6fc15224a1..00e58de9889 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -23,6 +23,7 @@ import {
import { parseMarkdownToDoc } from './markdown-parse'
import { useEditorMentions } from './mention'
import { EditorBubbleMenu } from './menus/bubble-menu'
+import { BlockDragHandle } from './menus/drag-handle'
import { LinkHoverCard } from './menus/link-hover-card'
import { TableBubbleMenu } from './menus/table-menu'
import { normalizeMarkdownContent } from './normalize-content'
@@ -446,6 +447,7 @@ export function LoadedRichMarkdownEditor({
{editor && }
{editor && }
{editor && }
+ {editor && isEditable && }