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:
parent
7aba9b306b
commit
6942871194
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -2750,6 +2750,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onFileDrop: () => noopUnsubscribe,
|
||||
syncTrafficLights: () => {},
|
||||
setMarkdownEditorFocused: () => {},
|
||||
setRichMarkdownContextMenuTarget: () => {},
|
||||
setTerminalInputFocused: () => {},
|
||||
setFloatingFocus: () => {},
|
||||
setShortcutRecorderFocused: () => {},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue