feat(editor): add Markdown table structure controls (#11985)

* feat(editor): add markdown table structure controls

* fix(editor): scope table context actions to cells

* Replace table toolbar with context-aware overlay controls

Replace the fixed table toolbar with context-sensitive overlay controls that position themselves around the active table, adding support for direct row/column insertion and full table deletion. This approach is less intrusive and supports click-targeted actions via coordinate-based cell resolution. Enhance structural safety by preventing header removal and ensuring tables never collapse below a single cell, deleting instead when the final row or column is removed. Harden the context-menu query with a 120ms timeout to keep the native menu responsive even if the renderer hangs.

* Replace markdown table context query with IPC coordination

Capture table cell targets on pointerdown and report via IPC channel
instead of executing JavaScript on context-menu events. Eliminates
120ms query timeout and unavailability race conditions. Header cells
now disable incompatible row-level actions.

* fix(editor): make table column rebalancing atomic with insertion

- Refactor rebalanceAddedColumn to mutate the caller's transaction, grouping
  insertion and rebalance into a single undo step
- Add validation for cached cell positions that may outlive the document
- Fix table detection to use isInTable() instead of isActive('table')
- Correct z-index layering to respect menu stacking context
- Fix cleanup of stale animation frames and pending pointer state

---------

Co-authored-by: rainL <WYK15@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
rainL 2026-08-06 02:10:05 +08:00 committed by GitHub
parent 7aba9b306b
commit 6942871194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2589 additions and 35 deletions

View File

@ -2742,7 +2742,7 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenCalledWith('ui:toggleLeftSidebar')
})
it('shows spellcheck context menu for editable text without relying on markdown focus mirror', () => {
it('opens a table-aware context menu synchronously without a renderer query', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
@ -2778,12 +2778,22 @@ describe('createMainWindow', () => {
createMainWindow(null)
const tableTargetListener = vi
.mocked(ipcMain.on)
.mock.calls.find(([channel]) => channel === 'rich-markdown:context-target')?.[1]
tableTargetListener?.({ sender: webContents } as never, {
cellType: 'body',
targetId: 'table-target',
x: 42,
y: 84
})
windowHandlers['context-menu'](
{} as never,
{
x: 42,
y: 84,
isEditable: true,
formControlType: 'none',
spellcheckEnabled: true,
dictionarySuggestions: ['reference'],
misspelledWord: 'refrence'
@ -2791,7 +2801,10 @@ describe('createMainWindow', () => {
)
expect(buildFromTemplateMock).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ label: 'reference' })])
expect.arrayContaining([
expect.objectContaining({ label: 'reference' }),
expect.objectContaining({ label: 'Table' })
])
)
expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 })
})

View File

