Files
orca/src/main/window/editable-context-menu.test.ts
T
6942871194 feat(editor): add Markdown table structure controls (#11985)
* feat(editor): add markdown table structure controls

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

* Replace table toolbar with context-aware overlay controls

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

* Replace markdown table context query with IPC coordination

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

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

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

---------

Co-authored-by: rainL <WYK15@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-05 11:10:05 -07:00

301 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
buildEditableContextMenuTemplate,
matchingRichMarkdownContextMenuTableTarget,
parseRichMarkdownContextMenuTableTarget
} from './editable-context-menu'
import { richMarkdownContextMenuCommandChannel } from '../../shared/rich-markdown-context-menu'
function contextParams(
overrides: Partial<Electron.ContextMenuParams> = {}
): Electron.ContextMenuParams {
return {
x: 0,
y: 0,
frame: null,
linkURL: '',
linkText: '',
pageURL: '',
frameURL: '',
srcURL: '',
mediaType: 'none',
hasImageContents: false,
isEditable: true,
selectionText: '',
titleText: '',
altText: '',
suggestedFilename: '',
selectionRect: { x: 0, y: 0, width: 0, height: 0 },
selectionStartOffset: 0,
referrerPolicy: {} as Electron.Referrer,
misspelledWord: 'refrence',
dictionarySuggestions: ['reference', 'reverence'],
frameCharset: 'utf-8',
formControlType: 'none',
spellcheckEnabled: true,
menuSourceType: 'mouse',
mediaFlags: {} as Electron.MediaFlags,
editFlags: {} as Electron.EditFlags,
...overrides
}
}
describe('buildEditableContextMenuTemplate', () => {
it('builds spelling suggestion actions for editable markdown text', () => {
const replaceMisspelling = vi.fn()
const addWordToSpellCheckerDictionary = vi.fn()
const send = vi.fn()
const template = buildEditableContextMenuTemplate(contextParams(), {
replaceMisspelling,
send,
session: { addWordToSpellCheckerDictionary } as unknown as Electron.Session
})
expect(template.slice(0, 5).map((item) => item.label ?? item.type)).toEqual([
'reference',
'reverence',
'separator',
'Add to dictionary',
'separator'
])
template[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
template[3].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(replaceMisspelling).toHaveBeenCalledWith('reference')
expect(addWordToSpellCheckerDictionary).toHaveBeenCalledWith('refrence')
})
it('builds a menu when Chromium reports suggestions even if spellcheckEnabled is false', () => {
const template = buildEditableContextMenuTemplate(contextParams({ spellcheckEnabled: false }), {
replaceMisspelling: vi.fn(),
send: vi.fn(),
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
})
expect(template.map((item) => item.label ?? item.type)).toContain('reference')
})
it('adds useful markdown and native edit actions for rich markdown text', () => {
const send = vi.fn()
const template = buildEditableContextMenuTemplate(
contextParams({ x: 12, y: 34, misspelledWord: '', dictionarySuggestions: [] }),
{
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([
'Add link',
'separator',
'Format',
'Paragraph',
'Insert',
'Table',
'separator',
'cut',
'copy',
'Paste',
'Paste as plain text',
'selectAll'
])
template[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(send).toHaveBeenCalledWith(richMarkdownContextMenuCommandChannel, {
command: 'add-link',
x: 12,
y: 34
})
const formatMenu = template[2].submenu as Electron.MenuItemConstructorOptions[]
formatMenu[0].click?.(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent
)
expect(send).toHaveBeenLastCalledWith(richMarkdownContextMenuCommandChannel, {
command: 'bold',
x: 12,
y: 34
})
const paragraphMenu = template[3].submenu as Electron.MenuItemConstructorOptions[]
expect(paragraphMenu.map((item) => item.label ?? item.type)).toEqual([
'Body text',
'Heading 1',
'Heading 2',
'Heading 3',
'Heading 4',
'Heading 5',
'separator',
'Bullet list',
'Numbered list',
'Checklist'
])
paragraphMenu[5].click?.(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent
)
expect(send).toHaveBeenLastCalledWith(richMarkdownContextMenuCommandChannel, {
command: 'heading-5',
x: 12,
y: 34
})
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(),
send: vi.fn(),
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
}
expect(
buildEditableContextMenuTemplate(contextParams({ isEditable: false }), webContents)
).toEqual([])
})
it('keeps coordinated paste available for regular editable text without spelling actions', () => {
const send = vi.fn()
const template = buildEditableContextMenuTemplate(
contextParams({
formControlType: 'input-text',
misspelledWord: '',
dictionarySuggestions: []
}),
{
replaceMisspelling: vi.fn(),
send,
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
}
)
expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([
'cut',
'copy',
'Paste',
'Paste as plain text',
'selectAll'
])
template[2].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
template[3].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false })
expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true })
})
it('keeps regular text inputs to spelling and native edit actions', () => {
const send = vi.fn()
const template = buildEditableContextMenuTemplate(
contextParams({ formControlType: 'input-text' }),
{
replaceMisspelling: vi.fn(),
send,
session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session
}
)
expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([
'reference',
'reverence',
'separator',
'Add to dictionary',
'separator',
'cut',
'copy',
'Paste',
'Paste as plain text',
'selectAll'
])
template[7].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
template[8].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent)
expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false })
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()
})
})