mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Actile: improve, multi-provider, label attachments
This commit is contained in:
@@ -49,8 +49,9 @@ import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ActileItem } from './actile/ActileProvider';
|
||||
import { providerAttachmentLabels } from './actile/providerAttachmentLabels';
|
||||
import { providerCommands } from './actile/providerCommands';
|
||||
import { providerStarredMessage, StarredMessageItem } from './actile/providerStarredMessage';
|
||||
import { providerStarredMessages, StarredMessageItem } from './actile/providerStarredMessage';
|
||||
import { useActileManager } from './actile/useActileManager';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
@@ -457,24 +458,21 @@ export function Composer(props: {
|
||||
|
||||
// Actiles
|
||||
|
||||
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
|
||||
const onActileCommandPaste = React.useCallback(({ label }: ActileItem, searchPrefix: string) => {
|
||||
if (composerTextAreaRef.current) {
|
||||
const textArea = composerTextAreaRef.current;
|
||||
const currentText = textArea.value;
|
||||
const cursorPos = textArea.selectionStart;
|
||||
|
||||
// Find the position where the command starts
|
||||
const commandStart = currentText.lastIndexOf('/', cursorPos);
|
||||
const commandStart = currentText.lastIndexOf(searchPrefix, cursorPos);
|
||||
|
||||
// Construct the new text with the autocompleted command
|
||||
const newText = currentText.substring(0, commandStart) + item.label + ' ' + currentText.substring(cursorPos);
|
||||
setComposeText((prevText) => prevText.substring(0, commandStart) + label + ' ' + prevText.substring(cursorPos));
|
||||
|
||||
// Update the text area with the new text
|
||||
setComposeText(newText);
|
||||
|
||||
// Move the cursor to the end of the autocompleted command
|
||||
const newCursorPos = commandStart + item.label.length + 1;
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
// Schedule setting the cursor position after the state update
|
||||
const newCursorPos = commandStart + label.length + 1;
|
||||
setTimeout(() => composerTextAreaRef.current?.setSelectionRange(newCursorPos, newCursorPos), 0);
|
||||
}
|
||||
}, [composerTextAreaRef, setComposeText]);
|
||||
|
||||
@@ -493,9 +491,12 @@ export function Composer(props: {
|
||||
}
|
||||
}, [attachAppendEgoFragments]);
|
||||
|
||||
const actileProviders = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)];
|
||||
}, [onActileCommandPaste, onActileEmbedMessage]);
|
||||
|
||||
const actileProviders = React.useMemo(() => [
|
||||
providerAttachmentLabels(conversationOverlayStore, onActileCommandPaste),
|
||||
providerCommands(onActileCommandPaste),
|
||||
providerStarredMessages(onActileEmbedMessage),
|
||||
], [conversationOverlayStore, onActileCommandPaste, onActileEmbedMessage]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, composerTextAreaRef);
|
||||
|
||||
|
||||
@@ -4,38 +4,44 @@ import { Box, ListItem, ListItemButton, ListItemDecorator, Sheet, Typography } f
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
|
||||
import type { ActileItem } from './ActileProvider';
|
||||
|
||||
import type { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
export function ActilePopup(props: {
|
||||
anchorEl: HTMLElement | null,
|
||||
onClose: () => void,
|
||||
title?: string,
|
||||
items: ActileItem[],
|
||||
activeItemIndex: number | undefined,
|
||||
itemsByProvider: { provider: ActileProvider, items: ActileItem[] }[],
|
||||
activeItemIndex: number,
|
||||
activePrefixLength: number,
|
||||
onItemClick: (item: ActileItem) => void,
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
|
||||
const hasAnyIcon = props.items.some(item => !!item.Icon);
|
||||
// We need to keep track of the overall item index to correctly match with activeItemIndex
|
||||
const itemIndices = React.useMemo(() => {
|
||||
const indices: { providerKey: string, itemKey: string, isActive: boolean }[] = [];
|
||||
let indexCounter = 0;
|
||||
props.itemsByProvider.forEach(({ provider, items }) => {
|
||||
items.forEach((item) => {
|
||||
indices.push({
|
||||
providerKey: provider.key,
|
||||
itemKey: item.key,
|
||||
isActive: indexCounter === props.activeItemIndex,
|
||||
});
|
||||
indexCounter += 1;
|
||||
});
|
||||
});
|
||||
return indices;
|
||||
}, [props.itemsByProvider, props.activeItemIndex]);
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
noTopPadding noBottomPadding
|
||||
open anchorEl={props.anchorEl} onClose={props.onClose}
|
||||
noTopPadding
|
||||
noBottomPadding
|
||||
maxHeightGapPx={320}
|
||||
sx={{ minWidth: 320 }}
|
||||
>
|
||||
|
||||
{!!props.title && (
|
||||
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
|
||||
<Typography level='title-sm'>
|
||||
{props.title}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{!props.items.length && (
|
||||
{!props.itemsByProvider.length && (
|
||||
<ListItem variant='soft' color='warning'>
|
||||
<Typography level='body-md'>
|
||||
No matching command
|
||||
@@ -43,46 +49,65 @@ export function ActilePopup(props: {
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{props.items.map((item, idx) => {
|
||||
const isActive = idx === props.activeItemIndex;
|
||||
const labelBold = item.label.slice(0, props.activePrefixLength);
|
||||
const labelNormal = item.label.slice(props.activePrefixLength);
|
||||
return (
|
||||
<ListItem
|
||||
key={item.key}
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
color={isActive ? 'primary' : undefined}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
>
|
||||
<ListItemButton color='primary'>
|
||||
{hasAnyIcon && (
|
||||
<ListItemDecorator>
|
||||
{item.Icon ? <item.Icon /> : null}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
<Box>
|
||||
{props.itemsByProvider.map(({ provider, items }) => (
|
||||
<React.Fragment key={provider.key}>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
|
||||
<span style={{ textDecoration: 'underline' }}><b>{labelBold}</b></span>{labelNormal}
|
||||
</Typography>
|
||||
{item.argument && <Typography level='body-sm'>
|
||||
{item.argument}
|
||||
{/* Provider Label */}
|
||||
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
|
||||
<Typography level='title-sm'>
|
||||
{provider.label}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
|
||||
{/* Items */}
|
||||
{items.map((item) => {
|
||||
const index = itemIndices.findIndex(idx => idx.providerKey === provider.key && idx.itemKey === item.key);
|
||||
const isActive = itemIndices[index]?.isActive;
|
||||
|
||||
const labelBold = item.label.slice(0, props.activePrefixLength);
|
||||
const labelNormal = item.label.slice(props.activePrefixLength);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`${provider.key}-${item.key}`}
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
color={isActive ? 'primary' : undefined}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
>
|
||||
<ListItemButton color='primary'>
|
||||
{item.Icon && (
|
||||
<ListItemDecorator>
|
||||
<item.Icon />
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
|
||||
{/* Item*/}
|
||||
<Box>
|
||||
|
||||
{/* Item main text */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
|
||||
<span style={{ textDecoration: 'underline' }}><b>{labelBold}</b></span>{labelNormal}
|
||||
</Typography>
|
||||
{item.argument && <Typography level='body-sm'>
|
||||
{item.argument}
|
||||
</Typography>}
|
||||
</Box>
|
||||
|
||||
{/* Item description */}
|
||||
{!!item.description && <Typography level='body-xs'>
|
||||
{item.description}
|
||||
</Typography>}
|
||||
|
||||
</Box>
|
||||
|
||||
{!!item.description && <Typography level='body-xs'>
|
||||
{item.description}
|
||||
</Typography>}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{props.children}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
export interface ActileProvider<TItem extends ActileItem = ActileItem> {
|
||||
|
||||
// Unique key for the provider
|
||||
readonly key: 'pcmd' | 'pstrmsg' | 'pattlbl';
|
||||
|
||||
// Label for display
|
||||
get label(): string;
|
||||
|
||||
// Interface for the provider
|
||||
fastCheckTriggerText: (trailingText: string) => boolean;
|
||||
fetchItems: () => ActileProviderItems<TItem>;
|
||||
onItemSelect: (item: ActileItem) => void;
|
||||
|
||||
}
|
||||
|
||||
export type ActileProviderItems<TItem extends ActileItem = ActileItem> = Promise<{ searchPrefix: string, items: TItem[] }>;
|
||||
|
||||
export interface ActileItem {
|
||||
key: string;
|
||||
providerKey: ActileProvider['key'];
|
||||
label: string;
|
||||
argument?: string;
|
||||
description?: string;
|
||||
Icon?: FunctionComponent;
|
||||
}
|
||||
|
||||
export interface ActileProvider<TItem extends ActileItem = ActileItem> {
|
||||
fastCheckTriggerText: (trailingText: string) => boolean;
|
||||
fetchItems: () => Promise<{ title: string, searchPrefix: string, items: TItem[] }>;
|
||||
onItemSelect: (item: ActileItem) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider';
|
||||
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-perchat-attachment-drafts_slice';
|
||||
|
||||
export interface AttachmentLabelItem extends ActileItem {
|
||||
// nothing to do do here, this is really just a label
|
||||
}
|
||||
|
||||
export const providerAttachmentLabels = (
|
||||
attachmentsStoreApi: AttachmentDraftsStoreApi | null,
|
||||
onLabelSelect: (item: ActileItem, searchPrefix: string) => void,
|
||||
): ActileProvider<AttachmentLabelItem> => ({
|
||||
|
||||
key: 'pattlbl',
|
||||
|
||||
get label() {
|
||||
return 'Attachment Labels';
|
||||
},
|
||||
|
||||
// Uses '@' as the trigger
|
||||
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
|
||||
|
||||
fetchItems: async (): ActileProviderItems<AttachmentLabelItem> => ({
|
||||
searchPrefix: '',
|
||||
items: attachmentsStoreApi?.getState()?.attachmentDrafts.map(draft => ({
|
||||
key: draft.id,
|
||||
providerKey: 'pattlbl',
|
||||
label: draft.label,
|
||||
argument: undefined,
|
||||
description: 'name',
|
||||
Icon: undefined,
|
||||
} as AttachmentLabelItem)) ?? [],
|
||||
}),
|
||||
|
||||
onItemSelect: item => onLabelSelect(item as AttachmentLabelItem, '@'),
|
||||
|
||||
});
|
||||
@@ -1,26 +1,35 @@
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
import { findAllChatCommands } from '../../../commands/commands.registry';
|
||||
|
||||
import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider';
|
||||
|
||||
export function providerCommands(onCommandSelect: (item: ActileItem) => void): ActileProvider {
|
||||
return {
|
||||
|
||||
export const providerCommands = (
|
||||
onCommandSelect: (item: ActileItem, searchPrefix: string) => void,
|
||||
): ActileProvider => ({
|
||||
|
||||
key: 'pcmd',
|
||||
|
||||
get label() {
|
||||
return 'Chat Commands';
|
||||
},
|
||||
|
||||
fastCheckTriggerText: (trailingText: string) => {
|
||||
// only the literal '/' is a trigger
|
||||
fastCheckTriggerText: (trailingText: string) => trailingText === '/',
|
||||
return trailingText === '/';
|
||||
},
|
||||
|
||||
// no real need to be async
|
||||
fetchItems: async () => ({
|
||||
title: 'Chat Commands',
|
||||
searchPrefix: '/',
|
||||
items: findAllChatCommands().map((cmd) => ({
|
||||
key: cmd.primary,
|
||||
label: cmd.primary,
|
||||
argument: cmd.arguments?.join(' ') ?? undefined,
|
||||
description: cmd.description,
|
||||
Icon: cmd.Icon,
|
||||
} satisfies ActileItem)),
|
||||
}),
|
||||
fetchItems: async (): ActileProviderItems => ({
|
||||
searchPrefix: '/',
|
||||
items: findAllChatCommands().map((cmd) => ({
|
||||
key: cmd.primary,
|
||||
providerKey: 'pcmd',
|
||||
label: cmd.primary,
|
||||
argument: cmd.arguments?.join(' ') ?? undefined,
|
||||
description: cmd.description,
|
||||
Icon: cmd.Icon,
|
||||
} satisfies ActileItem)),
|
||||
}),
|
||||
|
||||
onItemSelect: onCommandSelect,
|
||||
};
|
||||
}
|
||||
onItemSelect: (item) => onCommandSelect(item as ActileItem, '/'),
|
||||
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.co
|
||||
import { MESSAGE_FLAG_STARRED, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
import type { ActileItem, ActileProvider, ActileProviderItems } from './ActileProvider';
|
||||
|
||||
|
||||
export interface StarredMessageItem extends ActileItem {
|
||||
@@ -10,39 +10,44 @@ export interface StarredMessageItem extends ActileItem {
|
||||
messageId: string,
|
||||
}
|
||||
|
||||
export function providerStarredMessage(onMessageSeelect: (item: StarredMessageItem) => void): ActileProvider<StarredMessageItem> {
|
||||
return {
|
||||
export const providerStarredMessages = (onMessageSelect: (item: StarredMessageItem) => void): ActileProvider<StarredMessageItem> => ({
|
||||
|
||||
// only the literal '@' at start of chat, or ' @' at end of chat
|
||||
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
|
||||
key: 'pstrmsg',
|
||||
|
||||
// finds all the starred messages in all the conversations - this could be heavy
|
||||
fetchItems: async () => {
|
||||
const { conversations } = useChatStore.getState();
|
||||
get label() {
|
||||
return 'Starred Messages';
|
||||
},
|
||||
|
||||
const starredMessages: StarredMessageItem[] = [];
|
||||
conversations.forEach((conversation) => {
|
||||
conversation.messages.forEach((message) => {
|
||||
messageHasUserFlag(message, MESSAGE_FLAG_STARRED) && starredMessages.push({
|
||||
// data
|
||||
conversationId: conversation.id,
|
||||
messageId: message.id,
|
||||
// looks
|
||||
key: message.id,
|
||||
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
|
||||
// description: message.text.slice(32, 100),
|
||||
Icon: undefined,
|
||||
} satisfies StarredMessageItem);
|
||||
});
|
||||
// only the literal '@' at start of chat, or ' @' at end of chat
|
||||
fastCheckTriggerText: (trailingText: string) => trailingText === '@' || trailingText.endsWith(' @'),
|
||||
|
||||
// finds all the starred messages in all the conversations - this could be heavy
|
||||
fetchItems: async (): ActileProviderItems<StarredMessageItem> => {
|
||||
const { conversations } = useChatStore.getState();
|
||||
|
||||
const starredMessages: StarredMessageItem[] = [];
|
||||
conversations.forEach((conversation) => {
|
||||
conversation.messages.forEach((message) => {
|
||||
messageHasUserFlag(message, MESSAGE_FLAG_STARRED) && starredMessages.push({
|
||||
key: message.id,
|
||||
providerKey: 'pstrmsg',
|
||||
// data
|
||||
conversationId: conversation.id,
|
||||
messageId: message.id,
|
||||
// looks
|
||||
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
|
||||
// description: message.text.slice(32, 100),
|
||||
Icon: undefined,
|
||||
} satisfies StarredMessageItem);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
title: 'Starred Messages',
|
||||
searchPrefix: '',
|
||||
items: starredMessages,
|
||||
};
|
||||
},
|
||||
return {
|
||||
searchPrefix: '',
|
||||
items: starredMessages,
|
||||
};
|
||||
},
|
||||
|
||||
onItemSelect: item => onMessageSeelect(item as StarredMessageItem),
|
||||
};
|
||||
}
|
||||
onItemSelect: item => onMessageSelect(item as StarredMessageItem),
|
||||
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
import type { ActileItem, ActileProvider } from './ActileProvider';
|
||||
import { ActilePopup } from './ActilePopup';
|
||||
|
||||
|
||||
@@ -7,71 +8,74 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
|
||||
// state
|
||||
const [popupOpen, setPopupOpen] = React.useState(false);
|
||||
const [provider, setProvider] = React.useState<ActileProvider | null>(null);
|
||||
|
||||
const [title, setTitle] = React.useState<string>('');
|
||||
const [items, setItems] = React.useState<ActileItem[]>([]);
|
||||
const [itemsByProvider, setItemsByProvider] = React.useState<{ provider: ActileProvider, items: ActileItem[] }[]>([]);
|
||||
const [activeSearchString, setActiveSearchString] = React.useState<string>('');
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(0);
|
||||
|
||||
|
||||
// derived state
|
||||
const activeItems = React.useMemo(() => {
|
||||
const activeItemsByProvider = React.useMemo(() => {
|
||||
const search = activeSearchString.trim().toLowerCase();
|
||||
return items.filter(item => item.label?.toLowerCase().startsWith(search));
|
||||
}, [items, activeSearchString]);
|
||||
const activeItem = activeItemIndex >= 0 && activeItemIndex < activeItems.length ? activeItems[activeItemIndex] : null;
|
||||
return itemsByProvider.map(({ provider, items }) => ({
|
||||
provider,
|
||||
items: items.filter(item => item.label?.toLowerCase().startsWith(search)),
|
||||
})).filter(({ items }) => items.length > 0);
|
||||
}, [itemsByProvider, activeSearchString]);
|
||||
|
||||
const flatActiveItems = React.useMemo(() => {
|
||||
return activeItemsByProvider.flatMap(({ items }) => items);
|
||||
}, [activeItemsByProvider]);
|
||||
const totalItems = flatActiveItems.length;
|
||||
const activeItem = totalItems > 0 && activeItemIndex >= 0 && activeItemIndex < totalItems ? flatActiveItems[activeItemIndex] : null;
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setPopupOpen(false);
|
||||
setProvider(null);
|
||||
setTitle('');
|
||||
setItems([]);
|
||||
setItemsByProvider([]);
|
||||
setActiveSearchString('');
|
||||
setActiveItemIndex(0);
|
||||
}, []);
|
||||
|
||||
const handlePopupItemClicked = React.useCallback((item: ActileItem) => {
|
||||
const provider = providers.find(p => p.key === item.providerKey);
|
||||
provider?.onItemSelect(item);
|
||||
handleClose();
|
||||
}, [handleClose, provider]);
|
||||
}, [providers, handleClose]);
|
||||
|
||||
const handleEnterKey = React.useCallback(() => {
|
||||
activeItem && handlePopupItemClicked(activeItem);
|
||||
if (activeItem)
|
||||
handlePopupItemClicked(activeItem);
|
||||
}, [activeItem, handlePopupItemClicked]);
|
||||
|
||||
|
||||
const actileInterceptTextChange = React.useCallback((trailingText: string) => {
|
||||
for (const provider of providers) {
|
||||
if (provider.fastCheckTriggerText(trailingText)) {
|
||||
provider
|
||||
.fetchItems()
|
||||
.then(({ title, searchPrefix, items }) => {
|
||||
// if there are no items, ignore
|
||||
if (items.length) {
|
||||
setPopupOpen(true);
|
||||
setProvider(provider);
|
||||
setTitle(title);
|
||||
setItems(items);
|
||||
setActiveSearchString(searchPrefix);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
handleClose();
|
||||
console.error('Failed to fetch popup items:', error);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Collect all providers whose trigger matches
|
||||
const matchingProviders = providers.filter(provider => provider.fastCheckTriggerText(trailingText));
|
||||
|
||||
if (matchingProviders.length > 0) {
|
||||
// Fetch items from all matching providers
|
||||
Promise.all(matchingProviders.map(provider =>
|
||||
provider.fetchItems().then(({ searchPrefix, items }) => ({
|
||||
provider,
|
||||
searchPrefix,
|
||||
items: items.map(item => ({ ...item, providerKey: provider.key })),
|
||||
})),
|
||||
)).then((results) => {
|
||||
// Filter out empty results
|
||||
results = results.filter(result => result.items.length > 0);
|
||||
if (results.length) {
|
||||
setPopupOpen(true);
|
||||
setItemsByProvider(results.map(result => ({ provider: result.provider, items: result.items })));
|
||||
setActiveSearchString(results[0].searchPrefix); // Assuming all search prefixes are the same
|
||||
setActiveItemIndex(0);
|
||||
}
|
||||
}).catch(error => {
|
||||
handleClose();
|
||||
console.error('Failed to fetch popup items:', error);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [handleClose, providers]);
|
||||
|
||||
|
||||
const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
|
||||
// Popup open: Intercept
|
||||
|
||||
const { key, currentTarget, ctrlKey, metaKey } = _event;
|
||||
|
||||
if (popupOpen) {
|
||||
@@ -80,11 +84,11 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
handleClose();
|
||||
} else if (key === 'ArrowUp') {
|
||||
_event.preventDefault();
|
||||
setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : activeItems.length - 1));
|
||||
setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : totalItems - 1));
|
||||
} else if (key === 'ArrowDown') {
|
||||
_event.preventDefault();
|
||||
setActiveItemIndex((prevIndex) => (prevIndex < activeItems.length - 1 ? prevIndex + 1 : 0));
|
||||
} else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && activeItems.length === 1)) {
|
||||
setActiveItemIndex((prevIndex) => (prevIndex < totalItems - 1 ? prevIndex + 1 : 0));
|
||||
} else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && totalItems === 1)) {
|
||||
_event.preventDefault();
|
||||
handleEnterKey();
|
||||
} else if (key === 'Backspace') {
|
||||
@@ -100,26 +104,24 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
const trailingText = (currentTarget.value || '') + key;
|
||||
return actileInterceptTextChange(trailingText);
|
||||
|
||||
}, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]);
|
||||
|
||||
}, [actileInterceptTextChange, handleClose, handleEnterKey, popupOpen, totalItems]);
|
||||
|
||||
const actileComponent = React.useMemo(() => {
|
||||
return !popupOpen ? null : (
|
||||
<ActilePopup
|
||||
anchorEl={anchorRef.current}
|
||||
onClose={handleClose}
|
||||
title={title}
|
||||
items={activeItems}
|
||||
itemsByProvider={activeItemsByProvider}
|
||||
activeItemIndex={activeItemIndex}
|
||||
activePrefixLength={activeSearchString.length}
|
||||
onItemClick={handlePopupItemClicked}
|
||||
/>
|
||||
);
|
||||
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, title]);
|
||||
}, [activeItemIndex, activeItemsByProvider, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen]);
|
||||
|
||||
return {
|
||||
actileComponent,
|
||||
actileInterceptKeydown,
|
||||
actileInterceptTextChange,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user