@ -42,7 +42,15 @@ import {
type KeybindingOverrides
} from '../../shared/keybindings'
import { getMainE2EConfig } from '../e2e-config'
import { buildEditableContextMenuTemplate } from './editable-context-menu'
import {
buildEditableContextMenuTemplate,
matchingRichMarkdownContextMenuTableTarget,
parseRichMarkdownContextMenuTableTarget
} from './editable-context-menu'
import {
richMarkdownContextMenuTargetChannel,
type RichMarkdownContextMenuTableTarget
} from '../../shared/rich-markdown-context-menu'
import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui'
import { resolveWindowCloseAction } from './window-close-decision'
import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
@ -527,9 +535,24 @@ export function createMainWindow(
}
ipcMain.on(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
let pendingRichMarkdownContextMenuTableTarget: RichMarkdownContextMenuTableTarget | null = null
const onRichMarkdownContextMenuTarget = (event: Electron.IpcMainEvent, value: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
}
pendingRichMarkdownContextMenuTableTarget = parseRichMarkdownContextMenuTableTarget(value)
}
ipcMain.on(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget)
const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
const template = buildEditableContextMenuTemplate(params, mainWindow.webContents)
if (template.length === 0) {
const tableTarget = matchingRichMarkdownContextMenuTableTarget(
params,
pendingRichMarkdownContextMenuTableTarget
)
pendingRichMarkdownContextMenuTableTarget = null
const template = buildEditableContextMenuTemplate(params, mainWindow.webContents, {
tableTarget
})
if (template.length === 0 || mainWindow.isDestroyed()) {
return
}
// Why: the context-menu event can precede our focus-mirror update; trust Electron's editable params, not markdownEditorFocused.
@ -540,6 +563,7 @@ export function createMainWindow(
// Why: a dead renderer can't clear its focus mirror; default-deny carve-outs so it can't disable app shortcuts in a later lifecycle.
const resetMarkdownEditorFocus = (): void => {
markdownEditorFocused = false
pendingRichMarkdownContextMenuTableTarget = null
}
const resetTerminalInputFocus = (): void => {
terminalInputFocused = false
@ -1121,6 +1145,7 @@ export function createMainWindow(
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
ipcMain.removeListener(floatingFocusChannel, onFloatingFocus)
ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
ipcMain.removeListener(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget)
// Why: powerMonitor is app-global; without this the resume relay leaks and fires against a destroyed webContents.
powerMonitor.removeListener('resume', onSystemResume)
clearTrustedUIRendererWebContentsId(rendererWebContentsId)

View File

@ -1,5 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { buildEditableContextMenuTemplate } from './editable-context-menu'
import {
buildEditableContextMenuTemplate,
matchingRichMarkdownContextMenuTableTarget,
parseRichMarkdownContextMenuTableTarget
} from './editable-context-menu'
import { richMarkdownContextMenuCommandChannel } from '../../shared/rich-markdown-context-menu'
function contextParams(
@ -80,7 +84,8 @@ describe('buildEditableContextMenuTemplate', () => {
replaceMisspelling: vi.fn(),
send,
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
}
},
{ tableTarget: { cellType: 'body', targetId: 'table-target-1', x: 12, y: 34 } }
)
expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([
@ -89,6 +94,7 @@ describe('buildEditableContextMenuTemplate', () => {
'Format',
'Paragraph',
'Insert',
'Table',
'separator',
'cut',
'copy',
@ -140,12 +146,62 @@ describe('buildEditableContextMenuTemplate', () => {
y: 34
})
template[8].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
const tableMenu = template[5].submenu as Electron.MenuItemConstructorOptions[]
expect(tableMenu.map((item) => item.label ?? item.type)).toEqual([
'Insert row above',
'Insert row below',
'Delete row',
'separator',
'Insert column left',
'Insert column right',
'Delete column',
'separator',
'Delete table'
])
tableMenu[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(send).toHaveBeenLastCalledWith(richMarkdownContextMenuCommandChannel, {
command: 'insert-row-above',
tableTargetId: 'table-target-1',
x: 12,
y: 34
})
template[9].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
template[10].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false })
expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true })
})
it('omits table actions when the context target is outside a table', () => {
const template = buildEditableContextMenuTemplate(
contextParams({ misspelledWord: '', dictionarySuggestions: [] }),
{
replaceMisspelling: vi.fn(),
send: vi.fn(),
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
}
)
expect(template.map((item) => item.label ?? item.role ?? item.type)).not.toContain('Table')
})
it('disables native row actions that cannot cross the Markdown header boundary', () => {
const template = buildEditableContextMenuTemplate(
contextParams({ x: 12, y: 34, misspelledWord: '', dictionarySuggestions: [] }),
{
replaceMisspelling: vi.fn(),
send: vi.fn(),
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
},
{ tableTarget: { cellType: 'header', targetId: 'header-target', x: 12, y: 34 } }
)
const tableMenu = template[5].submenu as Electron.MenuItemConstructorOptions[]
expect(tableMenu[0]).toMatchObject({ label: 'Insert row above', enabled: false })
expect(tableMenu[1]).toMatchObject({ label: 'Insert row below' })
expect(tableMenu[2]).toMatchObject({ label: 'Delete row', enabled: false })
})
it('does not build a menu outside editable text', () => {
const webContents = {
replaceMisspelling: vi.fn(),
@ -217,3 +273,28 @@ describe('buildEditableContextMenuTemplate', () => {
expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true })
})
})
describe('rich markdown context-menu table targets', () => {
const target = { cellType: 'body' as const, targetId: 'table-target', x: 12, y: 34 }
it('accepts a valid renderer-reported table target', () => {
expect(parseRichMarkdownContextMenuTableTarget(target)).toEqual(target)
expect(
matchingRichMarkdownContextMenuTableTarget(contextParams({ x: 12, y: 34 }), target)
).toEqual(target)
})
it('rejects malformed, stale, and non-rich targets', () => {
expect(parseRichMarkdownContextMenuTableTarget({ ...target, x: Number.NaN })).toBeNull()
expect(parseRichMarkdownContextMenuTableTarget({ ...target, cellType: 'footer' })).toBeNull()
expect(
matchingRichMarkdownContextMenuTableTarget(contextParams({ x: 13, y: 34 }), target)
).toBeNull()
expect(
matchingRichMarkdownContextMenuTableTarget(
contextParams({ x: 12, y: 34, formControlType: 'input-text' }),
target
)
).toBeNull()
})
})

View File

@ -1,8 +1,10 @@
import {
richMarkdownContextMenuCommandChannel,
type RichMarkdownContextMenuCommand,
type RichMarkdownContextMenuCommandPayload
type RichMarkdownContextMenuCommandPayload,
type RichMarkdownContextMenuTableTarget
} from '../../shared/rich-markdown-context-menu'
import { translateMain } from '../i18n/main-i18n'
type EditableContextMenuWebContents = Pick<
Electron.WebContents,
@ -13,12 +15,17 @@ function markdownCommandItem(
label: string,
command: RichMarkdownContextMenuCommand,
webContents: EditableContextMenuWebContents,
point: { x: number; y: number }
point: { x: number; y: number },
tableTargetId?: string
): Electron.MenuItemConstructorOptions {
return {
label,
click: () => {
const payload: RichMarkdownContextMenuCommandPayload = { command, ...point }
const payload: RichMarkdownContextMenuCommandPayload = {
command,
...point,
...(tableTargetId ? { tableTargetId } : {})
}
webContents.send(richMarkdownContextMenuCommandChannel, payload)
}
}
@ -44,7 +51,8 @@ function editableContextPasteItem(
function buildMarkdownMenuTemplate(
webContents: EditableContextMenuWebContents,
point: { x: number; y: number }
point: { x: number; y: number },
tableTarget: RichMarkdownContextMenuTableTarget | null
): Electron.MenuItemConstructorOptions[] {
return [
markdownCommandItem('Add link', 'add-link', webContents, point),
@ -84,6 +92,84 @@ function buildMarkdownMenuTemplate(
markdownCommandItem('Code block', 'code-block', webContents, point)
]
},
...(tableTarget
? [
{
label: translateMain('auto.main.window.editableContextMenu.table', 'Table'),
submenu: [
{
...markdownCommandItem(
translateMain(
'auto.main.window.editableContextMenu.insertRowAbove',
'Insert row above'
),
'insert-row-above',
webContents,
point,
tableTarget.targetId
),
enabled: tableTarget.cellType !== 'header'
},
markdownCommandItem(
translateMain(
'auto.main.window.editableContextMenu.insertRowBelow',
'Insert row below'
),
'insert-row-below',
webContents,
point,
tableTarget.targetId
),
{
...markdownCommandItem(
translateMain('auto.main.window.editableContextMenu.deleteRow', 'Delete row'),
'delete-row',
webContents,
point,
tableTarget.targetId
),
enabled: tableTarget.cellType !== 'header'
},
{ type: 'separator' as const },
markdownCommandItem(
translateMain(
'auto.main.window.editableContextMenu.insertColumnLeft',
'Insert column left'
),
'insert-column-left',
webContents,
point,
tableTarget.targetId
),
markdownCommandItem(
translateMain(
'auto.main.window.editableContextMenu.insertColumnRight',
'Insert column right'
),
'insert-column-right',
webContents,
point,
tableTarget.targetId
),
markdownCommandItem(
translateMain('auto.main.window.editableContextMenu.deleteColumn', 'Delete column'),
'delete-column',
webContents,
point,
tableTarget.targetId
),
{ type: 'separator' as const },
markdownCommandItem(
translateMain('auto.main.window.editableContextMenu.deleteTable', 'Delete table'),
'delete-table',
webContents,
point,
tableTarget.targetId
)
]
}
]
: []),
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
@ -107,7 +193,8 @@ function buildNativeEditMenuTemplate(
export function buildEditableContextMenuTemplate(
params: Electron.ContextMenuParams,
webContents: EditableContextMenuWebContents
webContents: EditableContextMenuWebContents,
options?: { tableTarget?: RichMarkdownContextMenuTableTarget | null }
): Electron.MenuItemConstructorOptions[] {
if (!params.isEditable) {
return []
@ -137,9 +224,55 @@ export function buildEditableContextMenuTemplate(
}
template.push(
...(isRichMarkdownSurface
? buildMarkdownMenuTemplate(webContents, { x: params.x, y: params.y })
? buildMarkdownMenuTemplate(
webContents,
{ x: params.x, y: params.y },
options?.tableTarget ?? null
)
: buildNativeEditMenuTemplate(webContents))
)
return template
}
export function parseRichMarkdownContextMenuTableTarget(
value: unknown
): RichMarkdownContextMenuTableTarget | null {
if (!value || typeof value !== 'object') {
return null
}
const target = value as Partial<RichMarkdownContextMenuTableTarget>
if (
(target.cellType !== 'body' && target.cellType !== 'header') ||
typeof target.targetId !== 'string' ||
target.targetId.length === 0 ||
typeof target.x !== 'number' ||
!Number.isFinite(target.x) ||
typeof target.y !== 'number' ||
!Number.isFinite(target.y)
) {
return null
}
return {
cellType: target.cellType,
targetId: target.targetId,
x: target.x,
y: target.y
}
}
export function matchingRichMarkdownContextMenuTableTarget(
params: Electron.ContextMenuParams,
target: RichMarkdownContextMenuTableTarget | null
): RichMarkdownContextMenuTableTarget | null {
if (
!target ||
!params.isEditable ||
params.formControlType !== 'none' ||
params.x !== target.x ||
params.y !== target.y
) {
return null
}
return target
}

View File

@ -330,7 +330,10 @@ import type {
UpdatePullRequestBySlugArgs,
UpdateProjectItemFieldArgs
} from '../shared/github-project-types'
import type { RichMarkdownContextMenuCommandPayload } from '../shared/rich-markdown-context-menu'
import type {
RichMarkdownContextMenuCommandPayload,
RichMarkdownContextMenuTableTarget
} from '../shared/rich-markdown-context-menu'
import type {
BrowserSetGrabModeArgs,
BrowserSetGrabModeResult,
@ -3301,6 +3304,7 @@ export type PreloadApi = {
setZoomLevel: (level: number) => void
syncTrafficLights: (zoomFactor: number) => void
setMarkdownEditorFocused: (focused: boolean) => void
setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null) => void
setTerminalInputFocused: (focused: boolean) => void
setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }) => void
setShortcutRecorderFocused: (focused: boolean) => void

View File

@ -185,7 +185,9 @@ import type {
} from '../shared/github-project-types'
import {
richMarkdownContextMenuCommandChannel,
type RichMarkdownContextMenuCommandPayload
richMarkdownContextMenuTargetChannel,
type RichMarkdownContextMenuCommandPayload,
type RichMarkdownContextMenuTableTarget
} from '../shared/rich-markdown-context-menu'
import type {
AgentStatusClearIpcPayload,
@ -4029,6 +4031,9 @@ const api = {
setMarkdownEditorFocused: (focused: boolean): void => {
ipcRenderer.send('ui:setMarkdownEditorFocused', focused)
},
setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => {
ipcRenderer.send(richMarkdownContextMenuTargetChannel, target)
},
setTerminalInputFocused: (focused: boolean): void => {
ipcRenderer.send('ui:setTerminalInputFocused', focused)
},

View File

@ -807,6 +807,7 @@
margin: 1em 0;
border-collapse: collapse;
font-size: 0.95em;
table-layout: auto;
}
.rich-markdown-editor th,
@ -815,6 +816,7 @@
border: 1px solid var(--border);
text-align: left;
vertical-align: top;
overflow-wrap: break-word;
}
.rich-markdown-editor th {
@ -830,6 +832,109 @@
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
}
.rich-markdown-table-controls {
position: absolute;
inset: 0;
/* Why: the slash (30), emoji (31) and doc-link (30) menus share this stacking
context, so hover affordances must stay under them above editor content
chrome such as the code-block language selector (1). */
z-index: 10;
pointer-events: none;
}
.rich-markdown-table-control {
position: absolute;
z-index: 1;
border-radius: 0;
color: var(--muted-foreground);
pointer-events: auto;
backdrop-filter: blur(8px);
}
.rich-markdown-table-control::before {
position: absolute;
inset: -8px;
content: '';
}
.rich-markdown-table-axis-control {
border: 0;
background: transparent;
box-shadow: none;
opacity: 1;
}
.rich-markdown-table-axis-control::after {
position: absolute;
content: '';
background: color-mix(in srgb, var(--foreground) 18%, transparent);
transition: background-color 120ms ease;
}
.rich-markdown-table-axis-control[data-axis='column'] {
height: 14px;
}
.rich-markdown-table-axis-control[data-axis='column']::after {
right: 4px;
left: 4px;
height: 2px;
}
.rich-markdown-table-axis-control[data-axis='row'] {
width: 14px;
}
.rich-markdown-table-axis-control[data-axis='row']::after {
top: 4px;
bottom: 4px;
width: 2px;
}
.rich-markdown-table-axis-control svg {
opacity: 0;
transition: opacity 120ms ease;
}
.rich-markdown-table-axis-control:hover,
.rich-markdown-table-axis-control:focus-visible,
.rich-markdown-table-axis-control[data-state='open'] {
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
background: color-mix(in srgb, var(--background) 88%, transparent);
color: var(--foreground);
opacity: 1;
}
.rich-markdown-table-axis-control:hover::after,
.rich-markdown-table-axis-control:focus-visible::after,
.rich-markdown-table-axis-control[data-state='open']::after {
background: transparent;
}
.rich-markdown-table-axis-control:hover svg,
.rich-markdown-table-axis-control:focus-visible svg,
.rich-markdown-table-axis-control[data-state='open'] svg {
opacity: 1;
}
.rich-markdown-table-add-control {
border-color: color-mix(in srgb, var(--border) 76%, transparent);
background: transparent;
color: var(--muted-foreground);
opacity: 0.76;
}
.rich-markdown-table-add-control:hover,
.rich-markdown-table-add-control:focus-visible {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
color: var(--foreground);
opacity: 1;
}
.rich-markdown-editor .rich-markdown-table-control-active {
background: color-mix(in srgb, var(--primary) 12%, transparent) !important;
}
.rich-markdown-editor code {
padding: 0.2em 0.4em;
border-radius: 5px;

View File

@ -12,6 +12,7 @@ import {
type RichMarkdownEditorCodec
} from '@/components/editor/rich-markdown-source-transport'
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
import { RichMarkdownTableControls } from '@/components/editor/RichMarkdownTableControls'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@ -54,6 +55,7 @@ export function LinearIssueMarkdownDescriptionEditor({
const { i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? i18n.language
const lastEditorMarkdownRef = useRef(value)
const editorScrollRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<Editor | null>(null)
const richMarkdownSpellcheckEnabled = useAppStore(
(s) => s.settings?.richMarkdownSpellcheckEnabled ?? true
@ -152,8 +154,13 @@ export function LinearIssueMarkdownDescriptionEditor({
)}
>
<LinearIssueMarkdownToolbar editor={editor} disabled={disabled} />
<div className="linear-issue-markdown-scroll scrollbar-sleek">
<div ref={editorScrollRef} className="linear-issue-markdown-scroll relative scrollbar-sleek">
<EditorContent editor={editor} />
<RichMarkdownTableControls
disabled={disabled}
editor={editor}
scrollContainerRef={editorScrollRef}
/>
</div>
<div className="linear-issue-markdown-save-hint pointer-events-none absolute bottom-1.5 right-2 z-10 flex items-center gap-1.5 text-[10px] text-muted-foreground/75">
<span className="flex items-center gap-1">

View File

@ -21,6 +21,7 @@ import { useRichMarkdownReviewController } from './useRichMarkdownReviewControll
import { useRichMarkdownReviewEditorEffects } from './useRichMarkdownReviewEditorEffects'
import {
isRichMarkdownContextCommandTarget,
isRichMarkdownTableContextCommand,
runRichMarkdownContextCommand
} from './rich-markdown-context-command-routing'
import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
@ -323,12 +324,16 @@ export default function RichMarkdownEditor({
useEffect(() => {
return window.api.ui.onRichMarkdownContextCommand((payload) => {
const ed = editorRef.current
if (!ed || !isRichMarkdownContextCommandTarget(payload, rootRef.current)) {
if (
!ed ||
isRichMarkdownTableContextCommand(payload.command) ||
!isRichMarkdownContextCommandTarget(payload, rootRef.current)
) {
return
}
runRichMarkdownContextCommand({
command: payload.command,
payload,
editor: ed,
toggleLink: toggleLinkFromToolbar,
pickImage: handleLocalImagePick

View File

@ -11,6 +11,7 @@ import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
import { RichMarkdownAnnotationOverlay } from './RichMarkdownAnnotationOverlay'
import { RichMarkdownReviewNoteLayer } from './RichMarkdownReviewNoteLayer'
import { RichMarkdownReviewRailActions } from './RichMarkdownReviewRailActions'
import { RichMarkdownTableControls } from './RichMarkdownTableControls'
import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands'
import type { MarkdownTocItem } from './markdown-table-of-contents'
@ -217,6 +218,7 @@ export function RichMarkdownEditorSurface({
}}
>
<EditorContent editor={editor} />
<RichMarkdownTableControls editor={editor} scrollContainerRef={scrollContainerRef} />
{reviewRailVisible && notePositions.length > 0 ? (
<RichMarkdownReviewNoteLayer
positions={notePositions}

View File

@ -0,0 +1,269 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { RichMarkdownTableControls } from './RichMarkdownTableControls'
import { TooltipProvider } from '@/components/ui/tooltip'
const TABLE = `| A | B |
| --- | --- |
| a1 | b1 |
`
afterEach(cleanup)
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: {
onRichMarkdownContextCommand: vi.fn(() => vi.fn()),
setRichMarkdownContextMenuTarget: vi.fn()
}
}
})
})
describe('RichMarkdownTableControls', () => {
it('does not mutate header cells before ProseMirror handles pointer input', async () => {
const scrollContainer = document.createElement('div')
const editorElement = document.createElement('div')
const controlsElement = document.createElement('div')
scrollContainer.append(editorElement, controlsElement)
document.body.append(scrollContainer)
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
const view = render(
<TooltipProvider>
<RichMarkdownTableControls
editor={editor}
scrollContainerRef={{ current: scrollContainer }}
/>
</TooltipProvider>,
{ container: controlsElement }
)
try {
const header = editorElement.querySelector('th')
if (!header) {
throw new Error('Expected a table header cell')
}
let headerPosition = 0
editor.state.doc.descendants((node, position) => {
if (node.type.spec.tableRole === 'header_cell') {
headerPosition = position
return false
}
return true
})
fireEvent.pointerDown(header, { clientX: 20, clientY: 20 })
expect(editor.state.doc.nodeAt(headerPosition)?.attrs.colwidth).toBeNull()
} finally {
view.unmount()
editor.destroy()
scrollContainer.remove()
}
})
it('shows reachable, labeled controls only for an editable active table', async () => {
const scrollContainer = document.createElement('div')
const editorElement = document.createElement('div')
const controlsElement = document.createElement('div')
scrollContainer.append(editorElement, controlsElement)
document.body.append(scrollContainer)
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
let bodyCellPosition = 0
editor.state.doc.descendants((node, position) => {
if (node.isText && node.text === 'a1') {
bodyCellPosition = position
return false
}
return true
})
editor.commands.setTextSelection(bodyCellPosition)
const view = render(
<TooltipProvider>
<RichMarkdownTableControls
editor={editor}
scrollContainerRef={{ current: scrollContainer }}
/>
</TooltipProvider>,
{ container: controlsElement }
)
try {
const cell = editorElement.querySelector('td')
if (!cell) {
throw new Error('Expected a table body cell')
}
const table = cell.closest('table')
if (!table) {
throw new Error('Expected the table body cell to have a table parent')
}
vi.spyOn(cell, 'getBoundingClientRect').mockReturnValue({
bottom: 50,
height: 50,
left: 0,
right: 50,
toJSON: () => ({}),
top: 0,
width: 50,
x: 0,
y: 0
})
vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({
bottom: 100,
height: 100,
left: 0,
right: 100,
toJSON: () => ({}),
top: 0,
width: 100,
x: 0,
y: 0
})
fireEvent.pointerMove(cell, { clientX: 20, clientY: 0 })
await waitFor(() => expect(view.getByLabelText('Column actions')).toBeTruthy())
expect(view.queryByLabelText('Row actions')).toBeNull()
expect(view.queryByLabelText('Add row')).toBeNull()
expect(view.queryByLabelText('Add column')).toBeNull()
fireEvent.pointerMove(cell, { clientX: 0, clientY: 20 })
await waitFor(() => expect(view.getByLabelText('Row actions')).toBeTruthy())
const rowActions = view.getByLabelText('Row actions')
rowActions.focus()
expect(document.activeElement).toBe(rowActions)
fireEvent.pointerMove(cell, { clientX: 20, clientY: 100 })
await waitFor(() => expect(view.getByLabelText('Add row')).toBeTruthy())
expect(view.queryByLabelText('Add column')).toBeNull()
fireEvent.pointerMove(cell, { clientX: 100, clientY: 20 })
await waitFor(() => expect(view.getByLabelText('Add column')).toBeTruthy())
expect(view.queryByLabelText('Add row')).toBeNull()
view.rerender(
<TooltipProvider>
<RichMarkdownTableControls
disabled
editor={editor}
scrollContainerRef={{ current: scrollContainer }}
/>
</TooltipProvider>
)
expect(view.queryByLabelText('Row actions')).toBeNull()
} finally {
view.unmount()
editor.destroy()
scrollContainer.remove()
}
})
it('keeps an add control reachable after scrolling without another pointer move', async () => {
const scrollContainer = document.createElement('div')
const editorElement = document.createElement('div')
const controlsElement = document.createElement('div')
scrollContainer.append(editorElement, controlsElement)
document.body.append(scrollContainer)
Object.defineProperties(scrollContainer, {
clientHeight: { configurable: true, value: 100 },
clientWidth: { configurable: true, value: 100 }
})
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({
bottom: 100,
height: 100,
left: 0,
right: 100,
top: 0,
width: 100,
x: 0,
y: 0,
toJSON: () => ({})
})
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
let bodyTextPosition = 0
editor.state.doc.descendants((node, position) => {
if (node.isText && node.text === 'a1') {
bodyTextPosition = position
return false
}
return true
})
editor.commands.setTextSelection(bodyTextPosition)
const cell = editorElement.querySelector('td')!
const row = cell.parentElement!
const table = cell.closest('table')!
vi.spyOn(cell, 'getBoundingClientRect').mockImplementation(() => ({
bottom: 100,
height: 50,
left: -scrollContainer.scrollLeft,
right: 50 - scrollContainer.scrollLeft,
top: 50,
width: 50,
x: -scrollContainer.scrollLeft,
y: 50,
toJSON: () => ({})
}))
vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => ({
bottom: 100,
height: 50,
left: -scrollContainer.scrollLeft,
right: 150 - scrollContainer.scrollLeft,
top: 50,
width: 150,
x: -scrollContainer.scrollLeft,
y: 50,
toJSON: () => ({})
}))
vi.spyOn(table, 'getBoundingClientRect').mockImplementation(() => ({
bottom: 100,
height: 100,
left: -scrollContainer.scrollLeft,
right: 150 - scrollContainer.scrollLeft,
top: 0,
width: 150,
x: -scrollContainer.scrollLeft,
y: 0,
toJSON: () => ({})
}))
const view = render(
<TooltipProvider>
<RichMarkdownTableControls
editor={editor}
scrollContainerRef={{ current: scrollContainer }}
/>
</TooltipProvider>,
{ container: controlsElement }
)
try {
fireEvent.pointerMove(cell, { clientX: 20, clientY: 95 })
await waitFor(() => expect(view.getByLabelText('Add row').style.width).toBe('92px'))
scrollContainer.scrollLeft = 100
fireEvent.scroll(scrollContainer)
await waitFor(() => expect(view.getByLabelText('Add row').style.width).toBe('46px'))
} finally {
view.unmount()
editor.destroy()
scrollContainer.remove()
}
})
})

View File

@ -0,0 +1,326 @@
import React, { useEffect, useState } from 'react'
import type { Editor } from '@tiptap/react'
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Columns3,
GripHorizontal,
GripVertical,
Plus,
Rows3,
Trash2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { getRichMarkdownTableControlLayout } from './rich-markdown-table-control-layout'
import { useRichMarkdownTableContextMenu } from './use-rich-markdown-table-context-menu'
import {
useRichMarkdownTableControlTarget,
type TableAxis
} from './use-rich-markdown-table-control-target'
import {
richMarkdownTableCellPositionAtElement,
runRichMarkdownTableAction,
type RichMarkdownTableAction
} from './rich-markdown-table-actions'
function contentRect(element: Element, container: HTMLElement) {
const elementRect = element.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
return {
bottom: elementRect.bottom - containerRect.top + container.scrollTop,
left: elementRect.left - containerRect.left + container.scrollLeft,
right: elementRect.right - containerRect.left + container.scrollLeft,
top: elementRect.top - containerRect.top + container.scrollTop
}
}
function TableControlButton({
axis,
className,
icon,
label,
onClick,
style
}: {
axis: 'column' | 'row'
className: string
icon: React.ReactNode
label: string
onClick: () => void
style: React.CSSProperties
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
className={`rich-markdown-table-control ${className}`}
style={style}
aria-label={label}
onClick={onClick}
data-axis={axis}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
function TableActionMenu({
axis,
cellPosition,
editor,
isHeader,
onOpenChange,
style
}: {
axis: 'column' | 'row'
cellPosition: number
editor: Editor
isHeader: boolean
onOpenChange: (open: boolean) => void
style: React.CSSProperties
}): React.JSX.Element {
const isRow = axis === 'row'
const label = isRow
? translate('auto.components.editor.RichMarkdownTableControls.rowActions', 'Row actions')
: translate('auto.components.editor.RichMarkdownTableControls.columnActions', 'Column actions')
const beforeLabel = isRow
? translate(
'auto.components.editor.RichMarkdownTableControls.insertRowAbove',
'Insert row above'
)
: translate(
'auto.components.editor.RichMarkdownTableControls.insertColumnLeft',
'Insert column left'
)
const afterLabel = isRow
? translate(
'auto.components.editor.RichMarkdownTableControls.insertRowBelow',
'Insert row below'
)
: translate(
'auto.components.editor.RichMarkdownTableControls.insertColumnRight',
'Insert column right'
)
const deleteLabel = isRow
? translate('auto.components.editor.RichMarkdownTableControls.deleteRow', 'Delete row')
: translate('auto.components.editor.RichMarkdownTableControls.deleteColumn', 'Delete column')
const run = (action: RichMarkdownTableAction): void => {
runRichMarkdownTableAction(editor, action, { cellPosition })
}
return (
<DropdownMenu onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
className="rich-markdown-table-axis-control rich-markdown-table-control"
style={style}
aria-label={label}
data-axis={axis}
>
{isRow ? <GripVertical /> : <GripHorizontal />}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side={isRow ? 'right' : 'bottom'}>
<DropdownMenuItem
disabled={isRow && isHeader}
onSelect={() => run(isRow ? 'insert-row-above' : 'insert-column-left')}
>
{isRow ? <ArrowUp /> : <ArrowLeft />}
{beforeLabel}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => run(isRow ? 'insert-row-below' : 'insert-column-right')}>
{isRow ? <ArrowDown /> : <ArrowRight />}
{afterLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={isRow && isHeader}
onSelect={() => run(isRow ? 'delete-row' : 'delete-column')}
>
{isRow ? <Rows3 /> : <Columns3 />}
{deleteLabel}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={() => run('delete-table')}>
<Trash2 />
{translate(
'auto.components.editor.RichMarkdownTableControls.deleteTable',
'Delete table'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function RichMarkdownTableControls({
disabled = false,
editor,
scrollContainerRef
}: {
disabled?: boolean
editor: Editor | null
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}): React.JSX.Element | null {
const { active, hoveredAddAxis, hoveredAxis } = useRichMarkdownTableControlTarget(
editor,
scrollContainerRef
)
const [openAxis, setOpenAxis] = useState<TableAxis | null>(null)
useRichMarkdownTableContextMenu(editor)
useEffect(() => {
if (!active || !openAxis || !(active.cell.parentElement instanceof HTMLTableRowElement)) {
return
}
const cells =
openAxis === 'row'
? Array.from(active.cell.parentElement.cells)
: Array.from(active.table.rows, (tableRow) =>
tableRow.cells.item(active.cell.cellIndex)
).filter((cell): cell is HTMLTableCellElement => cell !== null)
cells.forEach((cell) => cell.classList.add('rich-markdown-table-control-active'))
return () =>
cells.forEach((cell) => cell.classList.remove('rich-markdown-table-control-active'))
}, [active, openAxis])
const scrollContainer = scrollContainerRef.current
if (
disabled ||
!editor ||
!editor.isEditable ||
!scrollContainer ||
!active?.cell.isConnected ||
!active.table.isConnected
) {
return null
}
const row = active.cell.parentElement
const finalRow = active.table.rows.item(active.table.rows.length - 1)
const firstRow = active.table.rows.item(0)
const addRowCell = finalRow?.cells.item(0) ?? null
const addColumnCell = firstRow?.cells.item(firstRow.cells.length - 1) ?? null
const cellPosition = richMarkdownTableCellPositionAtElement(editor, active.cell)
const addRowPosition = addRowCell
? richMarkdownTableCellPositionAtElement(editor, addRowCell)
: null
const addColumnPosition = addColumnCell
? richMarkdownTableCellPositionAtElement(editor, addColumnCell)
: null
if (!(row instanceof HTMLTableRowElement) || cellPosition === null) {
return null
}
const tableRect = contentRect(active.table, scrollContainer)
const layout = getRichMarkdownTableControlLayout({
cell: contentRect(active.cell, scrollContainer),
container: scrollContainer,
row: contentRect(row, scrollContainer),
table: tableRect
})
const style = (point: { left: number; top: number }): React.CSSProperties => ({
left: point.left,
top: point.top
})
const viewportRight = scrollContainer.scrollLeft + scrollContainer.clientWidth - 4
const viewportBottom = scrollContainer.scrollTop + scrollContainer.clientHeight - 4
const visibleTableWidth = Math.max(
0,
Math.min(tableRect.right, viewportRight) -
Math.max(tableRect.left, scrollContainer.scrollLeft + 4)
)
const visibleTableHeight = Math.max(
0,
Math.min(tableRect.bottom, viewportBottom) -
Math.max(tableRect.top, scrollContainer.scrollTop + 4)
)
return (
<div
className="rich-markdown-table-controls"
role="group"
aria-label={translate(
'auto.components.editor.RichMarkdownTableControls.tableActions',
'Table actions'
)}
>
{hoveredAxis === 'row' ? (
<TableActionMenu
axis="row"
cellPosition={cellPosition}
editor={editor}
isHeader={active.cell.tagName === 'TH'}
onOpenChange={(open) => setOpenAxis(open ? 'row' : null)}
style={style(layout.rowMenu)}
/>
) : null}
{hoveredAxis === 'column' ? (
<TableActionMenu
axis="column"
cellPosition={cellPosition}
editor={editor}
isHeader={false}
onOpenChange={(open) => setOpenAxis(open ? 'column' : null)}
style={style(layout.columnMenu)}
/>
) : null}
{hoveredAddAxis === 'column' && addColumnPosition !== null ? (
<TableControlButton
axis="column"
className="rich-markdown-table-add-control"
icon={<Plus />}
label={translate(
'auto.components.editor.RichMarkdownTableControls.addColumn',
'Add column'
)}
style={{ ...style(layout.addColumn), height: visibleTableHeight }}
onClick={() =>
runRichMarkdownTableAction(editor, 'insert-column-right', {
cellPosition: addColumnPosition
})
}
/>
) : null}
{hoveredAddAxis === 'row' && addRowPosition !== null ? (
<TableControlButton
axis="row"
className="rich-markdown-table-add-control"
icon={<Plus />}
label={translate('auto.components.editor.RichMarkdownTableControls.addRow', 'Add row')}
style={{ ...style(layout.addRow), width: visibleTableWidth }}
onClick={() =>
runRichMarkdownTableAction(editor, 'insert-row-below', {
cellPosition: addRowPosition
})
}
/>
) : null}
</div>
)
}

View File

@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import {
isRichMarkdownTableContextCommand,
runRichMarkdownContextCommand
} from './rich-markdown-context-command-routing'
const TABLE = `| A | B |
| --- | --- |
| a1 | b1 |
| a2 | b2 |
`
function textPosition(editor: Editor, text: string): number {
let position: number | null = null
editor.state.doc.descendants((node, nodePosition) => {
if (node.isText && node.text === text) {
position = nodePosition
return false
}
return true
})
if (position === null) {
throw new Error(`Missing text: ${text}`)
}
return position
}
describe('rich markdown context command routing', () => {
it('separates table commands for the shared rich-editor table owner', () => {
expect(isRichMarkdownTableContextCommand('delete-row')).toBe(true)
expect(isRichMarkdownTableContextCommand('bold')).toBe(false)
})
it('runs table actions against the clicked cell coordinates', () => {
const editor = new Editor({
element: document.createElement('div'),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
try {
editor.commands.setTextSelection(textPosition(editor, 'a1'))
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({
inside: -1,
pos: textPosition(editor, 'b2')
})
runRichMarkdownContextCommand({
payload: { command: 'delete-row', x: 120, y: 240 },
editor,
toggleLink: vi.fn(),
pickImage: vi.fn()
})
expect(editor.getMarkdown()).toContain('a1')
expect(editor.getMarkdown()).not.toContain('a2')
} finally {
editor.destroy()
}
})
})

View File

@ -1,20 +1,53 @@
import type { Editor } from '@tiptap/react'
import type {
RichMarkdownContextMenuCommand,
RichMarkdownContextMenuCommandPayload
} from '../../../../shared/rich-markdown-context-menu'
import { TextSelection } from '@tiptap/pm/state'
import type { RichMarkdownContextMenuCommandPayload } from '../../../../shared/rich-markdown-context-menu'
import {
runRichMarkdownTableAction,
type RichMarkdownTableAction
} from './rich-markdown-table-actions'
export function isRichMarkdownTableContextCommand(
command: RichMarkdownContextMenuCommandPayload['command']
): command is RichMarkdownTableAction {
return (
command === 'insert-row-above' ||
command === 'insert-row-below' ||
command === 'delete-row' ||
command === 'insert-column-left' ||
command === 'insert-column-right' ||
command === 'delete-column' ||
command === 'delete-table'
)
}
export function runRichMarkdownContextCommand({
command,
payload,
editor,
toggleLink,
pickImage
}: {
command: RichMarkdownContextMenuCommand
payload: RichMarkdownContextMenuCommandPayload
editor: Editor
toggleLink: () => void
pickImage: () => void
}): void {
const { command } = payload
if (!command.startsWith('insert-') && !command.startsWith('delete-')) {
try {
const clickPosition = editor.view.posAtCoords({ left: payload.x, top: payload.y })?.pos
const selection = editor.state.selection
if (
clickPosition !== undefined &&
(selection.empty || clickPosition < selection.from || clickPosition > selection.to)
) {
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.near(editor.state.doc.resolve(clickPosition)))
)
}
} catch {
return
}
}
switch (command) {
case 'add-link':
toggleLink()
@ -69,6 +102,15 @@ export function runRichMarkdownContextCommand({
return
case 'divider':
editor.chain().focus().setHorizontalRule().run()
return
case 'insert-row-above':
case 'insert-row-below':
case 'delete-row':
case 'insert-column-left':
case 'insert-column-right':
case 'delete-column':
case 'delete-table':
runRichMarkdownTableAction(editor, command, { clientX: payload.x, clientY: payload.y })
}
}

View File

@ -0,0 +1,344 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import {
runRichMarkdownTableAction,
type RichMarkdownTableAction
} from './rich-markdown-table-actions'
const TABLE = `| A | B |
| --- | --- |
| a1 | b1 |
| a2 | b2 |
`
function createEditor(): Editor {
return new Editor({
element: document.createElement('div'),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
}
function caretAtText(editor: Editor, text: string): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (!node.isText || node.text !== text) {
return true
}
position = pos
return false
})
if (position === null) {
throw new Error(`Expected cell text: ${text}`)
}
return position
}
function cellAtText(editor: Editor, text: string): number {
const $text = editor.state.doc.resolve(caretAtText(editor, text))
for (let depth = $text.depth; depth > 0; depth -= 1) {
if ($text.node(depth).type.spec.tableRole) {
return $text.before(depth)
}
}
throw new Error(`Expected table cell: ${text}`)
}
function tableDimensions(editor: Editor): { rows: number; columns: number } {
let dimensions = { rows: 0, columns: 0 }
editor.state.doc.descendants((node) => {
if (node.type.spec.tableRole === 'table') {
const tableMap = TableMap.get(node)
dimensions = { rows: tableMap.height, columns: tableMap.width }
return false
}
return true
})
return dimensions
}
function setFirstRowColumnWidths(editor: Editor, widths: number[]): void {
const table = editor.state.doc.firstChild
const row = table?.firstChild
if (!table || table.type.name !== 'table' || !row) {
throw new Error('Expected a table with a first row')
}
const transaction = editor.state.tr
row.forEach((cell, offset, index) => {
transaction.setNodeMarkup(2 + offset, undefined, {
...cell.attrs,
colwidth: [widths[index]]
})
})
editor.view.dispatch(transaction)
}
function firstRowColumnWidths(editor: Editor): number[] {
const table = editor.state.doc.firstChild
const row = table?.firstChild
if (!row) {
throw new Error('Expected a table with a first row')
}
const widths: number[] = []
row.forEach((cell) => widths.push(cell.attrs.colwidth?.[0] ?? 0))
return widths
}
function runAction(action: RichMarkdownTableAction, cellText: string): Editor {
const editor = createEditor()
editor.commands.setTextSelection(caretAtText(editor, cellText))
expect(runRichMarkdownTableAction(editor, action)).toBe(true)
return editor
}
describe('rich markdown table actions', () => {
it.each([
['insert-row-above', { rows: 4, columns: 2 }],
['insert-row-below', { rows: 4, columns: 2 }],
['insert-column-left', { rows: 3, columns: 3 }],
['insert-column-right', { rows: 3, columns: 3 }]
] as const)('runs %s from the current cell', (action, expectedDimensions) => {
const editor = runAction(action, 'a1')
try {
expect(tableDimensions(editor)).toEqual(expectedDimensions)
expect(editor.getMarkdown()).toContain('| ---')
} finally {
editor.destroy()
}
})
it('deletes the current body row', () => {
const editor = runAction('delete-row', 'a1')
try {
expect(tableDimensions(editor)).toEqual({ rows: 2, columns: 2 })
expect(editor.getMarkdown()).not.toContain('a1')
expect(editor.getMarkdown()).toContain('a2')
} finally {
editor.destroy()
}
})
it('deletes the current column', () => {
const editor = runAction('delete-column', 'b1')
try {
expect(tableDimensions(editor)).toEqual({ rows: 3, columns: 1 })
expect(editor.getMarkdown()).not.toContain('B')
expect(editor.getMarkdown()).not.toContain('b1')
expect(editor.getMarkdown()).toContain('a1')
} finally {
editor.destroy()
}
})
it('gives a new column an equal share of the locked table width', () => {
const editor = createEditor()
try {
setFirstRowColumnWidths(editor, [200, 100])
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(runRichMarkdownTableAction(editor, 'insert-column-right')).toBe(true)
expect(firstRowColumnWidths(editor)).toEqual([133, 100, 67])
} finally {
editor.destroy()
}
})
it('reverts a rebalanced column insertion in a single undo', () => {
const editor = createEditor()
try {
setFirstRowColumnWidths(editor, [200, 100])
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(runRichMarkdownTableAction(editor, 'insert-column-right')).toBe(true)
expect(tableDimensions(editor).columns).toBe(3)
editor.commands.undo()
expect(tableDimensions(editor).columns).toBe(2)
} finally {
editor.destroy()
}
})
it('does not throw when a cached cell position outlives the document', () => {
const editor = createEditor()
try {
const cellPosition = cellAtText(editor, 'a2')
editor.commands.setContent('Paragraph', { contentType: 'markdown' })
expect(runRichMarkdownTableAction(editor, 'delete-row', { cellPosition })).toBe(false)
expect(editor.getMarkdown()).toBe('Paragraph')
} finally {
editor.destroy()
}
})
it('keeps a two-column table when one column is deleted', () => {
const editor = createEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'b1'))
expect(runRichMarkdownTableAction(editor, 'delete-column')).toBe(true)
expect(tableDimensions(editor)).toEqual({ rows: 3, columns: 1 })
} finally {
editor.destroy()
}
})
it('keeps a merged-cell table when one logical column is deleted', () => {
const editor = createEditor()
try {
const firstHeader = cellAtText(editor, 'A')
const lastHeader = cellAtText(editor, 'B')
editor.view.dispatch(
editor.state.tr.setSelection(
CellSelection.create(editor.state.doc, firstHeader, lastHeader)
)
)
expect(editor.chain().mergeCells().run()).toBe(true)
const table = editor.view.dom.querySelector('table')
expect(table?.rows.item(0)?.cells).toHaveLength(1)
editor.commands.setTextSelection(caretAtText(editor, 'b1'))
expect(runRichMarkdownTableAction(editor, 'delete-column')).toBe(true)
expect(editor.isActive('table')).toBe(true)
expect(tableDimensions(editor)).toEqual({ rows: 3, columns: 1 })
expect(editor.state.doc.textContent).toContain('a1')
} finally {
editor.destroy()
}
})
it('keeps a multi-row single-column table when one row is deleted', () => {
const editor = createEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'b1'))
expect(runRichMarkdownTableAction(editor, 'delete-column')).toBe(true)
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(runRichMarkdownTableAction(editor, 'delete-row')).toBe(true)
expect(tableDimensions(editor)).toEqual({ rows: 2, columns: 1 })
expect(editor.isActive('table')).toBe(true)
} finally {
editor.destroy()
}
})
it('targets the clicked cell instead of a stale caret', () => {
const editor = createEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(
runRichMarkdownTableAction(editor, 'delete-row', {
cellPosition: cellAtText(editor, 'b2')
})
).toBe(true)
expect(editor.getMarkdown()).toContain('a1')
expect(editor.getMarkdown()).not.toContain('a2')
} finally {
editor.destroy()
}
})
it('does not mutate stale selection when coordinate targeting fails', () => {
const editor = createEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
const before = editor.getMarkdown()
const originalPosAtCoords = editor.view.posAtCoords
editor.view.posAtCoords = () => {
throw new Error('view unavailable')
}
expect(runRichMarkdownTableAction(editor, 'delete-row', { clientX: 10, clientY: 20 })).toBe(
false
)
expect(editor.getMarkdown()).toBe(before)
editor.view.posAtCoords = originalPosAtCoords
} finally {
editor.destroy()
}
})
it('preserves a multi-cell selection when the clicked cell belongs to it', () => {
const editor = createEditor()
try {
const first = cellAtText(editor, 'a1')
const last = cellAtText(editor, 'b2')
editor.view.dispatch(
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, first, last))
)
expect(runRichMarkdownTableAction(editor, 'delete-row', { cellPosition: last })).toBe(true)
expect(tableDimensions(editor)).toEqual({ rows: 1, columns: 2 })
expect(editor.getMarkdown()).not.toContain('a1')
expect(editor.getMarkdown()).not.toContain('a2')
} finally {
editor.destroy()
}
})
it('protects the Markdown header boundary', () => {
const editor = createEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'A'))
expect(runRichMarkdownTableAction(editor, 'insert-row-above')).toBe(false)
expect(runRichMarkdownTableAction(editor, 'delete-row')).toBe(false)
expect(tableDimensions(editor)).toEqual({ rows: 3, columns: 2 })
} finally {
editor.destroy()
}
})
it('deletes the table when its final column is removed', () => {
const editor = runAction('delete-column', 'b1')
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(runRichMarkdownTableAction(editor, 'delete-column')).toBe(true)
expect(editor.getMarkdown()).not.toContain('|')
} finally {
editor.destroy()
}
})
it('deletes a one-row headerless table instead of leaving an invalid shell', () => {
const editor = createEditor()
try {
editor.commands.setContent('Before', { contentType: 'markdown' })
editor.commands.setTextSelection(1)
editor.commands.insertTable({ rows: 1, cols: 2, withHeaderRow: false })
expect(runRichMarkdownTableAction(editor, 'delete-row')).toBe(true)
expect(editor.isActive('table')).toBe(false)
} finally {
editor.destroy()
}
})
it('serializes and reopens after structural edits', () => {
const editor = runAction('insert-column-right', 'b1')
const markdown = editor.getMarkdown()
editor.destroy()
const reopened = new Editor({
element: document.createElement('div'),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: markdown,
contentType: 'markdown'
})
try {
expect(tableDimensions(reopened)).toEqual({ rows: 3, columns: 3 })
expect(markdown).toMatch(/\|\s*-{3,}/)
} finally {
reopened.destroy()
}
})
it('does nothing outside a table', () => {
const editor = createEditor()
try {
editor.commands.setContent('Paragraph', { contentType: 'markdown' })
expect(runRichMarkdownTableAction(editor, 'insert-row-below')).toBe(false)
expect(editor.getMarkdown()).toBe('Paragraph')
} finally {
editor.destroy()
}
})
})

View File

@ -0,0 +1,305 @@
import type { Editor } from '@tiptap/react'
import { TextSelection, type Transaction } from '@tiptap/pm/state'
import { CellSelection, selectionCell, TableMap } from '@tiptap/pm/tables'
export type RichMarkdownTableAction =
| 'insert-row-above'
| 'insert-row-below'
| 'delete-row'
| 'insert-column-left'
| 'insert-column-right'
| 'delete-column'
| 'delete-table'
export type RichMarkdownTableActionTarget =
| { cellPosition: number }
| { clientX: number; clientY: number }
type TableContext = {
columnCount: number
hasHeaderRow: boolean
rowCount: number
selectedCellPositions: Set<number>
tablePosition: number
}
function cellPositionAtDocumentPosition(editor: Editor, position: number): number | null {
// Why: callers dispatch cached positions, so the doc may have shrunk since
// capture and resolve() throws past the end.
if (position < 0 || position > editor.state.doc.content.size) {
return null
}
const $position = editor.state.doc.resolve(position)
for (let depth = $position.depth; depth > 0; depth -= 1) {
const role = $position.node(depth).type.spec.tableRole
if (role === 'cell' || role === 'header_cell') {
return $position.before(depth)
}
}
return null
}
export function richMarkdownTableCellPositionAtElement(
editor: Editor,
cell: HTMLTableCellElement
): number | null {
try {
return cellPositionAtDocumentPosition(editor, editor.view.posAtDOM(cell, 0))
} catch {
return null
}
}
function cellPositionAtTarget(
editor: Editor,
target: RichMarkdownTableActionTarget
): number | null {
if ('cellPosition' in target) {
return cellPositionAtDocumentPosition(editor, target.cellPosition + 1)
}
try {
const position = editor.view.posAtCoords({ left: target.clientX, top: target.clientY })?.pos
return position === undefined ? null : cellPositionAtDocumentPosition(editor, position)
} catch {
return null
}
}
function normalizeMultiCellSelection(editor: Editor): CellSelection | null {
const { selection } = editor.state
if (selection instanceof CellSelection) {
return selection
}
if (selection.empty) {
return null
}
const anchor = cellPositionAtDocumentPosition(editor, selection.from)
const head = cellPositionAtDocumentPosition(editor, selection.to)
if (anchor === null || head === null || anchor === head) {
return null
}
try {
return CellSelection.create(editor.state.doc, anchor, head)
} catch {
return null
}
}
function retargetTableSelection(
editor: Editor,
target: RichMarkdownTableActionTarget | undefined
): number | null {
const existingMultiCellSelection = normalizeMultiCellSelection(editor)
if (existingMultiCellSelection && !(editor.state.selection instanceof CellSelection)) {
editor.view.dispatch(editor.state.tr.setSelection(existingMultiCellSelection))
}
if (!target) {
return editor.isActive('table') ? selectionCell(editor.state).pos : null
}
const cellPosition = cellPositionAtTarget(editor, target)
if (cellPosition === null) {
return null
}
const selection = editor.state.selection
let selectedTarget = false
if (selection instanceof CellSelection) {
selection.forEachCell((_cell, position) => {
selectedTarget ||= position === cellPosition
})
}
if (!selectedTarget) {
const caret = TextSelection.near(editor.state.doc.resolve(cellPosition + 1))
editor.view.dispatch(editor.state.tr.setSelection(caret))
}
return cellPosition
}
function tableContext(editor: Editor, targetCellPosition: number): TableContext | null {
const $cell = editor.state.doc.resolve(targetCellPosition)
if (
$cell.nodeAfter?.type.spec.tableRole !== 'cell' &&
$cell.nodeAfter?.type.spec.tableRole !== 'header_cell'
) {
return null
}
let table: ReturnType<typeof $cell.node> | null = null
let tablePosition = 0
for (let depth = $cell.depth; depth > 0; depth -= 1) {
const node = $cell.node(depth)
if (node.type.spec.tableRole === 'table') {
table = node
tablePosition = $cell.before(depth)
break
}
}
if (!table) {
return null
}
const tableMap = TableMap.get(table)
const selectedCellPositions = new Set<number>()
const selection = editor.state.selection
if (selection instanceof CellSelection) {
selection.forEachCell((_cell, position) => selectedCellPositions.add(position))
} else {
selectedCellPositions.add(targetCellPosition)
}
return {
columnCount: tableMap.width,
hasHeaderRow: table.firstChild?.firstChild?.type.spec.tableRole === 'header_cell',
rowCount: tableMap.height,
selectedCellPositions,
tablePosition
}
}
function columnIndexAtCellPosition(editor: Editor, cellPosition: number): number | null {
const $cell = editor.state.doc.resolve(cellPosition)
const role = $cell.nodeAfter?.type.spec.tableRole
return role === 'cell' || role === 'header_cell' ? $cell.index($cell.depth) : null
}
function selectedTableCoverage(
editor: Editor,
context: TableContext
): {
columns: Set<number>
rows: Set<number>
includesHeader: boolean
} {
const table = editor.state.doc.nodeAt(context.tablePosition)
const columns = new Set<number>()
const rows = new Set<number>()
let includesHeader = false
if (!table) {
return { columns, rows, includesHeader }
}
const tableMap = TableMap.get(table)
const tableStart = context.tablePosition + 1
tableMap.map.forEach((cellOffset, index) => {
const position = tableStart + cellOffset
if (!context.selectedCellPositions.has(position)) {
return
}
rows.add(Math.floor(index / tableMap.width))
columns.add(index % tableMap.width)
includesHeader ||= editor.state.doc.nodeAt(position)?.type.spec.tableRole === 'header_cell'
})
return { columns, rows, includesHeader }
}
// Why: mutates the caller's transaction so the rebalance lands in the same
// undo step as the insertion it follows.
function rebalanceAddedColumn(
transaction: Transaction,
tablePosition: number,
insertedColumnIndex: number
): void {
const table = transaction.doc.nodeAt(tablePosition)
const row = table?.firstChild
if (!table || !row) {
return
}
const cells: { width: number | null }[] = []
let hasSpans = false
row.forEach((cell, _offset, index) => {
if (cell.attrs.colspan && cell.attrs.colspan !== 1) {
hasSpans = true
return
}
const width = index === insertedColumnIndex ? null : cell.attrs.colwidth?.[0]
cells.push({ width: typeof width === 'number' ? width : null })
})
table.forEach((tableRow) => {
tableRow.forEach((cell) => {
hasSpans ||= Boolean(cell.attrs.colspan && cell.attrs.colspan !== 1)
})
})
const newColumns = cells.filter((cell) => cell.width === null)
const existingWidth = cells.reduce((total, cell) => total + (cell.width ?? 0), 0)
if (hasSpans || cells.length === 0 || newColumns.length === 0 || existingWidth <= 0) {
return
}
const newWidth = existingWidth / cells.length
const existingScale = (existingWidth - newWidth * newColumns.length) / existingWidth
const widths = cells.map((cell) => {
const width = cell.width === null ? newWidth : cell.width * existingScale
return Math.max(1, Math.round(width))
})
table.forEach((tableRow, rowOffset) => {
tableRow.forEach((cell, cellOffset, columnIndex) => {
transaction.setNodeMarkup(tablePosition + 2 + rowOffset + cellOffset, undefined, {
...cell.attrs,
colwidth: [widths[columnIndex]]
})
})
})
}
export function runRichMarkdownTableAction(
editor: Editor,
action: RichMarkdownTableAction,
target?: RichMarkdownTableActionTarget
): boolean {
if (!editor.isEditable) {
return false
}
const targetCellPosition = retargetTableSelection(editor, target)
if (targetCellPosition === null) {
return false
}
const context = tableContext(editor, targetCellPosition)
if (!context) {
return false
}
const targetColumnIndex = columnIndexAtCellPosition(editor, targetCellPosition)
const coverage = selectedTableCoverage(editor, context)
const chain = editor.chain().focus()
switch (action) {
case 'insert-row-above':
if (context.hasHeaderRow && coverage.includesHeader) {
return false
}
return chain.addRowBefore().run()
case 'insert-row-below':
return chain.addRowAfter().run()
case 'delete-row':
if (coverage.rows.size >= context.rowCount) {
return chain.deleteTable().run()
}
if (context.hasHeaderRow && coverage.includesHeader) {
return false
}
return chain.deleteRow().run()
case 'insert-column-left':
if (targetColumnIndex === null) {
return false
}
return chain
.addColumnBefore()
.command(({ tr }) => {
rebalanceAddedColumn(tr, context.tablePosition, targetColumnIndex)
return true
})
.run()
case 'insert-column-right':
if (targetColumnIndex === null) {
return false
}
return chain
.addColumnAfter()
.command(({ tr }) => {
rebalanceAddedColumn(tr, context.tablePosition, targetColumnIndex + 1)
return true
})
.run()
case 'delete-column':
if (coverage.columns.size >= context.columnCount) {
return chain.deleteTable().run()
}
return chain.deleteColumn().run()
case 'delete-table':
return chain.deleteTable().run()
}
}

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { getRichMarkdownTableControlLayout } from './rich-markdown-table-control-layout'
describe('rich markdown table control layout', () => {
it('places controls around the hovered row, column, and table edges', () => {
expect(
getRichMarkdownTableControlLayout({
cell: { left: 150, right: 250, top: 100, bottom: 140 },
row: { left: 50, right: 350, top: 100, bottom: 140 },
table: { left: 50, right: 350, top: 60, bottom: 220 },
container: { clientHeight: 400, clientWidth: 500, scrollLeft: 0, scrollTop: 0 }
})
).toEqual({
rowMenu: { left: 32, top: 108 },
columnMenu: { left: 188, top: 42 },
addColumn: { left: 354, top: 60 },
addRow: { left: 50, top: 224 }
})
})
it('keeps every control reachable in a narrow, scrolled editor', () => {
const layout = getRichMarkdownTableControlLayout({
cell: { left: 20, right: 180, top: 60, bottom: 100 },
row: { left: 20, right: 620, top: 60, bottom: 100 },
table: { left: 20, right: 620, top: 20, bottom: 800 },
container: { clientHeight: 180, clientWidth: 120, scrollLeft: 90, scrollTop: 300 }
})
for (const point of Object.values(layout)) {
expect(point.left).toBeGreaterThanOrEqual(94)
expect(point.left).toBeLessThanOrEqual(182)
expect(point.top).toBeGreaterThanOrEqual(304)
expect(point.top).toBeLessThanOrEqual(452)
}
})
})

View File

@ -0,0 +1,72 @@
export type RichMarkdownTableControlPoint = { left: number; top: number }
type ContentRect = {
bottom: number
left: number
right: number
top: number
}
type TableControlLayoutInput = {
cell: ContentRect
container: {
clientHeight: number
clientWidth: number
scrollLeft: number
scrollTop: number
}
row: ContentRect
table: ContentRect
}
export type RichMarkdownTableControlLayout = {
addColumn: RichMarkdownTableControlPoint
addRow: RichMarkdownTableControlPoint
columnMenu: RichMarkdownTableControlPoint
rowMenu: RichMarkdownTableControlPoint
}
const CONTROL_SIZE = 24
const AXIS_CONTROL_THICKNESS = 14
const EDGE_GAP = 4
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum))
}
function center(start: number, end: number): number {
return (start + end - CONTROL_SIZE) / 2
}
export function getRichMarkdownTableControlLayout({
cell,
container,
row,
table
}: TableControlLayoutInput): RichMarkdownTableControlLayout {
const minimumLeft = container.scrollLeft + EDGE_GAP
const maximumLeft = container.scrollLeft + container.clientWidth - CONTROL_SIZE - EDGE_GAP
const minimumTop = container.scrollTop + EDGE_GAP
const maximumTop = container.scrollTop + container.clientHeight - CONTROL_SIZE - EDGE_GAP
const visibleLeft = (value: number): number => clamp(value, minimumLeft, maximumLeft)
const visibleTop = (value: number): number => clamp(value, minimumTop, maximumTop)
return {
rowMenu: {
left: visibleLeft(table.left - AXIS_CONTROL_THICKNESS - EDGE_GAP),
top: visibleTop(center(row.top, row.bottom))
},
columnMenu: {
left: visibleLeft(center(cell.left, cell.right)),
top: visibleTop(table.top - AXIS_CONTROL_THICKNESS - EDGE_GAP)
},
addColumn: {
left: visibleLeft(table.right + EDGE_GAP),
top: visibleTop(table.top)
},
addRow: {
left: visibleLeft(table.left),
top: visibleTop(table.bottom + EDGE_GAP)
}
}
}

View File

@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import type { RichMarkdownContextMenuCommandPayload } from '../../../../shared/rich-markdown-context-menu'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { useRichMarkdownTableContextMenu } from './use-rich-markdown-table-context-menu'
const TABLE = `| A | B |
| --- | --- |
| a1 | b1 |
| a2 | b2 |
`
function TableContextMenuHarness({ editor }: { editor: Editor }): null {
useRichMarkdownTableContextMenu(editor)
return null
}
afterEach(cleanup)
describe('rich markdown table context menu', () => {
it('reports and routes the exact table target without coordinate hit testing later', () => {
const editorElement = document.createElement('div')
document.body.append(editorElement)
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
const setRichMarkdownContextMenuTarget = vi.fn()
const commandListeners: ((payload: RichMarkdownContextMenuCommandPayload) => void)[] = []
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: {
onRichMarkdownContextCommand: vi.fn((callback) => {
commandListeners.push(callback)
return vi.fn()
}),
setRichMarkdownContextMenuTarget
}
}
})
const view = render(<TableContextMenuHarness editor={editor} />)
try {
const bodyCells = editorElement.querySelectorAll('td')
const targetCell = bodyCells.item(3)
fireEvent.pointerDown(targetCell, { button: 2, clientX: 12, clientY: 34 })
expect(setRichMarkdownContextMenuTarget).toHaveBeenLastCalledWith(
expect.objectContaining({ cellType: 'body', x: 12, y: 34 })
)
fireEvent.contextMenu(targetCell, { clientX: 12, clientY: 34 })
const target = setRichMarkdownContextMenuTarget.mock.lastCall?.[0]
expect(target).toMatchObject({ cellType: 'body', x: 12, y: 34 })
editor.commands.setTextSelection(1)
expect(commandListeners).toHaveLength(1)
commandListeners[0]({
command: 'delete-row',
tableTargetId: target.targetId,
x: 12,
y: 34
})
expect(editor.state.doc.textContent).toContain('a1')
expect(editor.state.doc.textContent).not.toContain('a2')
} finally {
view.unmount()
editor.destroy()
editorElement.remove()
}
})
it('reports header targets so native row actions can be disabled', () => {
const editorElement = document.createElement('div')
document.body.append(editorElement)
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
const setRichMarkdownContextMenuTarget = vi.fn()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: {
onRichMarkdownContextCommand: vi.fn(() => vi.fn()),
setRichMarkdownContextMenuTarget
}
}
})
const view = render(<TableContextMenuHarness editor={editor} />)
try {
fireEvent.contextMenu(editorElement.querySelector('th')!, { clientX: 8, clientY: 9 })
expect(setRichMarkdownContextMenuTarget).toHaveBeenLastCalledWith(
expect.objectContaining({ cellType: 'header', x: 8, y: 9 })
)
} finally {
view.unmount()
editor.destroy()
editorElement.remove()
}
})
})

