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 && }