diff --git a/playwright/bdd/features/editor/editor-editing.feature b/playwright/bdd/features/editor/editor-editing.feature index a2405d4d..9f97d1ef 100644 --- a/playwright/bdd/features/editor/editor-editing.feature +++ b/playwright/bdd/features/editor/editor-editing.feature @@ -132,6 +132,58 @@ Feature: Editor editing When I press "Escape" Then the slash menu is hidden + Scenario: Question mark does not open the slash menu + When I type "?" in the editor + Then the editor contains "?" + And the slash menu is hidden + + Scenario: Slash menu desktop grouping and aliases work + When I open the slash menu + Then the slash menu group "Basic blocks" is visible + And the slash menu group "Media" is visible + And the slash menu group "Database" is visible + When I search the slash menu for "heading 1" + Then the slash menu command "heading1" is visible + When I press "Escape" + And I open the slash menu + And I search the slash menu for "hr" + Then the slash menu command "divider" is visible + + Scenario: Keyboard Enter follows grouped slash menu order + When I open the slash menu + And I search the slash menu for "table" + Then the slash menu command "simpleTable" is visible + When I press "Enter" + Then the document has 1 "simple_table" block + + Scenario: Slash trigger ignores native inputs inside editor chrome + When I focus a native input inside the editor + And I type "/" in the nested native input + Then the nested native input contains "/" + And the slash menu is hidden + + Scenario: Slash menu inside simple table follows desktop restrictions + When I choose slash command "simpleTable" + And I focus simple table cell 0, 0 + And I type slash in the editor + Then the slash menu command "text" is visible + And the slash menu command "pdf" is available + And the slash menu command "dateOrReminder" is available + And the slash menu command "simpleTable" is hidden + And the slash menu command "grid" is hidden + And the slash menu command "linkedGrid" is hidden + And the slash menu command "chart" is hidden + And the slash menu command "linkedChart" is hidden + And the slash menu command "outline" is hidden + + Scenario: Slash menu no-result state tolerates keyboard navigation + When I open the slash menu + And I search the slash menu for "zzzz-not-found" + Then the slash menu has 0 visible command + When I press "Tab" + And I press "ArrowDown" + Then the slash menu is visible + Scenario: Keyboard Enter selects a filtered slash command When I open the slash menu And I search the slash menu for "quote" diff --git a/playwright/bdd/steps/editor-editing.steps.ts b/playwright/bdd/steps/editor-editing.steps.ts index 22a5b8a6..ce9fd70b 100644 --- a/playwright/bdd/steps/editor-editing.steps.ts +++ b/playwright/bdd/steps/editor-editing.steps.ts @@ -199,13 +199,11 @@ When('I redo the editor change {int} times', async ({ page }, count: number) => When('I open the slash menu', async ({ page }) => { await focusEditor(page); - await page.keyboard.type('/'); - await expect(SlashCommandSelectors.slashPanel(page)).toBeVisible({ timeout: 10000 }); + await openSlashMenuAtCurrentSelection(page); }); When('I type slash in the editor', async ({ page }) => { - await page.keyboard.type('/'); - await expect(SlashCommandSelectors.slashPanel(page)).toBeVisible({ timeout: 10000 }); + await openSlashMenuAtCurrentSelection(page); }); When('I search the slash menu for {string}', async ({ page }, searchTerm: string) => { @@ -223,7 +221,8 @@ When('I select slash command {string}', async ({ page }, command: string) => { When('I choose slash command {string}', async ({ page }, command: string) => { await focusEditor(page); - await page.keyboard.type(`/${slashCommandSearch(command)}`, { delay: 50 }); + await openSlashMenuAtCurrentSelection(page); + await page.keyboard.type(slashCommandSearch(command), { delay: 50 }); const commandItem = page.getByTestId(`slash-menu-${command}`); @@ -247,6 +246,40 @@ When('I press the toggle block shortcut', async ({ page }) => { await page.waitForTimeout(300); }); +When('I focus simple table cell {int}, {int}', async ({ page }, rowIndex: number, cellIndex: number) => { + const cell = page.locator(`td[data-row-index="${rowIndex}"][data-cell-index="${cellIndex}"]`).first(); + + await expect(cell).toBeVisible({ timeout: 10000 }); + await cell.click({ force: true }); + await page.waitForTimeout(300); +}); + +When('I focus a native input inside the editor', async ({ page }) => { + await EditorSelectors.slateEditor(page).evaluate((editorElement) => { + let wrapper = editorElement.querySelector('[data-testid="nested-native-input-wrapper"]'); + let input = editorElement.querySelector('[data-testid="nested-native-input"]'); + + if (!wrapper || !input) { + wrapper = document.createElement('div'); + wrapper.setAttribute('contenteditable', 'false'); + wrapper.setAttribute('data-testid', 'nested-native-input-wrapper'); + input = document.createElement('input'); + input.setAttribute('data-testid', 'nested-native-input'); + wrapper.appendChild(input); + editorElement.appendChild(wrapper); + } + + input.value = ''; + }); + + await page.getByTestId('nested-native-input').focus(); +}); + +When('I type {string} in the nested native input', async ({ page }, text: string) => { + await page.keyboard.type(text); + await page.waitForTimeout(300); +}); + Then('the editor contains {string}', async ({ page }, text: string) => { await expect(EditorSelectors.slateEditor(page)).toContainText(text); }); @@ -368,12 +401,28 @@ Then('the slash menu command {string} is visible', async ({ page }, command: str await expect(page.getByTestId(`slash-menu-${command}`)).toBeVisible(); }); +Then('the slash menu command {string} is available', async ({ page }, command: string) => { + await expect(page.getByTestId(`slash-menu-${command}`)).toHaveCount(1); +}); + +Then('the slash menu command {string} is hidden', async ({ page }, command: string) => { + await expect(page.getByTestId(`slash-menu-${command}`)).toHaveCount(0); +}); + +Then('the slash menu group {string} is visible', async ({ page }, groupName: string) => { + await expect(SlashCommandSelectors.slashPanel(page).getByText(groupName, { exact: true })).toBeVisible(); +}); + Then('the slash menu has {int} visible command', async ({ page }, count: number) => { await expect(SlashCommandSelectors.slashPanel(page).locator('[data-testid^="slash-menu-"]:visible')).toHaveCount( count ); }); +Then('the nested native input contains {string}', async ({ page }, value: string) => { + await expect(page.getByTestId('nested-native-input')).toHaveValue(value); +}); + Then( '{string} is nested under {string} in {string}', async ({ page }, childText: string, parentText: string, blockType: string) => { @@ -432,6 +481,13 @@ async function focusEditor(page: Page) { await page.waitForTimeout(200); } +async function openSlashMenuAtCurrentSelection(page: Page) { + const slashPanel = SlashCommandSelectors.slashPanel(page); + + await page.keyboard.type('/'); + await expect(slashPanel).toBeVisible({ timeout: 10000 }); +} + async function undoEditorChange(page: Page) { await page.keyboard.press(`${modKey}+z`); } diff --git a/playwright/e2e/page/simple-table.spec.ts b/playwright/e2e/page/simple-table.spec.ts index 28f2036c..c47a6ace 100644 --- a/playwright/e2e/page/simple-table.spec.ts +++ b/playwright/e2e/page/simple-table.spec.ts @@ -695,6 +695,16 @@ test.describe('SimpleTable', () => { const slashMenu = page.getByRole('button', { name: 'Text' }); await expect(slashMenu).toBeVisible({ timeout: 3000 }); + await expect(page.getByTestId('slash-menu-simpleTable')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-grid')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-linkedGrid')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-board')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-calendar')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-chart')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-linkedChart')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-outline')).toHaveCount(0); + await expect(page.getByTestId('slash-menu-pdf')).toHaveCount(1); + await expect(page.getByTestId('slash-menu-dateOrReminder')).toHaveCount(1); // Press Escape to dismiss await page.keyboard.press('Escape'); diff --git a/src/components/editor/components/panels/PanelsContext.tsx b/src/components/editor/components/panels/PanelsContext.tsx index 8385ca05..60d12ecd 100644 --- a/src/components/editor/components/panels/PanelsContext.tsx +++ b/src/components/editor/components/panels/PanelsContext.tsx @@ -30,7 +30,30 @@ export interface PanelContextType { export const PanelContext = createContext(undefined); -const panelTypeChars = ['/', '@', '+']; +const panelTypeByTrigger: Record = { + '/': PanelType.Slash, + '+': PanelType.PageReference, + '@': PanelType.Mention, +}; + +const panelTypeChars = Object.keys(panelTypeByTrigger); + +function getPanelPosition(editor: ReactEditor, selection: BaseRange) { + const rect = getRangeRect(); + + if (rect) return { top: rect.top, left: rect.left }; + + try { + const domRange = ReactEditor.toDOMRange(editor, selection); + const domRect = domRange.getBoundingClientRect(); + + return { top: domRect.top, left: domRect.left }; + } catch { + const editorRect = ReactEditor.toDOMNode(editor, editor).getBoundingClientRect(); + + return { top: editorRect.top, left: editorRect.left }; + } +} export const PanelProvider = ({ children, editor }: { children: React.ReactNode; editor: ReactEditor }) => { const [activePanel, setActivePanel] = useState(undefined); @@ -48,6 +71,8 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; }, [activePanel]); const closePanel = useCallback(() => { + openRef.current = false; + activePanelRef.current = undefined; setActivePanel(undefined); startSelection.current = null; endSelection.current = null; @@ -77,6 +102,8 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; const openPanel = useCallback( (panel: PanelType, position: { top: number; left: number }) => { + openRef.current = true; + activePanelRef.current = panel; setActivePanel(panel); setPanelPosition(position); pasteAsPayloadRef.current = undefined; @@ -89,6 +116,47 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; [editor] ); + const isSlashPanelBlocked = useCallback( + (selection: BaseRange) => { + const inNonPanelBlock = Editor.above(editor, { + at: selection, + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + (SOFT_BREAK_TYPES.includes(n.type as BlockType) || + n.type === BlockType.AIMeetingTranscriptionBlock || + n.type === BlockType.AIMeetingSpeakerBlock), + }); + + return Boolean(inNonPanelBlock); + }, + [editor] + ); + + const openTriggerPanel = useCallback( + (panelType: PanelType, triggerLength = 1) => { + const { selection } = editor; + + if (!selection) return; + if (panelType === PanelType.Slash && isSlashPanelBlocked(selection)) return; + + const position = getPanelPosition(editor, selection); + + if (!position) return; + + openPanel(panelType, position); + startSelection.current = { + anchor: { + path: selection.anchor.path, + offset: Math.max(0, selection.anchor.offset - triggerLength), + }, + focus: selection.focus, + }; + endSelection.current = editor.selection; + }, + [editor, isSlashPanelBlocked, openPanel] + ); + useEffect(() => { const slateDom = ReactEditor.toDOMNode(editor, editor); const handlePasteAsMenu = (event: Event) => { @@ -137,38 +205,10 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; if (openRef.current) return; if (panelTypeChars.includes(text)) { - const position = getRangeRect(); - - if (!position) return; - - const panelType = { '/': PanelType.Slash, '+': PanelType.PageReference, '@': PanelType.Mention }[text]; + const panelType = panelTypeByTrigger[text]; if (!panelType) return; - - if (panelType === PanelType.Slash && selection) { - const inNonPanelBlock = Editor.above(editor, { - at: selection, - match: (n) => - !Editor.isEditor(n) && - Element.isElement(n) && - (SOFT_BREAK_TYPES.includes(n.type as BlockType) || - n.type === BlockType.AIMeetingTranscriptionBlock || - n.type === BlockType.AIMeetingSpeakerBlock), - }); - - if (inNonPanelBlock) return; - } - - openPanel(panelType, { top: position.top, left: position.left }); - - startSelection.current = { - anchor: { - path: selection.anchor.path, - offset: selection.anchor.offset - 1, - }, - focus: selection.focus, - }; - endSelection.current = editor.selection; + openTriggerPanel(panelType); return; } @@ -181,26 +221,14 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; }); if (rangeText === '[[') { - const position = getRangeRect(); - - if (!position) return; - - openPanel(PanelType.PageReference, { top: position.top, left: position.left }); - startSelection.current = { - anchor: { - path: selection.anchor.path, - offset: selection.anchor.offset - 2, - }, - focus: selection.focus, - }; - endSelection.current = editor.selection; + openTriggerPanel(PanelType.PageReference, 2); } }; return () => { editor.insertText = insertText; }; - }, [editor, openPanel]); + }, [editor, openTriggerPanel]); useEffect(() => { const { onChange } = editor; @@ -240,28 +268,31 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; }, [editor, closePanel]); useEffect(() => { + const slateDom = ReactEditor.toDOMNode(editor, editor); const handleKeyDown = (e: KeyboardEvent) => { const { key } = e; + const target = e.target; - if (!openRef.current) return; + if (!(target instanceof Node) || !ReactEditor.hasEditableTarget(editor, target)) return; + + if (!openRef.current) { + const panelType = panelTypeByTrigger[key]; + + if (!panelType || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return; + + window.setTimeout(() => { + if (!openRef.current) { + openTriggerPanel(panelType); + } + }, 0); + return; + } switch (key) { case 'Escape': e.stopPropagation(); closePanel(); break; - case 'ArrowLeft': - case 'ArrowRight': { - // Allow Shift+Arrow for text selection even when panel is open - if (e.shiftKey) { - // Let the browser handle Shift+Arrow for text selection - return; - } - - e.preventDefault(); - break; - } - case 'Backspace': { const { selection } = editor; @@ -283,14 +314,12 @@ export const PanelProvider = ({ children, editor }: { children: React.ReactNode; } }; - const slateDom = ReactEditor.toDOMNode(editor, editor); - - slateDom.addEventListener('keydown', handleKeyDown); + slateDom.addEventListener('keydown', handleKeyDown, true); return () => { - slateDom.removeEventListener('keydown', handleKeyDown); + slateDom.removeEventListener('keydown', handleKeyDown, true); }; - }, [closePanel, editor]); + }, [closePanel, editor, openTriggerPanel]); const contextValue = useMemo( () => ({ diff --git a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx index 3a4ffaf5..e96d1c76 100644 --- a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx +++ b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx @@ -8,13 +8,18 @@ import { ReactEditor, useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { isEmbedBlockTypes } from '@/application/slate-yjs/command/const'; -import { findSlateEntryByBlockId, getBlockEntry } from '@/application/slate-yjs/utils/editor'; +import { + findSlateEntryByBlockId, + getBlockEntry, + isInsideSimpleTableCell as isBlockInsideSimpleTableCell, +} from '@/application/slate-yjs/utils/editor'; import { getBlockIndex, getParent } from '@/application/slate-yjs/utils/yjs'; import { AlignType, BlockData, BlockType, CalloutBlockData, + CodeBlockData, HeadingBlockData, ImageBlockData, SubpageNodeData, @@ -32,7 +37,9 @@ import { ReactComponent as BoardIcon } from '@/assets/icons/board.svg'; import { ReactComponent as BulletedListIcon } from '@/assets/icons/bulleted_list.svg'; import { ReactComponent as CalendarIcon } from '@/assets/icons/calendar.svg'; import { ReactComponent as CalloutIcon } from '@/assets/icons/callout.svg'; +import { ReactComponent as ChartIcon } from '@/assets/icons/chart.svg'; import { ReactComponent as ContinueWritingIcon } from '@/assets/icons/continue_writing.svg'; +import { ReactComponent as DateIcon } from '@/assets/icons/date.svg'; import { ReactComponent as DividerIcon } from '@/assets/icons/divider.svg'; import { ReactComponent as OutlineIcon } from '@/assets/icons/doc.svg'; import { ReactComponent as FileIcon } from '@/assets/icons/file.svg'; @@ -46,6 +53,7 @@ import { ReactComponent as ImageIcon } from '@/assets/icons/image.svg'; import { ReactComponent as CodeIcon } from '@/assets/icons/inline_code.svg'; import { ReactComponent as NumberedListIcon } from '@/assets/icons/numbered_list.svg'; import { ReactComponent as DocumentIcon } from '@/assets/icons/page.svg'; +import { ReactComponent as PDFIcon } from '@/assets/icons/pdf.svg'; import { ReactComponent as QuoteIcon } from '@/assets/icons/quote.svg'; import { ReactComponent as RefDocumentIcon } from '@/assets/icons/ref_page.svg'; import { ReactComponent as TextIcon } from '@/assets/icons/text.svg'; @@ -72,19 +80,29 @@ import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Log } from '@/utils/log'; import { getCharacters } from '@/utils/word'; +import { + filterSlashMenuOptions, + groupSlashMenuOptions, + SlashMenuGroupKey, + SlashMenuOptionBase, +} from './slash-menu-options'; type DatabaseOption = { databaseId: string; view: View; }; -const AI_MEETING_DATABASE_OPTION_KEYS = new Set([ - 'grid', - 'linkedGrid', - 'board', - 'linkedKanban', - 'calendar', - 'linkedCalendar', +interface SlashMenuOption extends SlashMenuOptionBase { + icon: React.ReactNode; + onClick?: () => void; +} + +const AI_MEETING_BLOCK_TYPES = new Set([ + BlockType.AIMeetingBlock, + BlockType.AIMeetingSummaryBlock, + BlockType.AIMeetingNotesBlock, + BlockType.AIMeetingTranscriptionBlock, + BlockType.AIMeetingSpeakerBlock, ]); function filterViewsByDatabases(views: View[], allowedIds: Set, keyword: string) { @@ -158,7 +176,9 @@ const DatabaseTreeItem: React.FC<{ } }} > - + ) : (
@@ -202,7 +222,7 @@ export function SlashPanel({ }: { setEmojiPosition: (position: { top: number; left: number }) => void; }) { - const { isPanelOpen, panelPosition, closePanel, searchText, removeContent } = usePanelContext(); + const { isPanelOpen, panelPosition, closePanel, searchText, removeContent, openPanel } = usePanelContext(); const { addPage, openPageModal, @@ -238,20 +258,42 @@ export function SlashPanel({ return isPanelOpen(PanelType.Slash); }, [isPanelOpen]); - const shouldRestrictAIMeetingDatabaseOptions = useCallback(() => { + const getIsInsideAIMeeting = useCallback(() => { const { selection } = editor; if (!selection) return false; - const inAIMeetingSection = Editor.above(editor, { - at: selection, - match: (n) => - !Editor.isEditor(n) && - Element.isElement(n) && - (n.type === BlockType.AIMeetingSummaryBlock || n.type === BlockType.AIMeetingNotesBlock), - }); + try { + const inAIMeeting = Editor.above(editor, { + at: selection, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && AI_MEETING_BLOCK_TYPES.has(n.type as BlockType), + }); + + return Boolean(inAIMeeting); + } catch { + return false; + } + }, [editor]); + + const getIsInsideSimpleTableCell = useCallback(() => { + try { + const block = getBlockEntry(editor); + const blockId = block?.[0].blockId; + + if (blockId && isBlockInsideSimpleTableCell(editor, blockId)) return true; + } catch { + // Fall back to the ancestor lookup below; selections can briefly point at stale paths while the panel opens. + } + + try { + const inSimpleTableCell = Editor.above(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === BlockType.SimpleTableCellBlock, + }); - return Boolean(inAIMeetingSection); + return Boolean(inSimpleTableCell); + } catch { + return false; + } }, [editor]); useEffect(() => { @@ -303,6 +345,8 @@ export function SlashPanel({ return BlockType.BoardBlock; case ViewLayout.Calendar: return BlockType.CalendarBlock; + case ViewLayout.Chart: + return BlockType.ChartBlock; default: return null; } @@ -335,9 +379,14 @@ export function SlashPanel({ } if (newBlockId && isEmbedBlockTypes(type)) { - // Skip selection for database blocks (Grid, Board, Calendar) as they open in a modal + // Skip selection for database blocks as they open in a modal // and don't need cursor positioning. Explicitly deselect to prevent Slate from scrolling. - const isDatabaseBlock = [BlockType.GridBlock, BlockType.BoardBlock, BlockType.CalendarBlock].includes(type); + const isDatabaseBlock = [ + BlockType.GridBlock, + BlockType.BoardBlock, + BlockType.CalendarBlock, + BlockType.ChartBlock, + ].includes(type); if (isDatabaseBlock) { Transforms.deselect(editor); @@ -352,7 +401,15 @@ export function SlashPanel({ } } - if ([BlockType.FileBlock, BlockType.ImageBlock, BlockType.EquationBlock, BlockType.VideoBlock].includes(type)) { + if ( + [ + BlockType.FileBlock, + BlockType.ImageBlock, + BlockType.EquationBlock, + BlockType.VideoBlock, + BlockType.PDFBlock, + ].includes(type) + ) { setTimeout(() => { if (!newBlockId) return; const entry = findSlateEntryByBlockId(editor, newBlockId); @@ -377,8 +434,6 @@ export function SlashPanel({ return filterViewsByDatabases(databaseOutline, allowedDatabaseIds, databaseSearch); }, [databaseOutline, allowedDatabaseIds, databaseSearch]); - const { openPanel } = usePanelContext(); - const { askAIAnything, continueWriting } = useAIWriter(); const aiEnabled = useAIEnabled(); @@ -397,7 +452,7 @@ export function SlashPanel({ // 1. Database containers (v0.10.7+) - always selectable // 2. Legacy top-level databases (pre-v0.10.7) - selectable // 3. Child views of containers/databases - NOT selectable (hidden) - const databaseLayouts = new Set([ViewLayout.Grid, ViewLayout.Board, ViewLayout.Calendar]); + const databaseLayouts = new Set([ViewLayout.Grid, ViewLayout.Board, ViewLayout.Calendar, ViewLayout.Chart]); const selectableDatabaseViews: View[] = []; const collectSelectable = (items: View[], parentIsDatabase: boolean) => { @@ -435,7 +490,7 @@ export function SlashPanel({ Log.debug('[SlashPanel] loadDatabasesForPicker:', { databaseViews: selectableDatabaseViews.length, - databaseViewNames: selectableDatabaseViews.map(v => v.name), + databaseViewNames: selectableDatabaseViews.map((v) => v.name), }); setDatabaseOptions(options); @@ -508,9 +563,7 @@ export function SlashPanel({ try { const databaseViewId = option.view.view_id; - const baseName = - option.view.name || - t('document.view.placeholder', { defaultValue: 'Untitled' }); + const baseName = option.view.name || t('document.view.placeholder', { defaultValue: 'Untitled' }); // Database ID is available on database containers and database views via `extra.database_id`. // Prefer the outline value, then fallback to view meta / legacy database_relations mapping. @@ -519,9 +572,11 @@ export function SlashPanel({ if (!databaseId) { if (!loadViewMeta) { - notify.error(t('document.slashMenu.linkedDatabase.actionUnavailable', { - defaultValue: 'Unable to fetch database information', - })); + notify.error( + t('document.slashMenu.linkedDatabase.actionUnavailable', { + defaultValue: 'Unable to fetch database information', + }) + ); return; } @@ -551,9 +606,7 @@ export function SlashPanel({ }); if (freshRelations) { - relationEntry = Object.entries(freshRelations).find( - ([_, baseViewId]) => baseViewId === databaseViewId - ); + relationEntry = Object.entries(freshRelations).find(([_, baseViewId]) => baseViewId === databaseViewId); } } @@ -571,9 +624,11 @@ export function SlashPanel({ }); if (!databaseId) { - notify.error(t('document.slashMenu.linkedDatabase.actionUnavailable', { - defaultValue: 'Could not find database ID', - })); + notify.error( + t('document.slashMenu.linkedDatabase.actionUnavailable', { + defaultValue: 'Could not find database ID', + }) + ); return; } @@ -596,6 +651,10 @@ export function SlashPanel({ return t('document.calendar.referencedCalendarPrefix', { defaultValue: 'View of', }); + case ViewLayout.Chart: + return t('document.chart.referencedChartPrefix', { + defaultValue: 'View of', + }); default: return ''; } @@ -617,11 +676,14 @@ export function SlashPanel({ referencedName, }); - turnInto(blockType, createDatabaseNodeData({ - parentId: documentId, - viewIds: [response.view_id], - databaseId: response.database_id, - })); + turnInto( + blockType, + createDatabaseNodeData({ + parentId: documentId, + viewIds: [response.view_id], + databaseId: response.database_id, + }) + ); } catch (e) { const error = e as Error; @@ -643,45 +705,59 @@ export function SlashPanel({ ] ); - const options: { - label: string; - key: string; - icon: React.ReactNode; - keywords: string[]; - onClick?: () => void; - }[] = useMemo(() => { - const restrictDatabaseOptionsInAIMeeting = shouldRestrictAIMeetingDatabaseOptions(); - - return [ - ...(aiEnabled ? [ - { - label: t('document.slashMenu.name.askAIAnything'), - key: 'askAIAnything', - icon: , - keywords: ['ai', 'writer', 'ask', 'anything', 'askAIAnything', 'askai'], - onClick: () => { - const content = getBeforeContent(); - - askAIAnything(content); - }, - }, - { - label: t('document.slashMenu.name.continueWriting'), - key: 'continueWriting', - disabled: chars < 2, - icon: , - keywords: ['ai', 'writing', 'continue'], - onClick: () => { - const content = getBeforeContent(); - - void continueWriting(content); - }, - }, - ] : []), + const groupLabels = useMemo>( + () => ({ + [SlashMenuGroupKey.AppFlowyAI]: t('document.slashMenu.group.appflowyAI', { defaultValue: 'AppFlowy AI' }), + [SlashMenuGroupKey.BasicBlocks]: t('document.slashMenu.group.basicBlocks', { defaultValue: 'Basic blocks' }), + [SlashMenuGroupKey.Media]: t('document.slashMenu.group.media', { defaultValue: 'Media' }), + [SlashMenuGroupKey.Database]: t('document.slashMenu.group.database', { defaultValue: 'Database' }), + [SlashMenuGroupKey.AdvancedBlocks]: t('document.slashMenu.group.advancedBlocks', { + defaultValue: 'Advanced blocks', + }), + [SlashMenuGroupKey.Inline]: t('document.slashMenu.group.inline', { defaultValue: 'Inline' }), + }), + [t] + ); + + const options: SlashMenuOption[] = useMemo(() => { + const isInsideSimpleTableCell = getIsInsideSimpleTableCell(); + const isInsideAIMeeting = getIsInsideAIMeeting(); + + const allOptions: SlashMenuOption[] = [ + ...(aiEnabled + ? [ + { + label: t('document.slashMenu.name.askAIAnything'), + key: 'askAIAnything', + icon: , + group: SlashMenuGroupKey.AppFlowyAI, + keywords: ['ai', 'writer', 'ask', 'anything', 'askAIAnything', 'askai'], + onClick: () => { + const content = getBeforeContent(); + + askAIAnything(content); + }, + }, + { + label: t('document.slashMenu.name.continueWriting'), + key: 'continueWriting', + disabled: chars < 2, + icon: , + group: SlashMenuGroupKey.AppFlowyAI, + keywords: ['ai', 'writing', 'continue'], + onClick: () => { + const content = getBeforeContent(); + + void continueWriting(content); + }, + }, + ] + : []), { label: t('document.slashMenu.name.text'), key: 'text', icon: , + group: SlashMenuGroupKey.BasicBlocks, onClick: () => { turnInto(BlockType.Paragraph, {}); }, @@ -691,7 +767,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.heading1'), key: 'heading1', icon: , - keywords: ['heading1', 'h1', 'heading'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['heading1', 'h1', 'heading', 'heading 1'], + shortcut: '#', onClick: () => { turnInto(BlockType.HeadingBlock, { level: 1, @@ -702,7 +780,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.heading2'), key: 'heading2', icon: , - keywords: ['heading2', 'h2', 'subheading', 'heading'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['heading2', 'h2', 'subheading', 'heading', 'heading 2'], + shortcut: '##', onClick: () => { turnInto(BlockType.HeadingBlock, { level: 2, @@ -713,7 +793,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.heading3'), key: 'heading3', icon: , - keywords: ['heading3', 'h3', 'subheading', 'heading'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['heading3', 'h3', 'subheading', 'heading', 'heading 3'], + shortcut: '###', onClick: () => { turnInto(BlockType.HeadingBlock, { level: 3, @@ -724,7 +806,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.image'), key: 'image', icon: , - keywords: ['image', 'img'], + group: SlashMenuGroupKey.Media, + keywords: ['image', 'img', 'photo', 'picture'], onClick: () => { turnInto(BlockType.ImageBlock, { url: '', @@ -736,6 +819,7 @@ export function SlashPanel({ label: t('embedVideo'), key: 'video', icon: , + group: SlashMenuGroupKey.Media, keywords: ['video', 'youtube', 'embed'], onClick: () => { turnInto(BlockType.VideoBlock, { @@ -744,11 +828,23 @@ export function SlashPanel({ } as VideoBlockData); }, }, + { + label: t('document.slashMenu.name.pdf', { defaultValue: 'PDF' }), + key: 'pdf', + icon: , + group: SlashMenuGroupKey.Media, + keywords: ['pdf', 'file', 'document', 'embed'], + onClick: () => { + turnInto(BlockType.PDFBlock, {}); + }, + }, { label: t('document.slashMenu.name.bulletedList'), key: 'bulletedList', icon: , - keywords: ['bulleted', 'list'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['bulleted', 'list', 'unordered', 'ul', 'bl'], + shortcut: '-', onClick: () => { turnInto(BlockType.BulletedListBlock, {}); }, @@ -757,7 +853,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.numberedList'), key: 'numberedList', icon: , - keywords: ['numbered', 'list'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['numbered', 'list', 'ordered', 'ol', 'nl'], + shortcut: '1.', onClick: () => { turnInto(BlockType.NumberedListBlock, {}); }, @@ -766,7 +864,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.todoList'), key: 'todoList', icon: , - keywords: ['todo', 'list'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['todo', 'to-do', 'list', 'checkbox', 'task'], + shortcut: '[]', onClick: () => { turnInto(BlockType.TodoListBlock, {}); }, @@ -775,7 +875,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.divider'), key: 'divider', icon: , - keywords: ['divider', 'line'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['divider', 'line', 'separator', 'break', 'horizontal line', 'hr'], + shortcut: '---', onClick: () => { turnInto(BlockType.DividerBlock, {}); }, @@ -784,7 +886,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.quote'), key: 'quote', icon: , - keywords: ['quote'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['quote', 'refer', 'blockquote', 'citation'], + shortcut: '"', onClick: () => { turnInto(BlockType.QuoteBlock, {}); }, @@ -793,7 +897,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.linkedDoc'), key: 'linkedDoc', icon: , - keywords: ['linked', 'doc', 'page', 'document'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['linked', 'doc', 'page', 'document', 'reference'], + aliases: ['link to page', 'link to doc', 'referenced page', 'referenced document', 'ltp', 'ltd', 'rp', 'rd'], onClick: () => { const rect = getRangeRect(); @@ -805,7 +911,19 @@ export function SlashPanel({ label: t('document.menuName'), key: 'document', icon: , - keywords: ['document', 'doc', 'page', 'create', 'add'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: [ + 'document', + 'doc', + 'page', + 'create', + 'add', + 'sub page', + 'child page', + 'insert page', + 'embed page', + 'new page', + ], onClick: async () => { if (!documentId || !addPage || !openPageModal) return; try { @@ -828,7 +946,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.grid'), key: 'grid', icon: , - keywords: ['grid', 'table', 'database'], + group: SlashMenuGroupKey.Database, + keywords: ['grid', 'table', 'database', 'data table'], onClick: async () => { if (!documentId || !addPage) return; @@ -859,11 +978,14 @@ export function SlashPanel({ databaseViewId: response.view_id, }); - turnInto(BlockType.GridBlock, createDatabaseNodeData({ - parentId: documentId, - viewIds: [response.view_id], - databaseId: response.database_id, - })); + turnInto( + BlockType.GridBlock, + createDatabaseNodeData({ + parentId: documentId, + viewIds: [response.view_id], + databaseId: response.database_id, + }) + ); openPageModal?.(response.view_id); @@ -906,7 +1028,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.linkedGrid'), key: 'linkedGrid', icon: , - keywords: ['linked', 'grid', 'table', 'database'], + group: SlashMenuGroupKey.Database, + keywords: ['linked', 'grid', 'table', 'database', 'data table'], + aliases: ['link to grid', 'link to database', 'referenced grid', 'ltg'], onClick: () => { void handleOpenLinkedDatabasePicker(ViewLayout.Grid, 'linkedGrid'); }, @@ -915,6 +1039,7 @@ export function SlashPanel({ label: t('document.slashMenu.name.kanban'), key: 'board', icon: , + group: SlashMenuGroupKey.Database, keywords: ['board', 'kanban', 'database'], onClick: async () => { if (!documentId || !addPage) return; @@ -946,11 +1071,14 @@ export function SlashPanel({ databaseViewId: response.view_id, }); - turnInto(BlockType.BoardBlock, createDatabaseNodeData({ - parentId: documentId, - viewIds: [response.view_id], - databaseId: response.database_id, - })); + turnInto( + BlockType.BoardBlock, + createDatabaseNodeData({ + parentId: documentId, + viewIds: [response.view_id], + databaseId: response.database_id, + }) + ); openPageModal?.(response.view_id); @@ -993,7 +1121,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.linkedKanban'), key: 'linkedKanban', icon: , + group: SlashMenuGroupKey.Database, keywords: ['linked', 'kanban', 'board', 'database'], + aliases: ['link to board', 'link to kanban', 'referenced board', 'ltb'], onClick: () => { void handleOpenLinkedDatabasePicker(ViewLayout.Board, 'linkedKanban'); }, @@ -1002,6 +1132,7 @@ export function SlashPanel({ label: t('document.slashMenu.name.calendar'), key: 'calendar', icon: , + group: SlashMenuGroupKey.Database, keywords: ['calendar', 'date', 'database'], onClick: async () => { if (!documentId || !addPage) return; @@ -1033,11 +1164,14 @@ export function SlashPanel({ databaseViewId: response.view_id, }); - turnInto(BlockType.CalendarBlock, createDatabaseNodeData({ - parentId: documentId, - viewIds: [response.view_id], - databaseId: response.database_id, - })); + turnInto( + BlockType.CalendarBlock, + createDatabaseNodeData({ + parentId: documentId, + viewIds: [response.view_id], + databaseId: response.database_id, + }) + ); openPageModal?.(response.view_id); @@ -1080,16 +1214,113 @@ export function SlashPanel({ label: t('document.slashMenu.name.linkedCalendar'), key: 'linkedCalendar', icon: , + group: SlashMenuGroupKey.Database, keywords: ['linked', 'calendar', 'date', 'database'], + aliases: ['link to calendar', 'referenced calendar', 'ltc'], onClick: () => { void handleOpenLinkedDatabasePicker(ViewLayout.Calendar, 'linkedCalendar'); }, }, { - label: 'Table', + label: t('document.slashMenu.name.chart', { defaultValue: 'Chart' }), + key: 'chart', + icon: , + group: SlashMenuGroupKey.Database, + keywords: ['chart', 'database', 'visualization'], + onClick: async () => { + if (!documentId || !addPage) return; + + let scrollContainer: HTMLElement | null = null; + + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + scrollContainer = domNode.closest('.appflowy-scroll-container'); + } catch (e) { + // Ignore + } + + if (!scrollContainer) { + scrollContainer = document.querySelector('.appflowy-scroll-container'); + } + + const savedScrollTop = scrollContainer?.scrollTop; + + try { + const response = await addPage(documentId, { + layout: ViewLayout.Chart, + name: t('document.plugins.database.newDatabase'), + }); + + Log.debug('[SlashPanel] {} created chart', { + documentId, + databaseViewId: response.view_id, + }); + + turnInto( + BlockType.ChartBlock, + createDatabaseNodeData({ + parentId: documentId, + viewIds: [response.view_id], + databaseId: response.database_id, + }) + ); + + openPageModal?.(response.view_id); + + if (savedScrollTop !== undefined) { + const restoreScroll = () => { + let currentContainer: HTMLElement | null = null; + + if (scrollContainer?.isConnected) { + currentContainer = scrollContainer; + } else { + try { + const domNode = ReactEditor.toDOMNode(editor, editor); + + currentContainer = domNode.closest('.appflowy-scroll-container'); + } catch { + currentContainer = document.querySelector('.appflowy-scroll-container'); + } + } + + if (!currentContainer) return; + if (Math.abs(currentContainer.scrollTop - savedScrollTop) <= 5) return; + + currentContainer.scrollTop = savedScrollTop; + }; + + requestAnimationFrame(restoreScroll); + setTimeout(restoreScroll, 50); + setTimeout(restoreScroll, 250); + setTimeout(restoreScroll, 600); + setTimeout(restoreScroll, 1200); + setTimeout(restoreScroll, 1800); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + notify.error(e.message); + } + }, + }, + { + label: t('document.slashMenu.name.linkedChart', { defaultValue: 'Linked Chart' }), + key: 'linkedChart', + icon: , + group: SlashMenuGroupKey.Database, + keywords: ['linked', 'chart', 'database', 'visualization'], + aliases: ['link to chart', 'referenced chart'], + onClick: () => { + void handleOpenLinkedDatabasePicker(ViewLayout.Chart, 'linkedChart'); + }, + }, + { + label: t('document.slashMenu.name.table'), key: 'simpleTable', icon: , - keywords: ['table', 'simple table'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['table', 'simple table', 'rows', 'columns', 'data'], + aliases: ['st'], onClick: () => { const block = getBlockEntry(editor); @@ -1115,20 +1346,29 @@ export function SlashPanel({ const tableId = CustomEditor.createSimpleTable(editor, parentBlockId, 2, 2, insertIndex); if (tableId) { - setTimeout(() => { + const selectTableStart = () => { try { const entry = findSlateEntryByBlockId(editor, tableId); - if (entry) { - const point = Editor.start(editor, entry[1]); + if (!entry) return false; - Transforms.select(editor, point); - ReactEditor.focus(editor); - } + const point = Editor.start(editor, entry[1]); + + Transforms.select(editor, point); + ReactEditor.focus(editor); + return true; } catch { - // ignore + return false; } - }, 200); + }; + + if (!selectTableStart()) { + requestAnimationFrame(() => { + if (!selectTableStart()) { + setTimeout(selectTableStart, 50); + } + }); + } } }, }, @@ -1136,7 +1376,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.callout'), key: 'callout', icon: , - keywords: ['callout'], + group: SlashMenuGroupKey.AdvancedBlocks, + keywords: ['callout', 'note', 'tip'], onClick: () => { turnInto(BlockType.CalloutBlock, { icon: '📌', @@ -1147,7 +1388,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.outline'), key: 'outline', icon: , - keywords: ['outline', 'table', 'contents'], + group: SlashMenuGroupKey.AdvancedBlocks, + keywords: ['outline', 'table', 'contents', 'table of contents', 'toc', 'tableofcontents'], onClick: () => { turnInto(BlockType.OutlineBlock, {}); }, @@ -1156,7 +1398,9 @@ export function SlashPanel({ label: t('document.slashMenu.name.mathEquation'), key: 'math', icon: , - keywords: ['math', 'equation', 'formula'], + group: SlashMenuGroupKey.AdvancedBlocks, + keywords: ['math', 'equation', 'formula', 'tex', 'latex', 'katex'], + shortcut: '$$', onClick: () => { turnInto(BlockType.EquationBlock, {}); }, @@ -1165,16 +1409,32 @@ export function SlashPanel({ label: t('document.slashMenu.name.code'), key: 'code', icon: , - keywords: ['code', 'block'], + group: SlashMenuGroupKey.AdvancedBlocks, + keywords: ['code', 'block', 'codeblock', 'cb'], + shortcut: '```', onClick: () => { turnInto(BlockType.CodeBlock, {}); }, }, + { + label: t('document.slashMenu.name.mermaid', { defaultValue: 'Mermaid' }), + key: 'mermaid', + icon: , + group: SlashMenuGroupKey.AdvancedBlocks, + keywords: ['mermaid', 'diagram', 'chart'], + onClick: () => { + turnInto(BlockType.CodeBlock, { + language: 'mermaid', + } as CodeBlockData); + }, + }, { label: t('document.slashMenu.name.toggleList'), key: 'toggleList', icon: , - keywords: ['toggle', 'list'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['toggle', 'list', 'collapsed list', 'dropdown', 'cl', 'tl'], + shortcut: '>', onClick: () => { turnInto(BlockType.ToggleListBlock, { collapsed: false, @@ -1185,7 +1445,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.toggleHeading1'), key: 'toggleHeading1', icon: , - keywords: ['toggle', 'heading1', 'h1', 'heading'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['toggle', 'heading1', 'h1', 'heading', 'heading 1'], onClick: () => { turnInto(BlockType.ToggleListBlock, { collapsed: false, @@ -1197,7 +1458,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.toggleHeading2'), key: 'toggleHeading2', icon: , - keywords: ['toggle', 'heading2', 'h2', 'subheading', 'heading'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['toggle', 'heading2', 'h2', 'subheading', 'heading', 'heading 2'], onClick: () => { turnInto(BlockType.ToggleListBlock, { collapsed: false, @@ -1209,7 +1471,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.toggleHeading3'), key: 'toggleHeading3', icon: , - keywords: ['toggle', 'heading3', 'h3', 'subheading', 'heading'], + group: SlashMenuGroupKey.BasicBlocks, + keywords: ['toggle', 'heading3', 'h3', 'subheading', 'heading', 'heading 3'], onClick: () => { turnInto(BlockType.ToggleListBlock, { collapsed: false, @@ -1221,7 +1484,8 @@ export function SlashPanel({ label: t('document.slashMenu.name.emoji'), key: 'emoji', icon: , - keywords: ['emoji'], + group: SlashMenuGroupKey.Inline, + keywords: ['emoji', 'reaction'], onClick: () => { setTimeout(() => { const rect = getRangeRect(); @@ -1234,22 +1498,35 @@ export function SlashPanel({ }, 50); }, }, + { + label: t('document.slashMenu.name.dateOrReminder'), + key: 'dateOrReminder', + icon: , + group: SlashMenuGroupKey.Inline, + keywords: ['date', 'reminder', 'time', 'schedule'], + onClick: () => { + const rect = getRangeRect(); + + if (!rect) return; + openPanel(PanelType.Mention, { top: rect.top, left: rect.left }); + }, + }, { label: t('document.slashMenu.name.file'), key: 'file', icon: , - keywords: ['file', 'upload'], + group: SlashMenuGroupKey.Media, + keywords: ['file', 'upload', 'attachment', 'pdf', 'video', 'audio', 'zip', 'archive'], onClick: () => { turnInto(BlockType.FileBlock, {}); }, }, - ].filter((option) => { - if (option.disabled) return false; - if (restrictDatabaseOptionsInAIMeeting && AI_MEETING_DATABASE_OPTION_KEYS.has(option.key)) return false; - if (!searchText) return true; - return option.keywords.some((keyword: string) => { - return keyword.toLowerCase().includes(searchText.toLowerCase()); - }); + ]; + + return filterSlashMenuOptions(allOptions, { + searchText, + isInsideSimpleTableCell, + isInsideAIMeeting, }); }, [ t, @@ -1267,10 +1544,12 @@ export function SlashPanel({ searchText, handleOpenLinkedDatabasePicker, editor, - shouldRestrictAIMeetingDatabaseOptions, + getIsInsideAIMeeting, + getIsInsideSimpleTableCell, ]); - const resultLength = options.length; + const optionGroups = useMemo(() => groupSlashMenuOptions(options), [options]); + const orderedOptions = useMemo(() => optionGroups.flatMap(({ options }) => options), [optionGroups]); useEffect(() => { selectedOptionRef.current = selectedOption; @@ -1296,28 +1575,15 @@ export function SlashPanel({ } }, [selectedOption]); - useEffect(() => { - if (!open || options.length === 0) return; - setSelectedOption(options[0].key); - }, [open, options]); - - const countRef = useRef(0); - useEffect(() => { if (!open) return; - if (searchText && resultLength === 0) { - countRef.current += 1; - } else { - countRef.current = 0; - } - - if (countRef.current > 1) { - closePanel(); - countRef.current = 0; - return; - } - }, [closePanel, open, resultLength, searchText]); + setSelectedOption((current) => { + if (orderedOptions.length === 0) return null; + if (current && orderedOptions.some((option) => option.key === current)) return current; + return orderedOptions[0].key; + }); + }, [open, orderedOptions]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1326,25 +1592,34 @@ export function SlashPanel({ switch (key) { case 'Enter': + case 'NumpadEnter': { e.stopPropagation(); e.preventDefault(); - if (selectedOptionRef.current) { - handleSelectOption(selectedOptionRef.current); - const item = options.find((option) => option.key === selectedOptionRef.current); + if (orderedOptions.length === 0) return; - item?.onClick?.(); - } + const item = orderedOptions.find((option) => option.key === selectedOptionRef.current) ?? orderedOptions[0]; + + handleSelectOption(item.key); + item.onClick?.(); break; + } + case 'ArrowUp': - case 'ArrowDown': { + case 'ArrowDown': + case 'Tab': { e.stopPropagation(); e.preventDefault(); - const index = options.findIndex((option) => option.key === selectedOptionRef.current); - const nextIndex = - key === 'ArrowDown' ? (index + 1) % options.length : (index - 1 + options.length) % options.length; + if (orderedOptions.length === 0) return; - setSelectedOption(options[nextIndex].key); + const index = orderedOptions.findIndex((option) => option.key === selectedOptionRef.current); + const currentIndex = index >= 0 ? index : 0; + const moveToPrevious = key === 'ArrowUp' || (key === 'Tab' && e.shiftKey); + const nextIndex = moveToPrevious + ? (currentIndex - 1 + orderedOptions.length) % orderedOptions.length + : (currentIndex + 1) % orderedOptions.length; + + setSelectedOption(orderedOptions[nextIndex].key); break; } @@ -1360,12 +1635,7 @@ export function SlashPanel({ return () => { slateDom.removeEventListener('keydown', handleKeyDown); }; - }, [closePanel, editor, open, options, handleSelectOption]); - - useEffect(() => { - if (options.length > 0) return; - setSelectedOption(null); - }, [options.length]); + }, [closePanel, editor, open, orderedOptions, handleSelectOption]); useEffect(() => { if (open && panelPosition) { @@ -1376,9 +1646,9 @@ export function SlashPanel({ isAlignBottom ? origins.transformOrigin : { - vertical: -30, - horizontal: origins.transformOrigin.horizontal, - } + vertical: -30, + horizontal: origins.transformOrigin.horizontal, + } ); } }, [open, panelPosition]); @@ -1417,24 +1687,30 @@ export function SlashPanel({ 'appflowy-scroller flex max-h-[400px] w-[320px] flex-col gap-2 overflow-y-auto overflow-x-hidden p-2' } > - {options.length > 0 ? ( - options.map((option) => ( - + {optionGroups.length > 0 ? ( + optionGroups.map(({ group, options: groupOptions }) => ( +
+
{groupLabels[group]}
+ {groupOptions.map((option) => ( + + ))} +
)) ) : (
diff --git a/src/components/editor/components/panels/slash-panel/__tests__/slash-menu-options.test.ts b/src/components/editor/components/panels/slash-panel/__tests__/slash-menu-options.test.ts new file mode 100644 index 00000000..4b5eed2a --- /dev/null +++ b/src/components/editor/components/panels/slash-panel/__tests__/slash-menu-options.test.ts @@ -0,0 +1,82 @@ +import { + filterSlashMenuOptions, + groupSlashMenuOptions, + matchesSlashMenuOption, + SlashMenuGroupKey, + SlashMenuOptionBase, +} from '../slash-menu-options'; + +const option = (overrides: Partial & Pick): SlashMenuOptionBase => ({ + label: overrides.key, + keywords: [], + group: SlashMenuGroupKey.BasicBlocks, + ...overrides, +}); + +describe('slash-menu-options', () => { + describe('matchesSlashMenuOption', () => { + it('matches label, key, shortcut, keywords, and aliases', () => { + expect(matchesSlashMenuOption(option({ key: 'heading1', label: 'Heading 1' }), 'heading 1')).toBe(true); + expect(matchesSlashMenuOption(option({ key: 'numberedList', shortcut: '1.' }), '1.')).toBe(true); + expect(matchesSlashMenuOption(option({ key: 'divider', keywords: ['horizontal line', 'hr'] }), 'hr')).toBe(true); + expect(matchesSlashMenuOption(option({ key: 'simpleTable', aliases: ['st'] }), 'st')).toBe(true); + }); + + it('does not match unrelated queries', () => { + expect(matchesSlashMenuOption(option({ key: 'heading1', label: 'Heading 1' }), 'calendar')).toBe(false); + }); + }); + + describe('filterSlashMenuOptions', () => { + const options = [ + option({ key: 'text' }), + option({ key: 'simpleTable' }), + option({ key: 'grid' }), + option({ key: 'linkedGrid' }), + option({ key: 'chart' }), + option({ key: 'linkedChart' }), + option({ key: 'outline' }), + option({ key: 'askAIAnything' }), + option({ key: 'video' }), + option({ key: 'pdf' }), + option({ key: 'dateOrReminder', keywords: ['date', 'reminder'] }), + ]; + + it('hides desktop-excluded commands inside simple table cells', () => { + const result = filterSlashMenuOptions(options, { isInsideSimpleTableCell: true }); + + expect(result.map((item) => item.key)).toEqual(['text', 'video', 'pdf', 'dateOrReminder']); + }); + + it('hides desktop-excluded commands inside AI meeting blocks', () => { + const result = filterSlashMenuOptions(options, { isInsideAIMeeting: true }); + + expect(result.map((item) => item.key)).toEqual(['text', 'dateOrReminder']); + }); + + it('applies search after context exclusions', () => { + const result = filterSlashMenuOptions(options, { + searchText: 'reminder', + isInsideSimpleTableCell: true, + }); + + expect(result.map((item) => item.key)).toEqual(['dateOrReminder']); + }); + }); + + describe('groupSlashMenuOptions', () => { + it('keeps desktop-style group ordering and omits empty groups', () => { + const result = groupSlashMenuOptions([ + option({ key: 'grid', group: SlashMenuGroupKey.Database }), + option({ key: 'text', group: SlashMenuGroupKey.BasicBlocks }), + option({ key: 'emoji', group: SlashMenuGroupKey.Inline }), + ]); + + expect(result.map((group) => group.group)).toEqual([ + SlashMenuGroupKey.BasicBlocks, + SlashMenuGroupKey.Database, + SlashMenuGroupKey.Inline, + ]); + }); + }); +}); diff --git a/src/components/editor/components/panels/slash-panel/slash-menu-options.ts b/src/components/editor/components/panels/slash-panel/slash-menu-options.ts new file mode 100644 index 00000000..f41c327c --- /dev/null +++ b/src/components/editor/components/panels/slash-panel/slash-menu-options.ts @@ -0,0 +1,92 @@ +export enum SlashMenuGroupKey { + AppFlowyAI = 'appflowyAI', + BasicBlocks = 'basicBlocks', + Media = 'media', + Database = 'database', + AdvancedBlocks = 'advancedBlocks', + Inline = 'inline', +} + +export interface SlashMenuOptionBase { + key: string; + label: string; + keywords: string[]; + aliases?: string[]; + shortcut?: string; + disabled?: boolean; + group: SlashMenuGroupKey; +} + +export interface SlashMenuFilterContext { + searchText?: string; + isInsideSimpleTableCell?: boolean; + isInsideAIMeeting?: boolean; +} + +export const SLASH_MENU_GROUP_ORDER = [ + SlashMenuGroupKey.AppFlowyAI, + SlashMenuGroupKey.BasicBlocks, + SlashMenuGroupKey.Media, + SlashMenuGroupKey.Database, + SlashMenuGroupKey.AdvancedBlocks, + SlashMenuGroupKey.Inline, +] as const; + +export const SIMPLE_TABLE_EXCLUDED_OPTION_KEYS = new Set([ + 'askAIAnything', + 'continueWriting', + 'simpleTable', + 'grid', + 'linkedGrid', + 'board', + 'linkedKanban', + 'calendar', + 'linkedCalendar', + 'chart', + 'linkedChart', + 'outline', +]); + +export const AI_MEETING_EXCLUDED_OPTION_KEYS = new Set([ + 'askAIAnything', + 'continueWriting', + 'simpleTable', + 'grid', + 'linkedGrid', + 'board', + 'linkedKanban', + 'calendar', + 'linkedCalendar', + 'chart', + 'linkedChart', + 'outline', + 'video', + 'pdf', +]); + +export function matchesSlashMenuOption(option: SlashMenuOptionBase, searchText?: string) { + const query = searchText?.trim().toLowerCase(); + + if (!query) return true; + + return [option.key, option.label, option.shortcut, ...(option.keywords ?? []), ...(option.aliases ?? [])].some( + (value) => value?.toLowerCase().includes(query) + ); +} + +export function filterSlashMenuOptions(options: T[], context: SlashMenuFilterContext) { + return options.filter((option) => { + if (option.disabled) return false; + if (context.isInsideSimpleTableCell && SIMPLE_TABLE_EXCLUDED_OPTION_KEYS.has(option.key)) return false; + if (context.isInsideAIMeeting && AI_MEETING_EXCLUDED_OPTION_KEYS.has(option.key)) return false; + + return matchesSlashMenuOption(option, context.searchText); + }); +} + +export function groupSlashMenuOptions(options: T[]) { + return SLASH_MENU_GROUP_ORDER.map((group) => ({ + group, + options: options.filter((option) => option.group === group), + })).filter(({ options }) => options.length > 0); +}