View File

@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from 'react'
import type { Editor } from '@tiptap/react'
import type { RichMarkdownContextMenuTableTarget } from '../../../../shared/rich-markdown-context-menu'
import { isRichMarkdownTableContextCommand } from './rich-markdown-context-command-routing'
import {
richMarkdownTableCellPositionAtElement,
runRichMarkdownTableAction
} from './rich-markdown-table-actions'
type CapturedTableTarget = RichMarkdownContextMenuTableTarget & { cellPosition: number }
let nextTableContextTargetId = 0
function createTableContextTargetId(): string {
nextTableContextTargetId += 1
return `rich-markdown-table-${nextTableContextTargetId}`
}
export function useRichMarkdownTableContextMenu(editor: Editor | null): void {
const [targetId] = useState(createTableContextTargetId)
const capturedTargetRef = useRef<CapturedTableTarget | null>(null)
useEffect(() => {
if (!editor) {
return
}
const editorDom = editor.view.dom
const captureTarget = (event: MouseEvent): void => {
const cell =
event.target instanceof Element
? event.target.closest<HTMLTableCellElement>('td, th')
: null
const cellPosition =
cell && editorDom.contains(cell)
? richMarkdownTableCellPositionAtElement(editor, cell)
: null
if (!cell || cellPosition === null) {
capturedTargetRef.current = null
window.api.ui.setRichMarkdownContextMenuTarget(null)
return
}
const tableTarget: RichMarkdownContextMenuTableTarget = {
cellType: cell.tagName === 'TH' ? 'header' : 'body',
targetId,
x: event.clientX,
y: event.clientY
}
capturedTargetRef.current = { ...tableTarget, cellPosition }
window.api.ui.setRichMarkdownContextMenuTarget(tableTarget)
}
const capturePointerTarget = (event: PointerEvent): void => {
if (event.button === 2) {
captureTarget(event)
}
}
const unsubscribe = window.api.ui.onRichMarkdownContextCommand((payload) => {
if (!isRichMarkdownTableContextCommand(payload.command)) {
return
}
const target = capturedTargetRef.current
capturedTargetRef.current = null
if (
!target ||
payload.tableTargetId !== targetId ||
payload.x !== target.x ||
payload.y !== target.y
) {
return
}
runRichMarkdownTableAction(editor, payload.command, { cellPosition: target.cellPosition })
})
editorDom.addEventListener('pointerdown', capturePointerTarget)
editorDom.addEventListener('contextmenu', captureTarget)
return () => {
editorDom.removeEventListener('pointerdown', capturePointerTarget)
editorDom.removeEventListener('contextmenu', captureTarget)
unsubscribe()
}
}, [editor, targetId])
}

