Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { createReactInlineContentSpec } from '@blocknote/react';
import styled from '@emotion/styled';

const StyledRecordChip = styled(RecordChip)`
height: auto;
margin: 0;
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
`;

const MentionInlineContentRenderer = ({
recordId,
objectNameSingular,
}: {
recordId: string;
objectNameSingular: string;
}) => {
const { record } = useFindOneRecord({
objectNameSingular,
objectRecordId: recordId,
skip: !objectNameSingular || !recordId,
});

if (!record || !objectNameSingular) {
return <span>@mention</span>;
}

return (
<StyledRecordChip
objectNameSingular={objectNameSingular}
record={record}
forceDisableClick={false}
/>
);
};

export const MentionInlineContent = createReactInlineContentSpec(
{
type: 'mention' as const,
propSchema: {
recordId: {
default: '' as const,
},
objectNameSingular: {
default: '' as const,
},
},
content: 'none',
},
{
render: (props) => {
const { recordId, objectNameSingular } = props.inlineContent.props;

return (
<MentionInlineContentRenderer
recordId={recordId}
objectNameSingular={objectNameSingular}
/>
);
},
},
);
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core';
import {
BlockNoteSchema,
defaultBlockSpecs,
defaultInlineContentSpecs,
} from '@blocknote/core';

import { FileBlock } from '../components/FileBlock';
import { MentionInlineContent } from '../components/MentionInlineContent';

export const BLOCK_SCHEMA = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
file: FileBlock,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
mention: MentionInlineContent,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { type CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId';
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramCreateStepNodeClickOutsideId';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useTheme } from '@emotion/react';

import styled from '@emotion/styled';
Expand Down Expand Up @@ -77,6 +78,7 @@ export const CommandMenuOpenContainer = ({
LINK_CHIP_CLICK_OUTSIDE_ID,
RECORD_CHIP_CLICK_OUTSIDE_ID,
SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID,
WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID,
WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID =
'mention-menu-dropdown-click-outside-id';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MENTION_MENU_LIST_ID = 'mention-menu-list-id';
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { type ClipboardEvent } from 'react';

import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu';
import { CustomMentionMenu } from '@/ui/input/editor/components/CustomMentionMenu';
import { CustomSideMenu } from '@/ui/input/editor/components/CustomSideMenu';
import {
CustomSlashMenu,
type SuggestionItem,
} from '@/ui/input/editor/components/CustomSlashMenu';
import { useMentionMenu } from '@/ui/input/editor/hooks/useMentionMenu';

interface BlockEditorProps {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
Expand Down Expand Up @@ -141,6 +143,7 @@ export const BlockEditor = ({
}: BlockEditorProps) => {
const theme = useTheme();
const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark';
const getMentionItems = useMentionMenu(editor);

const handleFocus = () => {
onFocus?.();
Expand Down Expand Up @@ -179,6 +182,11 @@ export const BlockEditor = ({
}
suggestionMenuComponent={CustomSlashMenu}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={async (query) => getMentionItems(query)}
suggestionMenuComponent={CustomMentionMenu}
/>
</BlockNoteView>
</StyledEditor>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import styled from '@emotion/styled';
import { autoUpdate, useFloating } from '@floating-ui/react';
import { motion } from 'framer-motion';
import { useEffect, type MouseEvent as ReactMouseEvent } from 'react';
import { createPortal } from 'react-dom';

import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { CustomMentionMenuListItem } from '@/ui/input/editor/components/CustomMentionMenuListItem';
import {
type CustomMentionMenuProps,
type MentionItem,
} from '@/ui/input/editor/components/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';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
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,
}: CustomMentionMenuProps) => {
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
});

const { setSelectedItemId } = useSelectableList(MENTION_MENU_LIST_ID);

useEffect(() => {
if (!isDefined(selectedIndex) || !isDefined(items)) return;

const selectedItem = items[selectedIndex];

if (isDefined(selectedItem) && isDefined(selectedItem.recordId)) {
setSelectedItemId(selectedItem.recordId);
}
}, [items, selectedIndex, setSelectedItemId]);

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),
);

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>
<SelectableList
focusId={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
selectableListInstanceId={MENTION_MENU_LIST_ID}
selectableItemIdArray={filteredItems.map(
(item) => item.recordId!,
)}
>
{filteredItems.map((item) => (
<CustomMentionMenuListItem
key={item.recordId!}
recordId={item.recordId!}
onClick={() => item.onItemClick()}
objectNameSingular={item.objectNameSingular!}
/>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>,
document.body,
)}
</>
</StyledContainer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { type MouseEvent } from 'react';

import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
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 { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { MenuItemSuggestion } from 'twenty-ui/navigation';

type CustomMentionMenuListItemProps = {
recordId: string;
onClick: () => void;
objectNameSingular: string;
};

export const CustomMentionMenuListItem = ({
recordId,
onClick,
objectNameSingular,
}: CustomMentionMenuListItemProps) => {
const { resetSelectedItem } = useSelectableList(MENTION_MENU_LIST_ID);

const isSelectedItem = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
recordId,
);

// Get the search record from the store (same pattern as SingleRecordPickerMenuItem)
const searchRecord = useRecoilValue(searchRecordStoreFamilyState(recordId));

const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });

const handleClick = (event?: MouseEvent) => {
event?.preventDefault();
event?.stopPropagation();
resetSelectedItem();
onClick();
};

if (!isDefined(searchRecord)) {
return null;
}

return (
<SelectableListItem itemId={recordId} onEnter={handleClick}>
<MenuItemSuggestion
selected={isSelectedItem}
onClick={handleClick}
text={`${searchRecord.label}`}
contextualText={objectMetadataItem.labelSingular}
contextualTextPosition="left"
LeftIcon={() => (
<Avatar
placeholder={searchRecord.label}
placeholderColorSeed={recordId}
avatarUrl={searchRecord.imageUrl}
type={getAvatarType(objectNameSingular) ?? 'rounded'}
size="sm"
/>
)}
/>
</SelectableListItem>
);
};
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { SuggestionMenuProps } from '@blocknote/react';
import type {
DefaultReactSuggestionItem,
SuggestionMenuProps,
} from '@blocknote/react';
import { type IconComponent } from 'twenty-ui/display';

export type SuggestionItem = {
title: string;
onItemClick: () => void;
export type SuggestionItem = DefaultReactSuggestionItem & {
aliases?: string[];
Icon?: IconComponent;
};

export type CustomSlashMenuProps = SuggestionMenuProps<SuggestionItem>;

export type MentionItem = DefaultReactSuggestionItem & {
recordId?: string;
objectNameSingular?: string;
};

export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;
Loading