feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
.close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
:global {
|
||||
.semi-button.semi-button-with-icon-only {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
@apply coz-fg-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, type FC } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
|
||||
import { IconCozCross, IconCozQuotation } from '@coze-arch/coze-design/icons';
|
||||
import { IconButton } from '@coze-arch/coze-design';
|
||||
|
||||
import { QuoteNode } from '../quote-node';
|
||||
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
|
||||
|
||||
type IProps = Record<string, unknown>;
|
||||
|
||||
export const QuoteInputAddonTop: FC<IProps> = () => {
|
||||
const { pluginBizContext, chatAreaPluginContext } =
|
||||
useWriteablePlugin<GrabPluginBizContext>(PluginName.MessageGrab);
|
||||
|
||||
const { useChatInputLayout } = chatAreaPluginContext.readonlyHook.input;
|
||||
|
||||
const { useQuoteStore } = pluginBizContext.storeSet;
|
||||
|
||||
const { quoteVisible, quoteContent, updateQuoteVisible, updateQuoteContent } =
|
||||
useQuoteStore(
|
||||
useShallow(state => ({
|
||||
quoteVisible: state.quoteVisible,
|
||||
quoteContent: state.quoteContent,
|
||||
updateQuoteVisible: state.updateQuoteVisible,
|
||||
updateQuoteContent: state.updateQuoteContent,
|
||||
})),
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
updateQuoteContent(null);
|
||||
updateQuoteVisible(false);
|
||||
};
|
||||
|
||||
const { layoutContainerRef } = useChatInputLayout();
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutContainerRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStopPropagation = (e: PointerEvent) => e.stopPropagation();
|
||||
|
||||
layoutContainerRef.current.addEventListener(
|
||||
'pointerup',
|
||||
handleStopPropagation,
|
||||
);
|
||||
|
||||
return () => {
|
||||
layoutContainerRef.current?.removeEventListener(
|
||||
'pointerup',
|
||||
handleStopPropagation,
|
||||
);
|
||||
};
|
||||
}, [layoutContainerRef?.current]);
|
||||
|
||||
if (!quoteContent || !quoteVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[32px] flex items-center px-[16px] coz-mg-primary">
|
||||
<IconCozQuotation className="coz-fg-secondary mr-[8px] w-[12px] h-[12px]" />
|
||||
<div className="flex flex-row items-center flex-1">
|
||||
<div className="coz-fg-secondary flex-1 min-w-0 w-0 truncate text-[12px] leading-[16px]">
|
||||
<QuoteNode nodeList={quoteContent} theme="black" />
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<IconCozCross className="w-[14px] h-[14px]" />}
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
size="small"
|
||||
className="!rounded-[4px]"
|
||||
wrapperClass="flex item-center justify-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
.container-box-shadow {
|
||||
z-index: 1;
|
||||
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 8%), 0 4px 12px 0 rgba(0, 0, 0, 4%);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useEventCallback } from '@coze-common/chat-hooks';
|
||||
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
|
||||
|
||||
import {
|
||||
EventNames,
|
||||
type GrabPluginBizContext,
|
||||
} from '../../types/plugin-biz-context';
|
||||
|
||||
import s from './index.module.less';
|
||||
|
||||
interface MenuListProps {
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onScroll?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
}
|
||||
|
||||
export interface MenuListRef {
|
||||
getRef: () => MutableRefObject<HTMLDivElement | null>;
|
||||
refreshOpacity: () => void;
|
||||
}
|
||||
|
||||
const GRADIENT_RANGE = 100;
|
||||
|
||||
export const MenuList = forwardRef<MenuListRef, MenuListProps>((props, ref) => {
|
||||
const plugin = useWriteablePlugin<GrabPluginBizContext>(
|
||||
PluginName.MessageGrab,
|
||||
);
|
||||
|
||||
const { useGetScrollView } =
|
||||
plugin.chatAreaPluginContext.readonlyHook.scrollView;
|
||||
|
||||
const { on, off } = plugin.pluginBizContext.eventCenter;
|
||||
const { style, children, onScroll, onMouseEnter, onMouseLeave, className } =
|
||||
props;
|
||||
|
||||
const getScrollView = useGetScrollView();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getRef: () => menuRef,
|
||||
refreshOpacity,
|
||||
}));
|
||||
|
||||
const refreshOpacity = useEventCallback(() => {
|
||||
onScroll?.();
|
||||
|
||||
const localRect = menuRef.current?.getBoundingClientRect();
|
||||
|
||||
const { rect: scrollRect } = getScrollView().getOriginScrollInfo();
|
||||
|
||||
if (!scrollRect || !localRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (localRect.y - GRADIENT_RANGE <= scrollRect.top) {
|
||||
const _opacity = (localRect.y - scrollRect.top) / GRADIENT_RANGE;
|
||||
setOpacity(_opacity < 0 ? 0 : _opacity);
|
||||
} else if (localRect.y + GRADIENT_RANGE >= scrollRect.bottom) {
|
||||
const _opacity = (scrollRect.bottom - localRect.y) / GRADIENT_RANGE;
|
||||
setOpacity(_opacity < 0 ? 0 : _opacity);
|
||||
} else {
|
||||
setOpacity(1);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
on(EventNames.OnMessageUpdate, refreshOpacity);
|
||||
return () => {
|
||||
off(EventNames.OnMessageUpdate, refreshOpacity);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'fixed p-[2px] coz-bg-max coz-stroke-primary border-[0.5px] rounded-[8px] overflow-hidden grid grid-flow-col gap-[2px] h-[28px]',
|
||||
s['container-box-shadow'],
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
opacity,
|
||||
}}
|
||||
ref={menuRef}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
PluginName,
|
||||
useWriteablePlugin,
|
||||
type CustomTextMessageInnerTopSlot,
|
||||
} from '@coze-common/chat-area';
|
||||
|
||||
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
|
||||
import { RemoteQuoteInnerTopSlot } from './remote-slot';
|
||||
import { LocalQuoteInnerTopSlot } from './local-slot';
|
||||
|
||||
export const QuoteMessageInnerTopSlot: CustomTextMessageInnerTopSlot = ({
|
||||
message,
|
||||
}) => {
|
||||
const localMessageId = message.extra_info.local_message_id;
|
||||
|
||||
const plugin = useWriteablePlugin<GrabPluginBizContext>(
|
||||
PluginName.MessageGrab,
|
||||
);
|
||||
|
||||
const { useQuoteStore } = plugin.pluginBizContext.storeSet;
|
||||
|
||||
// 优先用本地映射的
|
||||
const hasLocal = useQuoteStore(
|
||||
useShallow(state => !!state.quoteContentMap[localMessageId]),
|
||||
);
|
||||
|
||||
if (hasLocal) {
|
||||
return <LocalQuoteInnerTopSlot message={message} />;
|
||||
}
|
||||
|
||||
return <RemoteQuoteInnerTopSlot message={message} />;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
PluginName,
|
||||
useWriteablePlugin,
|
||||
type CustomTextMessageInnerTopSlot,
|
||||
} from '@coze-common/chat-area';
|
||||
|
||||
import { QuoteNode } from '../quote-node';
|
||||
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
|
||||
import { QuoteTopUI } from './quote-top-ui';
|
||||
|
||||
export const LocalQuoteInnerTopSlot: CustomTextMessageInnerTopSlot = ({
|
||||
message,
|
||||
}) => {
|
||||
const localMessageId = message.extra_info.local_message_id;
|
||||
|
||||
const plugin = useWriteablePlugin<GrabPluginBizContext>(
|
||||
PluginName.MessageGrab,
|
||||
);
|
||||
|
||||
const { useQuoteStore } = plugin.pluginBizContext.storeSet;
|
||||
|
||||
// 优先用本地映射的
|
||||
const localNodeList = useQuoteStore(
|
||||
useShallow(state => state.quoteContentMap[localMessageId]),
|
||||
);
|
||||
|
||||
if (localNodeList) {
|
||||
return (
|
||||
<QuoteTopUI>
|
||||
<QuoteNode nodeList={localNodeList} theme="white" />
|
||||
</QuoteTopUI>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC, type PropsWithChildren } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useShowBackGround } from '@coze-common/chat-area';
|
||||
import { IconCozQuotation } from '@coze-arch/coze-design/icons';
|
||||
|
||||
import { typeSafeQuoteNodeColorVariants } from '../variants';
|
||||
|
||||
export const QuoteTopUI: FC<PropsWithChildren> = ({ children }) => {
|
||||
const showBackground = useShowBackGround();
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
['h-auto', 'py-4px'],
|
||||
'flex flex-row items-center select-none w-m-0',
|
||||
)}
|
||||
>
|
||||
<IconCozQuotation
|
||||
className={classNames(
|
||||
typeSafeQuoteNodeColorVariants({ showBackground }),
|
||||
'mr-[8px] shrink-0 w-[12px] h-[12px]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={classNames('flex-1 min-w-0 truncate text-[12px]', [
|
||||
'leading-[16px]',
|
||||
typeSafeQuoteNodeColorVariants({ showBackground }),
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseMarkdownToGrabNode } from '@coze-common/text-grab';
|
||||
import {
|
||||
ContentType,
|
||||
type CustomTextMessageInnerTopSlot,
|
||||
} from '@coze-common/chat-area';
|
||||
|
||||
import { QuoteNode } from '../quote-node';
|
||||
import { getReferFromMessage } from '../../utils/get-refer-from-message';
|
||||
import { QuoteTopUI } from './quote-top-ui';
|
||||
|
||||
export const RemoteQuoteInnerTopSlot: CustomTextMessageInnerTopSlot = ({
|
||||
message,
|
||||
}) => {
|
||||
// 本地没有用服务端下发的
|
||||
const refer = getReferFromMessage(message);
|
||||
|
||||
if (!refer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (refer.type === ContentType.Image) {
|
||||
return (
|
||||
<QuoteTopUI>
|
||||
<img className="w-[24px] h-[24px] rounded-[4px]" src={refer.url} />
|
||||
</QuoteTopUI>
|
||||
);
|
||||
}
|
||||
|
||||
// 尝试解析ast
|
||||
const nodeList = parseMarkdownToGrabNode(refer.text);
|
||||
|
||||
return (
|
||||
<QuoteTopUI>
|
||||
<QuoteNode nodeList={nodeList} theme="white" />
|
||||
</QuoteTopUI>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { defer } from 'lodash-es';
|
||||
import classNames from 'classnames';
|
||||
import { isTouchDevice } from '@coze-common/text-grab';
|
||||
import {
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
GrabElementType,
|
||||
type GrabNode,
|
||||
} from '@coze-common/text-grab';
|
||||
import {
|
||||
PluginName,
|
||||
useWriteablePlugin,
|
||||
type MessageListFloatSlot,
|
||||
} from '@coze-common/chat-area';
|
||||
|
||||
import { QuoteButton } from '../quote-button';
|
||||
import { MenuList } from '../menu-list';
|
||||
import { getMessage } from '../../utils/get-message';
|
||||
import {
|
||||
EventNames,
|
||||
type GrabPluginBizContext,
|
||||
} from '../../types/plugin-biz-context';
|
||||
import { useHideQuote } from '../../hooks/use-hide-quote';
|
||||
import { useFloatMenuListener } from '../../hooks/use-float-menu-listener';
|
||||
import { useAutoGetMaxPosition } from '../../hooks/use-auto-get-max-position';
|
||||
|
||||
export const FloatMenu: MessageListFloatSlot = () => {
|
||||
const plugin = useWriteablePlugin<GrabPluginBizContext>(
|
||||
PluginName.MessageGrab,
|
||||
);
|
||||
const { chatAreaPluginContext, pluginBizContext } = plugin;
|
||||
const { eventCenter } = pluginBizContext;
|
||||
const { usePreferenceStore } = pluginBizContext.storeSet;
|
||||
const enableGrab = usePreferenceStore(state => state.enableGrab);
|
||||
|
||||
const { on, off } = eventCenter;
|
||||
|
||||
const { useQuoteStore } = pluginBizContext.storeSet;
|
||||
const { updateQuoteContent, updateQuoteVisible } = useQuoteStore(
|
||||
useShallow(state => ({
|
||||
updateQuoteVisible: state.updateQuoteVisible,
|
||||
updateQuoteContent: state.updateQuoteContent,
|
||||
})),
|
||||
);
|
||||
|
||||
const {
|
||||
handleMenuMouseEnter,
|
||||
handleMenuMouseLeave,
|
||||
targetElement,
|
||||
targetInfo,
|
||||
position,
|
||||
floatMenuRef,
|
||||
visible,
|
||||
setVisible,
|
||||
computePosition,
|
||||
isScrolling,
|
||||
} = useFloatMenuListener({
|
||||
plugin,
|
||||
});
|
||||
|
||||
const { targetRef: messageRef, forceHidden } = useHideQuote<HTMLElement>({
|
||||
containerRef: targetElement,
|
||||
});
|
||||
|
||||
const { maxPositionX } = useAutoGetMaxPosition({
|
||||
messageRef,
|
||||
position,
|
||||
floatMenuRef,
|
||||
});
|
||||
|
||||
const isGrabMenuVisible = plugin.pluginBizContext.storeSet.useSelectionStore(
|
||||
state => state.isFloatMenuVisible && !!state.floatMenuPosition,
|
||||
);
|
||||
|
||||
const handleQuoteClick = () => {
|
||||
const { source = '', type = 'link', text = '' } = targetInfo.current ?? {};
|
||||
const target = messageRef.current;
|
||||
const messageId = target?.attributes.getNamedItem(
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
)?.value;
|
||||
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = getMessage({
|
||||
messageId,
|
||||
chatAreaPluginContext,
|
||||
});
|
||||
|
||||
const node: GrabNode =
|
||||
type === 'image'
|
||||
? {
|
||||
type: GrabElementType.IMAGE,
|
||||
src: source,
|
||||
children: [
|
||||
{
|
||||
text: source,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: GrabElementType.LINK,
|
||||
url: source,
|
||||
children: [
|
||||
{
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateQuoteContent([node]);
|
||||
updateQuoteVisible(true);
|
||||
pluginBizContext.eventCallbacks.onQuote?.({
|
||||
botId: message?.sender_id ?? '',
|
||||
source: message?.source,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
on(EventNames.OnMessageUpdate, computePosition);
|
||||
on(EventNames.OnViewScroll, computePosition);
|
||||
|
||||
return () => {
|
||||
off(EventNames.OnMessageUpdate, computePosition);
|
||||
off(EventNames.OnViewScroll, computePosition);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!enableGrab || isTouchDevice()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const top = position?.y;
|
||||
const left =
|
||||
(position?.x ?? 0) > (maxPositionX ?? 0) ? maxPositionX : position?.x;
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<MenuList
|
||||
ref={floatMenuRef}
|
||||
className={classNames({
|
||||
hidden: !visible || forceHidden || isGrabMenuVisible || isScrolling,
|
||||
})}
|
||||
style={{
|
||||
top,
|
||||
left,
|
||||
}}
|
||||
onMouseEnter={handleMenuMouseEnter}
|
||||
onMouseLeave={handleMenuMouseLeave}
|
||||
>
|
||||
<QuoteButton
|
||||
onClick={handleQuoteClick}
|
||||
onClose={() => {
|
||||
defer(() => setVisible(false));
|
||||
}}
|
||||
/>
|
||||
</MenuList>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { defer } from 'lodash-es';
|
||||
import classNames from 'classnames';
|
||||
import { type GrabPosition, type SelectionData } from '@coze-common/text-grab';
|
||||
import { useGrab } from '@coze-common/text-grab';
|
||||
import { NO_MESSAGE_ID_MARK } from '@coze-common/chat-uikit';
|
||||
import {
|
||||
PluginName,
|
||||
useWriteablePlugin,
|
||||
type MessageListFloatSlot,
|
||||
} from '@coze-common/chat-area';
|
||||
|
||||
import { QuoteButton } from '../quote-button';
|
||||
import { MenuList, type MenuListRef } from '../menu-list';
|
||||
import {
|
||||
EventNames,
|
||||
type GrabPluginBizContext,
|
||||
} from '../../types/plugin-biz-context';
|
||||
import { FILTER_MESSAGE_SOURCE } from '../../constants/filter-message';
|
||||
|
||||
export const GrabMenu: MessageListFloatSlot = ({ contentRef }) => {
|
||||
// 获取插件实例
|
||||
const plugin = useWriteablePlugin<GrabPluginBizContext>(
|
||||
PluginName.MessageGrab,
|
||||
);
|
||||
|
||||
// 获取插件的业务上下文信息
|
||||
const { pluginBizContext } = plugin;
|
||||
const { eventCenter, storeSet } = pluginBizContext;
|
||||
const { usePreferenceStore, useSelectionStore } = storeSet;
|
||||
|
||||
// 事件中心监听函数
|
||||
const { on, off } = eventCenter;
|
||||
|
||||
/**
|
||||
* Float Menu 的 Ref
|
||||
*/
|
||||
const floatMenuRef = useRef<MenuListRef>(null);
|
||||
|
||||
const { isFloatMenuVisible, position, updateFloatMenuPosition } =
|
||||
useSelectionStore(
|
||||
useShallow(state => ({
|
||||
isFloatMenuVisible: state.isFloatMenuVisible,
|
||||
position: state.floatMenuPosition,
|
||||
updateFloatMenuPosition: state.updateFloatMenuPosition,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否启用 Grab 插件
|
||||
*/
|
||||
const enableGrab = usePreferenceStore(state => state.enableGrab);
|
||||
|
||||
/**
|
||||
* 处理位置信息的回调
|
||||
*/
|
||||
const handlePositionChange = (_position: GrabPosition | null) => {
|
||||
// 更新浮动菜单在 Store 中的数据
|
||||
updateFloatMenuPosition(_position);
|
||||
|
||||
// 清空栈后调用刷新的逻辑 (因为 floatMenu 在 hidden 状态下 通过 ref 拿到的 rect 信息是空,所以需要延迟一会儿获取)
|
||||
defer(() => floatMenuRef.current?.refreshOpacity());
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理选区的变化
|
||||
*/
|
||||
const handleSelectChange = (selectionData: SelectionData | null) => {
|
||||
const {
|
||||
updateHumanizedContentText,
|
||||
updateNormalizeSelectionNodeList,
|
||||
updateOriginContentText,
|
||||
updateSelectionData,
|
||||
updateIsFloatMenuVisible,
|
||||
} = plugin.pluginBizContext.storeSet.useSelectionStore.getState();
|
||||
|
||||
/**
|
||||
* 过滤特殊类型的消息
|
||||
* 目前只有通过消息来源进行判断 messageSource
|
||||
* 1. 通知类型 (禁止选择)
|
||||
*/
|
||||
if (
|
||||
selectionData &&
|
||||
FILTER_MESSAGE_SOURCE.includes(selectionData.messageSource)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新选区数据
|
||||
updateSelectionData(selectionData);
|
||||
|
||||
// 更新展示浮动菜单的状态
|
||||
updateIsFloatMenuVisible(!!selectionData);
|
||||
|
||||
// 拿取 MessageId
|
||||
const messageId = selectionData?.ancestorAttributeValue;
|
||||
|
||||
// 判断是否是特殊消息
|
||||
const isSpecialMessage = messageId === NO_MESSAGE_ID_MARK;
|
||||
|
||||
// 如果拿不到消息 ID,就证明选区不在消息哪
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 特殊处理,需要判断是否是回复中的消息
|
||||
const { is_finish } =
|
||||
plugin.chatAreaPluginContext.readonlyAPI.message.findMessage(messageId) ??
|
||||
{};
|
||||
|
||||
// 如果消息回复没完成 并且 不是一个特殊的消息 那么就不展示,否则展示
|
||||
if (!is_finish && !isSpecialMessage) {
|
||||
updateIsFloatMenuVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateHumanizedContentText(selectionData?.humanizedContentText ?? '');
|
||||
updateNormalizeSelectionNodeList(
|
||||
selectionData?.normalizeSelectionNodeList ?? [],
|
||||
);
|
||||
updateOriginContentText(selectionData?.originContentText ?? '');
|
||||
};
|
||||
|
||||
const { clearSelection, isScrolling, computePosition } = useGrab({
|
||||
contentRef,
|
||||
floatMenuRef: floatMenuRef.current?.getRef(),
|
||||
onSelectChange: handleSelectChange,
|
||||
onPositionChange: handlePositionChange,
|
||||
});
|
||||
|
||||
const isShowFloatMenu = !!position && isFloatMenuVisible;
|
||||
|
||||
useEffect(() => {
|
||||
on(EventNames.OnMessageUpdate, computePosition);
|
||||
on(EventNames.OnViewScroll, computePosition);
|
||||
|
||||
return () => {
|
||||
off(EventNames.OnMessageUpdate, computePosition);
|
||||
off(EventNames.OnViewScroll, computePosition);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 如果没有开启 Grab 插件,那么就隐藏了当然
|
||||
if (!enableGrab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuList
|
||||
ref={floatMenuRef}
|
||||
style={{
|
||||
top: position?.y,
|
||||
left: position?.x,
|
||||
}}
|
||||
className={classNames({
|
||||
hidden: !isShowFloatMenu || isScrolling,
|
||||
})}
|
||||
>
|
||||
<QuoteButton onClose={clearSelection} />
|
||||
</MenuList>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type MessageListFloatSlot } from '@coze-common/chat-area';
|
||||
|
||||
import { GrabMenu } from './grab-menu';
|
||||
import { FloatMenu } from './float-menu';
|
||||
|
||||
export const MessageListFloat: MessageListFloatSlot = props => (
|
||||
<>
|
||||
<GrabMenu {...props} />
|
||||
<FloatMenu {...props} />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozQuotation } from '@coze-arch/coze-design/icons';
|
||||
import { IconButton, Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { getMessage } from '../../utils/get-message';
|
||||
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
|
||||
|
||||
export interface QuoteButtonProps {
|
||||
onClose?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const QuoteButton: FC<QuoteButtonProps> = ({ onClose, onClick }) => {
|
||||
const { pluginBizContext, chatAreaPluginContext } =
|
||||
useWriteablePlugin<GrabPluginBizContext>(PluginName.MessageGrab);
|
||||
|
||||
const { onQuote } = pluginBizContext.eventCallbacks;
|
||||
|
||||
const { useQuoteStore, useSelectionStore, usePreferenceStore } =
|
||||
pluginBizContext.storeSet;
|
||||
|
||||
const { updateQuoteContent, updateQuoteVisible } = useQuoteStore(
|
||||
useShallow(state => ({
|
||||
updateQuoteVisible: state.updateQuoteVisible,
|
||||
updateQuoteContent: state.updateQuoteContent,
|
||||
})),
|
||||
);
|
||||
|
||||
const enableGrab = usePreferenceStore(state => state.enableGrab);
|
||||
|
||||
const { useDeleteFile } = chatAreaPluginContext.writeableHook.file;
|
||||
const { getFileStoreInstantValues } =
|
||||
chatAreaPluginContext.readonlyAPI.batchFile;
|
||||
|
||||
const deleteFile = useDeleteFile();
|
||||
|
||||
const deleteAllFile = () => {
|
||||
const { fileIdList } = getFileStoreInstantValues();
|
||||
|
||||
fileIdList.forEach(id => deleteFile(id));
|
||||
};
|
||||
|
||||
const getMessageInfo = () => {
|
||||
const { selectionData } = useSelectionStore.getState();
|
||||
|
||||
const messageId = selectionData?.ancestorAttributeValue;
|
||||
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getMessage({ messageId, chatAreaPluginContext });
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
deleteAllFile();
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const { normalizeSelectionNodeList } = useSelectionStore.getState();
|
||||
|
||||
updateQuoteContent(normalizeSelectionNodeList);
|
||||
|
||||
updateQuoteVisible(true);
|
||||
|
||||
const message = getMessageInfo();
|
||||
|
||||
onQuote?.({ botId: message?.sender_id ?? '', source: message?.source });
|
||||
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
if (!enableGrab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={I18n.t('quote_ask_in_chat')} clickToHide={true}>
|
||||
<IconButton
|
||||
icon={<IconCozQuotation className="text-lg coz-fg-secondary" />}
|
||||
color="secondary"
|
||||
onClick={handleClick}
|
||||
size="small"
|
||||
wrapperClass="flex justify-center items-center"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.image {
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, type FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
isGrabTextNode,
|
||||
isGrabLink,
|
||||
isGrabImage,
|
||||
} from '@coze-common/text-grab';
|
||||
import { type GrabNode } from '@coze-common/text-grab';
|
||||
import { useShowBackGround } from '@coze-common/chat-area';
|
||||
import { Image, ImagePreview } from '@coze-arch/coze-design';
|
||||
|
||||
import { typeSafeQuoteNodeColorVariants } from '../variants';
|
||||
import DefaultImage from '../../assets/image-default.png';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
const getQuoteNode = ({
|
||||
nodeList,
|
||||
theme,
|
||||
handleClickImage,
|
||||
showBackground,
|
||||
}: {
|
||||
nodeList: GrabNode[];
|
||||
theme: 'white' | 'black';
|
||||
handleClickImage: (url: string) => void;
|
||||
showBackground: boolean;
|
||||
}): React.ReactNode =>
|
||||
nodeList.map((node, index) => {
|
||||
if (isGrabTextNode(node)) {
|
||||
return (
|
||||
<span
|
||||
className={classNames('text-[14px] align-middle', 'leading-[16px]', {
|
||||
'coz-fg-secondary': theme === 'black',
|
||||
[typeSafeQuoteNodeColorVariants({ showBackground })]:
|
||||
theme === 'white',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{node.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGrabImage(node)) {
|
||||
return (
|
||||
<Image
|
||||
className={classNames(
|
||||
styles.image,
|
||||
'w-[24px] h-[24px] leading-[24px] align-middle rounded-[4px] cursor-zoom-in',
|
||||
{
|
||||
'mx-[4px]': index !== 0,
|
||||
'mr-[4px]': index === 0,
|
||||
},
|
||||
)}
|
||||
src={node.src}
|
||||
onClick={() => handleClickImage(node.src)}
|
||||
key={index}
|
||||
fallback={DefaultImage}
|
||||
preview={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGrabLink(node)) {
|
||||
return (
|
||||
<span
|
||||
className={classNames('text-[14px] align-middle', 'leading-[16px]', {
|
||||
'coz-fg-secondary': theme === 'black',
|
||||
[typeSafeQuoteNodeColorVariants({ showBackground })]:
|
||||
theme === 'white',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
[
|
||||
{getQuoteNode({
|
||||
nodeList: node.children,
|
||||
theme,
|
||||
handleClickImage,
|
||||
showBackground,
|
||||
})}
|
||||
]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
export const QuoteNode: FC<{
|
||||
nodeList: GrabNode[];
|
||||
theme: 'white' | 'black';
|
||||
}> = ({ nodeList, theme }) => {
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const showBackground = useShowBackGround();
|
||||
const handleClickImage = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ImagePreview
|
||||
src={previewUrl}
|
||||
visible={Boolean(previewUrl)}
|
||||
onVisibleChange={() => setPreviewUrl('')}
|
||||
/>
|
||||
{getQuoteNode({
|
||||
nodeList,
|
||||
theme,
|
||||
handleClickImage,
|
||||
showBackground,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
const quoteNodeColorVariants = cva([], {
|
||||
variants: {
|
||||
showBackground: {
|
||||
true: ['text-[#FFFFFF/60]'],
|
||||
false: ['coz-fg-secondary'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const typeSafeQuoteNodeColorVariants: (
|
||||
props: Required<VariantProps<typeof quoteNodeColorVariants>>,
|
||||
) => string = quoteNodeColorVariants;
|
||||
Reference in New Issue
Block a user