View File

@ -0,0 +1,191 @@
// @vitest-environment happy-dom
import React, { useRef } from 'react'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { useRichMarkdownTableControlTarget } from './use-rich-markdown-table-control-target'
const TABLE = `| A | B |
| --- | --- |
| a1 | b1 |
`
let nextFrameId = 0
let frameCallbacks = new Map<number, FrameRequestCallback>()
function flushFrames(): void {
const callbacks = [...frameCallbacks.values()]
frameCallbacks.clear()
callbacks.forEach((callback) => callback(performance.now()))
}
function TargetHarness({
editor,
scrollContainer
}: {
editor: Editor
scrollContainer: HTMLDivElement
}): React.JSX.Element {
const renders = useRef(0)
const scrollContainerRef = useRef(scrollContainer)
renders.current += 1
const target = useRichMarkdownTableControlTarget(editor, scrollContainerRef)
return (
<div
data-active={String(target.active !== null)}
data-add-axis={target.hoveredAddAxis ?? ''}
data-axis={target.hoveredAxis ?? ''}
data-renders={renders.current}
/>
)
}
beforeEach(() => {
nextFrameId = 0
frameCallbacks = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
nextFrameId += 1
frameCallbacks.set(nextFrameId, callback)
return nextFrameId
})
vi.stubGlobal('cancelAnimationFrame', (frameId: number) => {
frameCallbacks.delete(frameId)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('rich markdown table control target', () => {
it('coalesces same-cell pointer geometry and preserves observer identity on updates', async () => {
const scrollContainer = document.createElement('div')
const editorElement = document.createElement('div')
scrollContainer.append(editorElement)
document.body.append(scrollContainer)
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
const cell = editorElement.querySelector('td')!
const table = editorElement.querySelector('table')!
let bodyTextPosition = 0
editor.state.doc.descendants((node, position) => {
if (node.isText && node.text === 'a1') {
bodyTextPosition = position
return false
}
return true
})
editor.commands.setTextSelection(bodyTextPosition)
const cellRect = vi.spyOn(cell, 'getBoundingClientRect').mockReturnValue({
bottom: 50,
height: 50,
left: 0,
right: 50,
top: 0,
width: 50,
x: 0,
y: 0,
toJSON: () => ({})
})
const tableRect = vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({
bottom: 100,
height: 100,
left: 0,
right: 100,
top: 0,
width: 100,
x: 0,
y: 0,
toJSON: () => ({})
})
const observerDisconnect = vi.fn()
const observerConstruct = vi.fn()
vi.stubGlobal(
'ResizeObserver',
class {
constructor() {
observerConstruct()
}
disconnect = observerDisconnect
observe = vi.fn()
}
)
const view = render(<TargetHarness editor={editor} scrollContainer={scrollContainer} />)
try {
await waitFor(() =>
expect(view.container.firstElementChild?.getAttribute('data-active')).toBe('true')
)
expect(observerConstruct).toHaveBeenCalledTimes(1)
const stableRenderCount = view.container.firstElementChild?.getAttribute('data-renders')
editor.commands.insertContent('x')
await act(async () => {})
expect(view.container.firstElementChild?.getAttribute('data-renders')).toBe(stableRenderCount)
expect(observerConstruct).toHaveBeenCalledTimes(1)
expect(observerDisconnect).not.toHaveBeenCalled()
fireEvent.pointerMove(cell, { clientX: 2, clientY: 2 })
fireEvent.pointerMove(cell, { clientX: 3, clientY: 3 })
fireEvent.pointerMove(cell, { clientX: 4, clientY: 4 })
expect(frameCallbacks.size).toBe(1)
expect(cellRect).not.toHaveBeenCalled()
expect(tableRect).not.toHaveBeenCalled()
act(flushFrames)
expect(cellRect).toHaveBeenCalledTimes(1)
expect(tableRect).toHaveBeenCalledTimes(1)
expect(observerConstruct).toHaveBeenCalledTimes(1)
const afterPointerRenderCount = view.container.firstElementChild?.getAttribute('data-renders')
expect(Number(afterPointerRenderCount)).toBeGreaterThan(Number(stableRenderCount))
} finally {
view.unmount()
editor.destroy()
scrollContainer.remove()
}
})
it('refreshes layout state when the editor scrolls without pointer movement', async () => {
const scrollContainer = document.createElement('div')
const editorElement = document.createElement('div')
scrollContainer.append(editorElement)
document.body.append(scrollContainer)
const editor = new Editor({
element: editorElement,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE,
contentType: 'markdown'
})
vi.stubGlobal(
'ResizeObserver',
class {
disconnect = vi.fn()
observe = vi.fn()
}
)
const view = render(<TargetHarness editor={editor} scrollContainer={scrollContainer} />)
try {
await waitFor(() =>
expect(view.container.firstElementChild?.getAttribute('data-active')).toBe('true')
)
const renderCount = Number(view.container.firstElementChild?.getAttribute('data-renders'))
fireEvent.scroll(scrollContainer)
expect(frameCallbacks.size).toBe(1)
act(flushFrames)
expect(
Number(view.container.firstElementChild?.getAttribute('data-renders'))
).toBeGreaterThan(renderCount)
} finally {
view.unmount()
editor.destroy()
scrollContainer.remove()
}
})
})

View File

@ -0,0 +1,159 @@
import { useEffect, useRef, useState } from 'react'
import type { RefObject } from 'react'
import type { Editor } from '@tiptap/react'
import { isInTable, selectionCell } from '@tiptap/pm/tables'
export type ActiveTableCell = { cell: HTMLTableCellElement; table: HTMLTableElement }
export type TableAxis = 'column' | 'row'
type PointerSample = { cell: HTMLTableCellElement; x: number; y: number }
const TABLE_EDGE_HIT_AREA = 8
function tableCellFromTarget(target: EventTarget | null): HTMLTableCellElement | null {
return target instanceof Element ? target.closest<HTMLTableCellElement>('td, th') : null
}
function selectionTableCell(editor: Editor): HTMLTableCellElement | null {
// Why: isActive('table') still admits a node selection on the table or a
// selection merely spanning it, and selectionCell throws for both.
if (!isInTable(editor.state)) {
return null
}
const node = editor.view.nodeDOM(selectionCell(editor.state).pos)
return node instanceof HTMLTableCellElement ? node : null
}
function pointerAxes(sample: PointerSample): { add: TableAxis | null; edge: TableAxis | null } {
const cellRect = sample.cell.getBoundingClientRect()
const table = sample.cell.closest('table')
if (!(table instanceof HTMLTableElement)) {
return { add: null, edge: null }
}
const tableRect = table.getBoundingClientRect()
const edge =
sample.y - cellRect.top <= TABLE_EDGE_HIT_AREA
? 'column'
: sample.x - cellRect.left <= TABLE_EDGE_HIT_AREA
? 'row'
: null
const add =
sample.y >= tableRect.bottom - TABLE_EDGE_HIT_AREA
? 'row'
: sample.x >= tableRect.right - TABLE_EDGE_HIT_AREA
? 'column'
: null
return { add, edge }
}
export function useRichMarkdownTableControlTarget(
editor: Editor | null,
scrollContainerRef: RefObject<HTMLDivElement | null>
): {
active: ActiveTableCell | null
hoveredAddAxis: TableAxis | null
hoveredAxis: TableAxis | null
} {
const [active, setActive] = useState<ActiveTableCell | null>(null)
const [hoveredAddAxis, setHoveredAddAxis] = useState<TableAxis | null>(null)
const [hoveredAxis, setHoveredAxis] = useState<TableAxis | null>(null)
const [, setLayoutVersion] = useState(0)
const pendingPointerRef = useRef<PointerSample | null>(null)
const pointerFrameRef = useRef<number | null>(null)
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!editor || !scrollContainer) {
return
}
const editorDom = editor.view.dom
const activate = (cell: HTMLTableCellElement | null): void => {
setActive((current) => {
if (current?.cell === cell) {
return current
}
const table = cell?.closest('table')
return cell && table instanceof HTMLTableElement ? { cell, table } : null
})
}
const activateSelection = (): void => {
pendingPointerRef.current = null
setHoveredAddAxis(null)
setHoveredAxis(null)
activate(selectionTableCell(editor))
}
const flushPointer = (): void => {
pointerFrameRef.current = null
const sample = pendingPointerRef.current
pendingPointerRef.current = null
if (!sample?.cell.isConnected || !editorDom.contains(sample.cell)) {
return
}
const axes = pointerAxes(sample)
setHoveredAddAxis(axes.add)
setHoveredAxis(axes.edge)
}
const onPointerMove = (event: PointerEvent): void => {
const cell = tableCellFromTarget(event.target)
if (cell && editorDom.contains(cell)) {
activate(cell)
pendingPointerRef.current = { cell, x: event.clientX, y: event.clientY }
pointerFrameRef.current ??= window.requestAnimationFrame(flushPointer)
return
}
if (
!(event.target instanceof Element) ||
!event.target.closest('.rich-markdown-table-controls')
) {
pendingPointerRef.current = null
activateSelection()
}
}
scrollContainer.addEventListener('pointermove', onPointerMove)
editor.on('selectionUpdate', activateSelection)
editor.on('update', activateSelection)
activateSelection()
return () => {
scrollContainer.removeEventListener('pointermove', onPointerMove)
editor.off('selectionUpdate', activateSelection)
editor.off('update', activateSelection)
if (pointerFrameRef.current !== null) {
window.cancelAnimationFrame(pointerFrameRef.current)
// Why: onPointerMove schedules with ??=, so a stale id blocks every
// later frame once the effect re-runs on a new editor instance.
pointerFrameRef.current = null
}
pendingPointerRef.current = null
}
}, [editor, scrollContainerRef])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
const table = active?.table
if (!table || !scrollContainer) {
return
}
let layoutFrame: number | null = null
const update = (): void => {
layoutFrame ??= window.requestAnimationFrame(() => {
layoutFrame = null
setLayoutVersion((version) => version + 1)
})
}
const observer = new ResizeObserver(update)
observer.observe(table)
observer.observe(scrollContainer)
scrollContainer.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update)
return () => {
observer.disconnect()
scrollContainer.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
if (layoutFrame !== null) {
window.cancelAnimationFrame(layoutFrame)
}
}
}, [active?.table, scrollContainerRef])
return { active, hoveredAddAxis, hoveredAxis }
}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import Placeholder from '@tiptap/extension-placeholder'
import { ImageIcon, Paperclip } from 'lucide-react'
@ -20,6 +20,7 @@ import { createEditableMarkdownLinkBubble } from '@/components/editor/rich-markd
import { copyRichMarkdownLink } from '@/components/editor/rich-markdown-link-clipboard'
import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize'
import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane'
import { GitHubMarkdownComposerEditorPane } from '@/components/github/GitHubMarkdownComposerEditorPane'
import {
GitHubMarkdownComposerTabbar,
type ComposerTab
@ -337,13 +338,7 @@ export function GitHubMarkdownComposer({
</Button>
</form>
) : null
const editorPane = (
<div className="max-h-[360px] overflow-y-auto scrollbar-sleek">
<EditorContent editor={editor} />
</div>
)
const editorPane = <GitHubMarkdownComposerEditorPane disabled={disabled} editor={editor} />
const previewPane = (
<GitHubMarkdownComposerPreviewPane
value={value}
@ -351,7 +346,6 @@ export function GitHubMarkdownComposer({
previewGithubRepo={previewGithubRepo}
/>
)
const attachmentFooter = isTabbed ? (
<button
type="button"
@ -368,7 +362,6 @@ export function GitHubMarkdownComposer({
</span>
</button>
) : null
return (
<div
ref={rootRef}

View File

@ -0,0 +1,25 @@
import React, { useRef } from 'react'
import type { Editor } from '@tiptap/react'
import { EditorContent } from '@tiptap/react'
import { RichMarkdownTableControls } from '@/components/editor/RichMarkdownTableControls'
export function GitHubMarkdownComposerEditorPane({
disabled,
editor
}: {
disabled: boolean
editor: Editor | null
}): React.JSX.Element {
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
return (
<div ref={scrollContainerRef} className="relative max-h-[360px] overflow-y-auto scrollbar-sleek">
<EditorContent editor={editor} />
<RichMarkdownTableControls
disabled={disabled}
editor={editor}
scrollContainerRef={scrollContainerRef}
/>
</div>
)
}

View File

@ -13474,6 +13474,20 @@
}
}
}
},
"RichMarkdownTableControls": {
"deleteTable": "Delete table",
"tableActions": "Table actions",
"addColumn": "Add column",
"addRow": "Add row",
"rowActions": "Row actions",
"columnActions": "Column actions",
"insertRowAbove": "Insert row above",
"insertColumnLeft": "Insert column left",
"insertRowBelow": "Insert row below",
"insertColumnRight": "Insert column right",
"deleteRow": "Delete row",
"deleteColumn": "Delete column"
}
},
"diff": {
@ -14715,6 +14729,20 @@
"connect": "Connect",
"connecting": "Connecting…"
}
},
"main": {
"window": {
"editableContextMenu": {
"table": "Table",
"insertRowAbove": "Insert row above",
"insertRowBelow": "Insert row below",
"deleteRow": "Delete row",
"insertColumnLeft": "Insert column left",
"insertColumnRight": "Insert column right",
"deleteColumn": "Delete column",
"deleteTable": "Delete table"
}
}
}
},
"components": {

View File

@ -12983,6 +12983,20 @@
"6bbf827ef5": "Título 5",
"d1bbf9a835": "Sección plegable"
},
"RichMarkdownTableControls": {
"deleteTable": "Eliminar tabla",
"tableActions": "Acciones de tabla",
"addColumn": "Agregar columna",
"addRow": "Agregar fila",
"rowActions": "Acciones de fila",
"columnActions": "Acciones de columna",
"insertRowAbove": "Insertar fila arriba",
"insertColumnLeft": "Insertar columna a la izquierda",
"insertRowBelow": "Insertar fila abajo",
"insertColumnRight": "Insertar columna a la derecha",
"deleteRow": "Eliminar fila",
"deleteColumn": "Eliminar columna"
},
"UntitledFileRenameDialog": {
"a7dd27b0bc": "Guardar",
"949711deb4": "Cancelar",
@ -14387,6 +14401,20 @@
"reconnect": "Reconectar",
"retry": "Reintentar"
}
},
"main": {
"window": {
"editableContextMenu": {
"table": "Tabla",
"insertRowAbove": "Insertar fila arriba",
"insertRowBelow": "Insertar fila abajo",
"deleteRow": "Eliminar fila",
"insertColumnLeft": "Insertar columna a la izquierda",
"insertColumnRight": "Insertar columna a la derecha",
"deleteColumn": "Eliminar columna",
"deleteTable": "Eliminar tabla"
}
}
}
},
"components": {

View File

@ -12983,6 +12983,20 @@
"6bbf827ef5": "見出し 5",
"d1bbf9a835": "折りたたみ可能なセクション"
},
"RichMarkdownTableControls": {
"deleteTable": "テーブルを削除",
"tableActions": "テーブル操作",
"addColumn": "列を追加",
"addRow": "行を追加",
"rowActions": "行の操作",
"columnActions": "列の操作",
"insertRowAbove": "上に行を挿入",
"insertColumnLeft": "左に列を挿入",
"insertRowBelow": "下に行を挿入",
"insertColumnRight": "右に列を挿入",
"deleteRow": "行を削除",
"deleteColumn": "列を削除"
},
"UntitledFileRenameDialog": {
"a7dd27b0bc": "保存",
"949711deb4": "キャンセル",
@ -14387,6 +14401,20 @@
"reconnect": "再接続",
"retry": "再試行"
}
},
"main": {
"window": {
"editableContextMenu": {
"table": "テーブル",
"insertRowAbove": "上に行を挿入",
"insertRowBelow": "下に行を挿入",
"deleteRow": "行を削除",
"insertColumnLeft": "左に列を挿入",
"insertColumnRight": "右に列を挿入",
"deleteColumn": "列を削除",
"deleteTable": "テーブルを削除"
}
}
}
},
"components": {

View File

@ -12983,6 +12983,20 @@
"6bbf827ef5": "제목 5",
"d1bbf9a835": "접을 수 있는 섹션"
},
"RichMarkdownTableControls": {
"deleteTable": "표 삭제",
"tableActions": "표 작업",
"addColumn": "열 추가",
"addRow": "행 추가",
"rowActions": "행 작업",
"columnActions": "열 작업",
"insertRowAbove": "위에 행 삽입",
"insertColumnLeft": "왼쪽에 열 삽입",
"insertRowBelow": "아래에 행 삽입",
"insertColumnRight": "오른쪽에 열 삽입",
"deleteRow": "행 삭제",
"deleteColumn": "열 삭제"
},
"UntitledFileRenameDialog": {
"a7dd27b0bc": "저장",
"949711deb4": "취소",
@ -14398,6 +14412,20 @@
"reconnect": "다시 연결",
"retry": "재시도"
}
},
"main": {
"window": {
"editableContextMenu": {
"table": "표",
"insertRowAbove": "위에 행 삽입",
"insertRowBelow": "아래에 행 삽입",
"deleteRow": "행 삭제",
"insertColumnLeft": "왼쪽에 열 삽입",
"insertColumnRight": "오른쪽에 열 삽입",
"deleteColumn": "열 삭제",
"deleteTable": "표 삭제"
}
}
}
},
"components": {

View File

@ -13003,6 +13003,20 @@
"6bbf827ef5": "标题 5",
"d1bbf9a835": "可折叠小节"
},
"RichMarkdownTableControls": {
"deleteTable": "删除表格",
"tableActions": "表格操作",
"addColumn": "添加列",
"addRow": "添加行",
"rowActions": "行操作",
"columnActions": "列操作",
"insertRowAbove": "在上方插入行",
"insertColumnLeft": "在左侧插入列",
"insertRowBelow": "在下方插入行",
"insertColumnRight": "在右侧插入列",
"deleteRow": "删除行",
"deleteColumn": "删除列"
},
"UntitledFileRenameDialog": {
"a7dd27b0bc": "保存",
"949711deb4": "取消",
@ -14407,6 +14421,20 @@
"reconnect": "重新连接",
"retry": "重试"
}
},
"main": {
"window": {
"editableContextMenu": {
"table": "表格",
"insertRowAbove": "在上方插入行",
"insertRowBelow": "在下方插入行",
"deleteRow": "删除行",
"insertColumnLeft": "在左侧插入列",
"insertColumnRight": "在右侧插入列",
"deleteColumn": "删除列",
"deleteTable": "删除表格"
}
}
}
},
"components": {

View File

@ -2750,6 +2750,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onFileDrop: () => noopUnsubscribe,
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},
setRichMarkdownContextMenuTarget: () => {},
setTerminalInputFocused: () => {},
setFloatingFocus: () => {},
setShortcutRecorderFocused: () => {},

View File

@ -17,11 +17,27 @@ export type RichMarkdownContextMenuCommand =
| 'task-list'
| 'image'
| 'divider'
| 'insert-row-above'
| 'insert-row-below'
| 'delete-row'
| 'insert-column-left'
| 'insert-column-right'
| 'delete-column'
| 'delete-table'
export type RichMarkdownContextMenuCommandPayload = {
command: RichMarkdownContextMenuCommand
tableTargetId?: string
x: number
y: number
}
export type RichMarkdownContextMenuTableTarget = {
cellType: 'body' | 'header'
targetId: string
x: number
y: number
}
export const richMarkdownContextMenuCommandChannel = 'rich-markdown:context-command'
export const richMarkdownContextMenuTargetChannel = 'rich-markdown:context-target'