diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 0764fa3ad..bc595f10b 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 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 }) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 3dc853e64..327772164 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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) diff --git a/src/main/window/editable-context-menu.test.ts b/src/main/window/editable-context-menu.test.ts index 23252f22c..b057803d7 100644 --- a/src/main/window/editable-context-menu.test.ts +++ b/src/main/window/editable-context-menu.test.ts @@ -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() + }) +}) diff --git a/src/main/window/editable-context-menu.ts b/src/main/window/editable-context-menu.ts index da9dded64..c4e9b8cc5 100644 --- a/src/main/window/editable-context-menu.ts +++ b/src/main/window/editable-context-menu.ts @@ -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 + 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 +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5208f3ffa..16e68ee5a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index f1614048b..e612e441d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) }, diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 66dddbabe..ce59662d7 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -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; diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index f8f11c956..3eb171409 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -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(null) const editorRef = useRef(null) const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true @@ -152,8 +154,13 @@ export function LinearIssueMarkdownDescriptionEditor({ )} > -
+
+
diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 877f5109a..56eb1e9d7 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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 diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index fd619247c..d3a7b423d 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -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({ }} > + {reviewRailVisible && notePositions.length > 0 ? ( { + 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( + + + , + { 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( + + + , + { 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( + + + + ) + 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( + + + , + { 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() + } + }) +}) diff --git a/src/renderer/src/components/editor/RichMarkdownTableControls.tsx b/src/renderer/src/components/editor/RichMarkdownTableControls.tsx new file mode 100644 index 000000000..4df5f5e1b --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownTableControls.tsx @@ -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 ( + + + + + + {label} + + + ) +} + +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 ( + + + + + + + + + {label} + + + + run(isRow ? 'insert-row-above' : 'insert-column-left')} + > + {isRow ? : } + {beforeLabel} + + run(isRow ? 'insert-row-below' : 'insert-column-right')}> + {isRow ? : } + {afterLabel} + + + run(isRow ? 'delete-row' : 'delete-column')} + > + {isRow ? : } + {deleteLabel} + + run('delete-table')}> + + {translate( + 'auto.components.editor.RichMarkdownTableControls.deleteTable', + 'Delete table' + )} + + + + ) +} + +export function RichMarkdownTableControls({ + disabled = false, + editor, + scrollContainerRef +}: { + disabled?: boolean + editor: Editor | null + scrollContainerRef: React.RefObject +}): React.JSX.Element | null { + const { active, hoveredAddAxis, hoveredAxis } = useRichMarkdownTableControlTarget( + editor, + scrollContainerRef + ) + const [openAxis, setOpenAxis] = useState(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 ( +
+ {hoveredAxis === 'row' ? ( + setOpenAxis(open ? 'row' : null)} + style={style(layout.rowMenu)} + /> + ) : null} + {hoveredAxis === 'column' ? ( + setOpenAxis(open ? 'column' : null)} + style={style(layout.columnMenu)} + /> + ) : null} + {hoveredAddAxis === 'column' && addColumnPosition !== null ? ( + } + 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 ? ( + } + label={translate('auto.components.editor.RichMarkdownTableControls.addRow', 'Add row')} + style={{ ...style(layout.addRow), width: visibleTableWidth }} + onClick={() => + runRichMarkdownTableAction(editor, 'insert-row-below', { + cellPosition: addRowPosition + }) + } + /> + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts b/src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts new file mode 100644 index 000000000..9a90b557d --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts @@ -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() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts b/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts index dd3d8b233..08c3e95f2 100644 --- a/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts +++ b/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts @@ -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 }) } } diff --git a/src/renderer/src/components/editor/rich-markdown-table-actions.test.ts b/src/renderer/src/components/editor/rich-markdown-table-actions.test.ts new file mode 100644 index 000000000..b0e3dfb58 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-actions.test.ts @@ -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() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-table-actions.ts b/src/renderer/src/components/editor/rich-markdown-table-actions.ts new file mode 100644 index 000000000..b4500dfc3 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-actions.ts @@ -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 + 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 | 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() + 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 + rows: Set + includesHeader: boolean +} { + const table = editor.state.doc.nodeAt(context.tablePosition) + const columns = new Set() + const rows = new Set() + 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() + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts b/src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts new file mode 100644 index 000000000..c1ed371de --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts @@ -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) + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-table-control-layout.ts b/src/renderer/src/components/editor/rich-markdown-table-control-layout.ts new file mode 100644 index 000000000..32b4c5b31 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-control-layout.ts @@ -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) + } + } +} diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx b/src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx new file mode 100644 index 000000000..91bb49567 --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx @@ -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() + 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() + 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() + } + }) +}) diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-context-menu.ts b/src/renderer/src/components/editor/use-rich-markdown-table-context-menu.ts new file mode 100644 index 000000000..4cbcbe7ce --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-context-menu.ts @@ -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(null) + + useEffect(() => { + if (!editor) { + return + } + const editorDom = editor.view.dom + const captureTarget = (event: MouseEvent): void => { + const cell = + event.target instanceof Element + ? event.target.closest('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]) +} diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx b/src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx new file mode 100644 index 000000000..06e66d811 --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx @@ -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() + +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 ( +
+ ) +} + +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() + 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() + 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() + } + }) +}) diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-control-target.ts b/src/renderer/src/components/editor/use-rich-markdown-table-control-target.ts new file mode 100644 index 000000000..3861f7cbd --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-control-target.ts @@ -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('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 +): { + active: ActiveTableCell | null + hoveredAddAxis: TableAxis | null + hoveredAxis: TableAxis | null +} { + const [active, setActive] = useState(null) + const [hoveredAddAxis, setHoveredAddAxis] = useState(null) + const [hoveredAxis, setHoveredAxis] = useState(null) + const [, setLayoutVersion] = useState(0) + const pendingPointerRef = useRef(null) + const pointerFrameRef = useRef(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 } +} diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 0d7d78d1e..9fa818066 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -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({ ) : null - - const editorPane = ( -
- -
- ) - + const editorPane = const previewPane = ( ) - const attachmentFooter = isTabbed ? ( ) : null - return (
(null) + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 29ccc6928..68b446d7a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2a514fff5..e1740cf37 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d1c5601dd..73e4b9348 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a6b1f219b..cca80e335 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 38951bf31..cbdc7e4b4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6199c35b8..b0e0d4cfa 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2750,6 +2750,7 @@ function createWebUiApi(): NonNullable['ui']> { onFileDrop: () => noopUnsubscribe, syncTrafficLights: () => {}, setMarkdownEditorFocused: () => {}, + setRichMarkdownContextMenuTarget: () => {}, setTerminalInputFocused: () => {}, setFloatingFocus: () => {}, setShortcutRecorderFocused: () => {}, diff --git a/src/shared/rich-markdown-context-menu.ts b/src/shared/rich-markdown-context-menu.ts index 5ecc22ee5..41c14c5a9 100644 --- a/src/shared/rich-markdown-context-menu.ts +++ b/src/shared/rich-markdown-context-menu.ts @@ -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'