Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { RichMarkdownKeymap } from './keymap'
import { MarkdownPaste } from './markdown-paste'
import { Mention } from './mention/mention'
import { MentionChip } from './mention/mention-chip'
import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet'
import { SlashCommand } from './slash-command/slash-command'

interface MarkdownEditorExtensionOptions {
Expand Down Expand Up @@ -36,6 +37,8 @@ export function createMarkdownEditorExtensions({
codeBlock: CodeBlockWithLanguage,
image: ResizableImage,
mention: MentionChip,
rawHtmlBlock: RawHtmlBlockWithView,
footnoteDef: FootnoteDefWithView,
}),
CodeBlockHighlight,
SlashCommand,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MarkdownImage } from './image'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownMention } from './mention/mention-node'
import { SIM_LINK_SCHEME } from './mention/sim-link'
import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet'

/**
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
Expand Down Expand Up @@ -66,6 +67,8 @@ export interface ContentNodeViews {
codeBlock?: Node
image?: Node
mention?: Node
rawHtmlBlock?: Node
footnoteDef?: Node
}

/**
Expand Down Expand Up @@ -100,6 +103,10 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
TableRow,
TableHeader,
TableCell,
nodeViews.rawHtmlBlock ?? RawHtmlBlock,
nodeViews.footnoteDef ?? FootnoteDef,
FootnoteRef,
RawInlineHtml,
MarkdownLinkInputRule,
Markdown,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,32 @@ describe('markdown paste', () => {
expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false)
})

it('does not markdown-parse a paste that carries richer HTML', () => {
it("prefers the markdown parser over DOM mapping when the HTML sibling's plain-text side also looks like markdown", () => {
editor = mount()
expect(paste(editor, '# heading', '<h1>heading</h1>')).toBe(false)
expect(paste(editor, '# heading', '<h1>heading</h1>')).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"type":"heading"')
})

it('preserves GFM table alignment on a paste that carries both text/plain and text/html', () => {
editor = mount()
const table = '| a | b |\n| :-- | --: |\n| 1 | 2 |'
const html = '<table><tr><td>a</td><td>b</td></tr><tr><td>1</td><td>2</td></tr></table>'
expect(paste(editor, table, html)).toBe(true)
const json = JSON.stringify(editor.getJSON())
expect(json).toContain('"align":"left"')
expect(json).toContain('"align":"right"')
})

it('still defers to DOM mapping when the HTML sibling has no markdown-shaped plain-text counterpart', () => {
editor = mount()
expect(
paste(
editor,
'just a normal sentence with no syntax',
'<p>just a normal sentence with no syntax</p>'
)
).toBe(false)
})

it('keeps pasted markdown literal inside a code block', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@ function looksLikeMarkdown(text: string): boolean {

/**
* Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block
* are left untouched (code is meant to stay literal), as are pastes that carry richer HTML — those
* are handled by ProseMirror's own clipboard parsing.
* are left untouched (code is meant to stay literal).
*
* A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub,
* or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping,
* even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully
* (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only
* defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard
* payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word
* processor, a web page selection, …) still goes through the DOM path unchanged.
*/
export const MarkdownPaste = Extension.create({
name: 'markdownPaste',
Expand All @@ -38,8 +45,6 @@ export const MarkdownPaste = Extension.create({
handlePaste: (_view, event) => {
if (!editor.isEditable) return false
if (editor.isActive('codeBlock')) return false
const html = event.clipboardData?.getData('text/html')
if (html) return false
const text = event.clipboardData?.getData('text/plain')
if (!text || !looksLikeMarkdown(text)) return false
// Parse through the chunker (linear) so pasting a large markdown blob can't freeze the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @vitest-environment jsdom
*
* `TableBubbleMenu` (table-menu.tsx) is a thin UI wrapper around `@tiptap/extension-table`'s stock
* commands — the button that matters is the command it calls, not the floating-toolbar chrome. These
* exercise the exact commands the toolbar wires up (`addRowBefore`/`addRowAfter`/`deleteRow`,
* `addColumnBefore`/`addColumnAfter`/`deleteColumn`, `toggleHeaderRow`, `deleteTable`) against a real
* editor and assert the result round-trips through `PipeSafeTable` to clean, correctly-shaped GFM.
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownContentExtensions } from '../extensions'

let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})

function mount(markdown: string): Editor {
return new Editor({
extensions: createMarkdownContentExtensions(),
content: markdown,
contentType: 'markdown',
})
}

function firstCellPos(ed: Editor): number {
let pos = -1
ed.state.doc.descendants((node, p) => {
if (pos < 0 && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) pos = p + 1
})
return pos
}

describe('table toolbar commands', () => {
it('inserts a row after the current row and it round-trips as a clean GFM table', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addRowAfter()).toBe(true)

const rows = editor.state.doc.firstChild
expect(rows?.type.name).toBe('table')
expect(rows?.childCount).toBe(3) // header + original row + inserted row

const md = editor.getMarkdown().trim()
expect(md.split('\n')).toHaveLength(4)
expect(md).toContain('| a')
expect(md).toContain('| --- | --- |')
})

it('inserts a row before the current row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addRowBefore()).toBe(true)
expect(editor.state.doc.firstChild?.childCount).toBe(3)
})

it('deletes the current row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |')
// Select the second body row (skip header).
let pos = -1
let seen = 0
editor.state.doc.descendants((node, p) => {
if (node.type.name === 'tableRow') {
seen++
if (seen === 3) pos = p + 2
}
})
editor.commands.setTextSelection(pos)
expect(editor.commands.deleteRow()).toBe(true)
expect(editor.state.doc.firstChild?.childCount).toBe(2)
expect(editor.getMarkdown()).toContain('| 1 | 2 |')
expect(editor.getMarkdown()).not.toContain('3')
})

