fix(rich-markdown-editor): highlight text-shift + hover block drag handle (#5543)

* fix(rich-markdown-editor): keep highlight from shifting text

The highlight <mark> added horizontal padding, which pushed the highlighted text (and the
text after it) to the right when the mark was applied. Cancel the padding with an equal
negative margin so the amber tint still bleeds slightly past the text but the text never
moves as a highlight is applied or removed.

* feat(rich-markdown-editor): hover block drag handle (+ / grip)

Adds a left-margin block handle revealed on hover (only when the editor is editable): a + that
inserts a paragraph below the hovered block and opens the slash menu, and a grip that drags to
reorder blocks (via @tiptap/extension-drag-handle) or, on a plain click, selects the block. The
keyboard equivalent of the reorder is Mod-Shift-Arrow (block-mover). The control buttons reset
their own chrome (no default border/background/padding) so they render consistently regardless
of the surrounding reset.
This commit is contained in:
Waleed
2026-07-09 14:54:13 -07:00
committed by GitHub
parent 6168cff79a
commit ff5353cd83
6 changed files with 219 additions and 1 deletions
@@ -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)
})
})
@@ -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 (
<DragHandle editor={editor} onNodeChange={handleNodeChange}>
<div className='rich-md-block-controls'>
<button
type='button'
aria-label='Insert block below'
className='rich-md-block-btn'
onMouseDown={(event) => event.preventDefault()}
onClick={insertBelow}
>
<Plus size={15} strokeWidth={2} />
</button>
<button
type='button'
aria-label='Drag to reorder, or click to select the block'
className='rich-md-block-btn rich-md-block-grip'
onClick={selectBlock}
>
<GripVertical size={15} strokeWidth={2} />
</button>
</div>
</DragHandle>
)
}
@@ -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
@@ -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 && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <TableBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
{editor && isEditable && <BlockDragHandle editor={editor} />}
<input
ref={imageInputRef}
type='file'
+1
View File
@@ -114,6 +114,7 @@
"@tanstack/react-virtual": "3.13.24",
"@tiptap/core": "3.26.1",
"@tiptap/extension-code-block": "3.26.1",
"@tiptap/extension-drag-handle-react": "3.26.1",
"@tiptap/extension-image": "3.26.1",
"@tiptap/extension-list": "3.26.1",
"@tiptap/extension-placeholder": "3.26.1",
+19
View File
@@ -183,6 +183,7 @@
"@tanstack/react-virtual": "3.13.24",
"@tiptap/core": "3.26.1",
"@tiptap/extension-code-block": "3.26.1",
"@tiptap/extension-drag-handle-react": "3.26.1",
"@tiptap/extension-image": "3.26.1",
"@tiptap/extension-list": "3.26.1",
"@tiptap/extension-placeholder": "3.26.1",
@@ -1750,8 +1751,14 @@
"@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ=="],
"@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/y-tiptap": "^3.0.5", "yjs": "^13" } }, "sha512-NLF3tWPla1bg9VAaICTrheEjDi9ZFGk1HhoALfVHWSwIqnUpVxSubyBxw/PFWyHYZNQrxaGvypf457NHHINolA=="],
"@tiptap/extension-document": ["@tiptap/extension-document@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA=="],
"@tiptap/extension-drag-handle": ["@tiptap/extension-drag-handle@3.26.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13" }, "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/extension-collaboration": "3.26.1", "@tiptap/extension-node-range": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/y-tiptap": "^3.0.5" } }, "sha512-LaZDIBjBT1b7vJImwe6GdNCQlTKeIc0bbF+GZGAVzuLvjczBmWaZRaOleHELomzPeAY/t9cyV8xNJuq8pN+e3A=="],
"@tiptap/extension-drag-handle-react": ["@tiptap/extension-drag-handle-react@3.26.1", "", { "peerDependencies": { "@tiptap/extension-drag-handle": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/react": "3.26.1", "react": "^16.8 || ^17 || ^18 || ^19", "react-dom": "^16.8 || ^17 || ^18 || ^19" } }, "sha512-70euHOq3aeeVJAksjNeX2yUoWGPiOwgnp8o12Eu9B/wbRw7LHQaazEEUeRE/ed3azEYKaBRfBS8CWv9N4hF1Zw=="],
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.26.1", "", { "peerDependencies": { "@tiptap/extensions": "3.26.1" } }, "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA=="],
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.26.1", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw=="],
@@ -1776,6 +1783,8 @@
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.26.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.26.1" } }, "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ=="],
"@tiptap/extension-node-range": ["@tiptap/extension-node-range@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-tsxy2ROyK3LLUeV/rVbDyXEZNCVwirul7Aj0ykHWRch9c+4j+bJkvdLwOhaBE/eGCooniwh1DkpYjvzzoC8owQ=="],
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.26.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.26.1" } }, "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw=="],
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw=="],
@@ -1802,6 +1811,8 @@
"@tiptap/suggestion": ["@tiptap/suggestion@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-Bg8IyuDC92InSPzcHvCT3+ZDCJSMJIEINdFg513RPQzwZTw1dsrU0K00XYcDT6lOhZwLM2IVTiE6sZl2GY25Rg=="],
"@tiptap/y-tiptap": ["@tiptap/y-tiptap@3.0.6", "", { "dependencies": { "lib0": "^0.2.100" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-kcGeVGKtq/cPGVseNKjtmtcY2WXUAEm1SqS5x0Smubj4nOCRyPiHg6kY4QuuZhmXjTK7hdo8chokkPUKWXPE9Q=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="],
@@ -2918,6 +2929,8 @@
"isomorphic-unfetch": ["isomorphic-unfetch@3.1.0", "", { "dependencies": { "node-fetch": "^2.6.1", "unfetch": "^4.2.0" } }, "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q=="],
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
@@ -2980,6 +2993,8 @@
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
@@ -4092,6 +4107,8 @@
"xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
@@ -4104,6 +4121,8 @@
"yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="],
"yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="],