Add @mention support in AI Chat input (#17943)
## Summary
- Add `@mention` support to the AI Chat text input by replacing the
plain textarea with a minimal Tiptap editor and building a shared
`mention` module with reusable Tiptap extensions (`MentionTag`,
`MentionSuggestion`), search hook (`useMentionSearch`), and suggestion
menu — all shared with the existing BlockNote-based Notes mentions to
avoid code duplication
- Mentions are serialized as
`[[record:objectName:recordId:displayName]]` markdown (the format
already understood by the backend and rendered in chat messages), and
displayed using the existing `RecordLink` chip component for visual
consistency
- Fix images in chat messages overflowing their container by
constraining to `max-width: 100%`
- Fix web_search tool display showing literal `{query}` instead of the
actual query (ICU single-quote escaping issue in Lingui `t` tagged
templates)
## Test plan
- [ ] Open AI Chat, type `@` and verify the suggestion menu appears with
searchable records
- [ ] Select a mention from the dropdown (via click or keyboard
Enter/ArrowUp/Down) and verify the record chip renders inline
- [ ] Send a message containing a mention and verify it appears
correctly in the conversation as a clickable `RecordLink`
- [ ] Verify Enter sends the message when the suggestion menu is closed,
and selects a mention when the menu is open
- [ ] Verify images in AI chat responses are constrained to the
container width
- [ ] Verify the web_search tool step shows the actual search query
(e.g. "Searched the web for Salesforce") instead of `{query}`
- [ ] Verify Notes @mentions still work as before
Made with [Cursor](https://cursor.com)
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { filterSuggestionItems } from '@blocknote/core';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import { SuggestionMenuController } from '@blocknote/react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type ClipboardEvent } from 'react';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { getSlashMenu } from '@/blocknote-editor/utils/getSlashMenu';
|
||||
import { CustomMentionMenu } from '@/blocknote-editor/components/CustomMentionMenu';
|
||||
import { CustomSideMenu } from '@/blocknote-editor/components/CustomSideMenu';
|
||||
import {
|
||||
CustomSlashMenu,
|
||||
type SuggestionItem,
|
||||
} from '@/blocknote-editor/components/CustomSlashMenu';
|
||||
import { useMentionMenu } from '@/mention/hooks/useMentionMenu';
|
||||
|
||||
interface BlockEditorProps {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
onChange?: () => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line twenty/no-hardcoded-colors
|
||||
const StyledEditor = styled.div`
|
||||
width: 100%;
|
||||
|
||||
& .editor {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
min-height: 400px;
|
||||
}
|
||||
& .editor [class^='_inlineContent']:before {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-style: normal !important;
|
||||
}
|
||||
& .editor .bn-inline-content:has(> .ProseMirror-trailingBreak):before {
|
||||
font-style: normal;
|
||||
}
|
||||
& .mantine-ActionIcon-icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background: transparent;
|
||||
}
|
||||
& .bn-container .bn-drag-handle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
& .bn-block-content[data-content-type='checkListItem'] > div > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
& .bn-drag-handle-menu {
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
backdrop-filter: ${({ theme }) => theme.blur.medium};
|
||||
box-shadow:
|
||||
0px 2px 4px rgba(0, 0, 0, 0.04),
|
||||
2px 4px 16px rgba(0, 0, 0, 0.12);
|
||||
min-width: 160px;
|
||||
min-height: 96px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
& .bn-editor {
|
||||
padding-inline: 0px;
|
||||
}
|
||||
|
||||
& .bn-inline-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& .bn-container .bn-suggestion-menu-item:hover {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
& .bn-suggestion-menu {
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
backdrop-filter: ${({ theme }) => theme.blur.medium};
|
||||
}
|
||||
|
||||
& .mantine-Menu-item {
|
||||
background-color: transparent;
|
||||
min-width: 152px;
|
||||
min-height: 32px;
|
||||
|
||||
font-style: normal;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
& .mantine-ActionIcon-root:hover {
|
||||
box-shadow:
|
||||
0px 0px 4px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 4px rgba(0, 0, 0, 0.04);
|
||||
background: ${({ theme }) => theme.background.transparent.primary};
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
}
|
||||
& .bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
& .bn-mantine .bn-side-menu > [draggable='true'] {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
& .bn-color-picker-dropdown {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
& .bn-inline-content a {
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
}
|
||||
|
||||
& .bn-inline-content code {
|
||||
font-family: monospace;
|
||||
color: ${({ theme }) => theme.font.color.danger};
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${({ theme }) => theme.font.color.extraLight};
|
||||
font-size: 0.9rem;
|
||||
background-color: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
export const BlockEditor = ({
|
||||
editor,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChange,
|
||||
onPaste,
|
||||
readonly,
|
||||
}: BlockEditorProps) => {
|
||||
const theme = useTheme();
|
||||
const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark';
|
||||
const getMentionItems = useMentionMenu(editor);
|
||||
|
||||
const handleFocus = () => {
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
onChange?.();
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
onPaste?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledEditor>
|
||||
<BlockNoteView
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onChange={handleChange}
|
||||
editor={editor}
|
||||
theme={blockNoteTheme}
|
||||
slashMenu={false}
|
||||
sideMenu={false}
|
||||
editable={!readonly}
|
||||
>
|
||||
<CustomSideMenu editor={editor} />
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="/"
|
||||
getItems={async (query) =>
|
||||
filterSuggestionItems<SuggestionItem>(getSlashMenu(editor), query)
|
||||
}
|
||||
suggestionMenuComponent={CustomSlashMenu}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="@"
|
||||
getItems={async (query) => getMentionItems(query)}
|
||||
suggestionMenuComponent={CustomMentionMenu}
|
||||
/>
|
||||
</BlockNoteView>
|
||||
</StyledEditor>
|
||||
);
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { isSlashMenuOpenComponentState } from '@/blocknote-editor/states/isSlashMenuOpenComponentState';
|
||||
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
|
||||
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
export type BlockEditorDropdownFocusEffectProps = {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
};
|
||||
|
||||
export const BlockEditorDropdownFocusEffect = ({
|
||||
editor,
|
||||
}: BlockEditorDropdownFocusEffectProps) => {
|
||||
const isSlashMenuOpenState = useRecoilComponentCallbackState(
|
||||
isSlashMenuOpenComponentState,
|
||||
);
|
||||
|
||||
const { setActiveDropdownFocusIdAndMemorizePrevious } =
|
||||
useSetActiveDropdownFocusIdAndMemorizePrevious();
|
||||
|
||||
const { goBackToPreviousDropdownFocusId } =
|
||||
useGoBackToPreviousDropdownFocusId();
|
||||
|
||||
const updateCallBack = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
(event: any) => {
|
||||
// TODO: This triggers before the onClick event of the slash menu item, so the click outside of the editor dropdown is triggered and everything closes.
|
||||
// This is due to useRecoilCallback being executed before the onClick event of the slash menu item.
|
||||
const eventWantsToOpen = event.show === true;
|
||||
|
||||
const isAlreadyOpen = snapshot
|
||||
.getLoadable(isSlashMenuOpenState)
|
||||
.getValue();
|
||||
|
||||
const shouldOpen = eventWantsToOpen && !isAlreadyOpen;
|
||||
|
||||
if (shouldOpen) {
|
||||
setActiveDropdownFocusIdAndMemorizePrevious('custom-slash-menu');
|
||||
set(isSlashMenuOpenState, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventWantsToClose = event.show === false;
|
||||
|
||||
const isAlreadyClosed = !isAlreadyOpen;
|
||||
|
||||
const shouldClose = eventWantsToClose && !isAlreadyClosed;
|
||||
|
||||
if (shouldClose) {
|
||||
goBackToPreviousDropdownFocusId();
|
||||
set(isSlashMenuOpenState, false);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
isSlashMenuOpenState,
|
||||
setActiveDropdownFocusIdAndMemorizePrevious,
|
||||
goBackToPreviousDropdownFocusId,
|
||||
],
|
||||
);
|
||||
|
||||
editor.suggestionMenus.on('update /', updateCallBack);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
|
||||
import { useComponentsContext } from '@blocknote/react';
|
||||
|
||||
type CustomAddBlockItemProps = {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
children: React.ReactNode; // Adding the children prop
|
||||
};
|
||||
|
||||
type ContentItem = {
|
||||
type: string;
|
||||
text: string;
|
||||
styles: any;
|
||||
};
|
||||
export const CustomAddBlockItem = ({
|
||||
editor,
|
||||
children,
|
||||
}: CustomAddBlockItemProps) => {
|
||||
const Components = useComponentsContext();
|
||||
|
||||
if (!Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const blockIdentifier = editor.getTextCursorPosition().block;
|
||||
const currentBlockContent = blockIdentifier?.content as
|
||||
| Array<ContentItem>
|
||||
| undefined;
|
||||
|
||||
const [firstElement] = currentBlockContent || [];
|
||||
|
||||
if (firstElement === undefined) {
|
||||
editor.openSuggestionMenu('/');
|
||||
} else {
|
||||
editor.openSuggestionMenu('/');
|
||||
editor.sideMenu.unfreezeMenu();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Components.Generic.Menu.Item onClick={handleClick}>
|
||||
{children}
|
||||
</Components.Generic.Menu.Item>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { autoUpdate, useFloating } from '@floating-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
|
||||
import { MentionMenuListItem } from '@/mention/components/MentionMenuListItem';
|
||||
import {
|
||||
type CustomMentionMenuProps,
|
||||
type MentionItem,
|
||||
} from '@/blocknote-editor/types/types';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type { MentionItem };
|
||||
|
||||
const MenuPixelWidth = 240;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
export const CustomMentionMenu = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
onItemClick,
|
||||
}: CustomMentionMenuProps) => {
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const handleContainerClick = (e: ReactMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
if (!isDefined(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(item) =>
|
||||
isDefined(item.recordId) &&
|
||||
isDefined(item.objectNameSingular) &&
|
||||
isDefined(item.objectMetadataId),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<>
|
||||
{createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
<OverlayContainer
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
data-click-outside-id={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<DropdownContent widthInPixels={MenuPixelWidth}>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{filteredItems.map((item, index) => (
|
||||
<MentionMenuListItem
|
||||
key={item.recordId!}
|
||||
recordId={item.recordId!}
|
||||
objectNameSingular={item.objectNameSingular!}
|
||||
label={item.label ?? item.title}
|
||||
imageUrl={item.imageUrl ?? ''}
|
||||
objectLabelSingular={item.objectLabelSingular ?? ''}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { CustomAddBlockItem } from '@/blocknote-editor/components/CustomAddBlockItem';
|
||||
import { CustomSideMenuOptions } from '@/blocknote-editor/components/CustomSideMenuOptions';
|
||||
import {
|
||||
BlockColorsItem,
|
||||
DragHandleButton,
|
||||
DragHandleMenu,
|
||||
RemoveBlockItem,
|
||||
SideMenu,
|
||||
SideMenuController,
|
||||
} from '@blocknote/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
|
||||
|
||||
type CustomSideMenuProps = {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
};
|
||||
|
||||
const StyledDivToCreateGap = styled.div`
|
||||
width: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const CustomSideMenu = ({ editor }: CustomSideMenuProps) => {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<SideMenuController
|
||||
sideMenu={(props) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<SideMenu {...props}>
|
||||
<DragHandleButton
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
dragHandleMenu={(props) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<DragHandleMenu {...props}>
|
||||
<CustomAddBlockItem editor={editor}>
|
||||
<CustomSideMenuOptions
|
||||
LeftIcon={IconPlus}
|
||||
text={t`Add Block`}
|
||||
Variant="normal"
|
||||
/>
|
||||
</CustomAddBlockItem>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<BlockColorsItem {...props}>
|
||||
<CustomSideMenuOptions
|
||||
LeftIcon={IconColorSwatch}
|
||||
text={t`Change Color`}
|
||||
Variant="normal"
|
||||
/>
|
||||
</BlockColorsItem>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<RemoveBlockItem {...props}>
|
||||
{' '}
|
||||
<CustomSideMenuOptions
|
||||
LeftIcon={IconTrash}
|
||||
text={t`Delete`}
|
||||
Variant="danger"
|
||||
/>
|
||||
</RemoveBlockItem>
|
||||
</DragHandleMenu>
|
||||
)}
|
||||
/>
|
||||
<StyledDivToCreateGap />
|
||||
</SideMenu>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type IconComponent } from 'twenty-ui/display';
|
||||
|
||||
const StyledContainer = styled.div<{ Variant: Variants }>`
|
||||
color: ${({ theme, Variant }) =>
|
||||
Variant === 'danger' ? theme.color.red : 'inherit'};
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTextContainer = styled.div``;
|
||||
|
||||
type CustomSideMenuOptionsProps = {
|
||||
LeftIcon: IconComponent; // Any valid React node (e.g., a component)
|
||||
Variant: Variants;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Variants = 'normal' | 'danger';
|
||||
|
||||
export const CustomSideMenuOptions = ({
|
||||
LeftIcon,
|
||||
Variant,
|
||||
text,
|
||||
}: CustomSideMenuOptionsProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledContainer Variant={Variant}>
|
||||
<LeftIcon
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
></LeftIcon>
|
||||
<StyledTextContainer>{text}</StyledTextContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { CustomSlashMenuListItem } from '@/blocknote-editor/components/CustomSlashMenuListItem';
|
||||
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect';
|
||||
import type {
|
||||
CustomSlashMenuProps,
|
||||
SuggestionItem,
|
||||
} from '@/blocknote-editor/types/types';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
|
||||
export type { SuggestionItem };
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
export const CustomSlashMenu = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
}: CustomSlashMenuProps) => {
|
||||
const editor = useBlockNoteEditor();
|
||||
|
||||
const currentBlock = editor?.getTextCursorPosition()?.block;
|
||||
const blockType = currentBlock?.type;
|
||||
const headingLevel =
|
||||
blockType === 'heading' ? (currentBlock?.props?.level as number) : null;
|
||||
|
||||
const getOffsetValue = (placement: string) => {
|
||||
if (!placement.startsWith('top')) return 0;
|
||||
|
||||
switch (headingLevel) {
|
||||
case 1:
|
||||
return 65;
|
||||
case 2:
|
||||
return 50;
|
||||
case 3:
|
||||
return 45;
|
||||
default:
|
||||
return 40;
|
||||
}
|
||||
};
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [flip(), offset(({ placement }) => getOffsetValue(placement))],
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<CustomSlashMenuSelectedIndexSyncEffect
|
||||
items={items}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
<>
|
||||
{createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<OverlayContainer
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<SelectableList
|
||||
focusId={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
selectableListInstanceId={SLASH_MENU_LIST_ID}
|
||||
selectableItemIdArray={items.map((item) => item.title)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<CustomSlashMenuListItem key={item.title} item={item} />
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/types/types';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
|
||||
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
|
||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
|
||||
export type CustomSlashMenuListItemProps = {
|
||||
item: SuggestionItem;
|
||||
};
|
||||
|
||||
export const CustomSlashMenuListItem = ({
|
||||
item,
|
||||
}: CustomSlashMenuListItemProps) => {
|
||||
const { resetSelectedItem } = useSelectableList(SLASH_MENU_LIST_ID);
|
||||
|
||||
const isSelectedItem = useRecoilComponentFamilyValue(
|
||||
isSelectedItemIdComponentFamilySelector,
|
||||
item.title,
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
resetSelectedItem();
|
||||
item.onItemClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectableListItem itemId={item.title} onEnter={handleClick}>
|
||||
<MenuItemSuggestion
|
||||
selected={isSelectedItem}
|
||||
onClick={handleClick}
|
||||
LeftIcon={item.Icon}
|
||||
text={item.title}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/types/types';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type CustomSlashMenuSelectedIndexSyncEffectProps = {
|
||||
items: SuggestionItem[];
|
||||
selectedIndex: number | undefined;
|
||||
};
|
||||
|
||||
export const CustomSlashMenuSelectedIndexSyncEffect = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
}: CustomSlashMenuSelectedIndexSyncEffectProps) => {
|
||||
const { setSelectedItemId } = useSelectableList(SLASH_MENU_LIST_ID);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(selectedIndex)) return;
|
||||
|
||||
const selectedItem = items[selectedIndex];
|
||||
|
||||
if (isDefined(selectedItem)) {
|
||||
setSelectedItemId(selectedItem.title);
|
||||
}
|
||||
}, [items, selectedIndex, setSelectedItemId]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
Reference in New Issue
Block a user