it('inserts and deletes a column', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.addColumnAfter()).toBe(true)
let cols = 0
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow' && cols === 0) cols = node.childCount
})
expect(cols).toBe(3)

// The insert shifted positions — the cursor's old cell no longer maps to the same column, so
// re-select the first cell before deleting, exactly as a real user would click a cell first.
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.deleteColumn()).toBe(true)
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow') cols = node.childCount
})
expect(cols).toBe(2)
})

it('toggles the header row', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
const before = editor.isActive('tableHeader')
expect(editor.commands.toggleHeaderRow()).toBe(true)
expect(editor.isActive('tableHeader')).toBe(!before)
})

it('deletes the whole table', () => {
editor = mount('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter')
editor.commands.setTextSelection(firstCellPos(editor))
expect(editor.commands.deleteTable()).toBe(true)
const types: string[] = []
editor.state.doc.forEach((node) => types.push(node.type.name))
expect(types).not.toContain('table')
expect(editor.getMarkdown()).toContain('before')
expect(editor.getMarkdown()).toContain('after')
})

it('a full add-row + add-column + delete-row sequence stays idempotent on re-serialize', () => {
editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |')
editor.commands.setTextSelection(firstCellPos(editor))
editor.commands.addRowAfter()
editor.commands.addColumnAfter()
const once = editor.getMarkdown().trim()
const reparsed = new Editor({
extensions: createMarkdownContentExtensions(),
content: once,
contentType: 'markdown',
})
const twice = reparsed.getMarkdown().trim()
reparsed.destroy()
expect(twice).toBe(once)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Columns3,
Rows3,
Table as TableIcon,
Trash2,
} from 'lucide-react'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'

/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const

/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body

interface TableBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}

/**
* Floating toolbar shown whenever the selection is inside a table: row/column insert-before/after,
* row/column delete, header-row toggle, and delete-table. `@tiptap/extension-table` already exposes
* all of these as editor commands (`addRowBefore`, `addColumnAfter`, …) — this is UI only, no schema
* or serializer change.
*/
export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) {
const [menuKey] = useState(() => new PluginKey('markdownTableMenu'))

const active = useEditorState({
editor,
selector: ({ editor: e }) => ({
headerRow: e.isActive('tableHeader'),
}),
})

// Recomputed on every call (not cached by selection key) — the same table cell can land at a
// different screen position purely from scrolling with no selection change, and Floating UI's
// `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time.
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.top < viewport.top
? new DOMRect(selection.left, viewport.top, selection.width, 0)
: selection
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])

return (
<BubbleMenu
editor={editor}
pluginKey={menuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
role='toolbar'
aria-label='Table editing'
updateDelay={0}
shouldShow={({ editor: e }) => e.isEditable && e.isActive('table')}
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none'
>
<ToolbarButton
icon={ArrowUp}
label='Insert row above'
isActive={false}
onClick={() => editor.chain().focus().addRowBefore().run()}
/>
<ToolbarButton
icon={ArrowDown}
label='Insert row below'
isActive={false}
onClick={() => editor.chain().focus().addRowAfter().run()}
/>
<ToolbarButton
icon={Rows3}
label='Delete row'
isActive={false}
onClick={() => editor.chain().focus().deleteRow().run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={ArrowLeft}
label='Insert column left'
isActive={false}
onClick={() => editor.chain().focus().addColumnBefore().run()}
/>
<ToolbarButton
icon={ArrowRight}
label='Insert column right'
isActive={false}
onClick={() => editor.chain().focus().addColumnAfter().run()}
/>
<ToolbarButton
icon={Columns3}
label='Delete column'
isActive={false}
onClick={() => editor.chain().focus().deleteColumn().run()}
/>
<ToolbarDivider />
<ToolbarButton
icon={TableIcon}
label='Toggle header row'
isActive={active.headerRow}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
/>
<ToolbarButton
icon={Trash2}
label='Delete table'
isActive={false}
onClick={() => editor.chain().focus().deleteTable().run()}
/>
</BubbleMenu>
)
}
Loading
Loading