feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
/*
* 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 MutableRefObject,
useEffect,
useState,
type RefObject,
} from 'react';
import { defer } from 'lodash-es';
import { type GrabPosition } from '@coze-common/text-grab';
import { type MenuListRef } from '../custom-components/menu-list';
export const useAutoGetMaxPosition = ({
position,
messageRef,
floatMenuRef,
}: {
position: GrabPosition | null;
messageRef: MutableRefObject<Element | null>;
floatMenuRef: RefObject<MenuListRef>;
}) => {
const [maxPositionX, setMaxPositionX] = useState(0);
useEffect(() => {
const maxX = messageRef.current?.getBoundingClientRect().right ?? 0;
setMaxPositionX(maxX);
defer(() => floatMenuRef.current?.refreshOpacity());
}, [position, messageRef.current, floatMenuRef.current]);
return { maxPositionX };
};

View File

@@ -0,0 +1,240 @@
/*
* 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, useState } from 'react';
import { defer, delay } from 'lodash-es';
import {
CONTENT_ATTRIBUTE_NAME,
getAncestorAttributeNode,
type GrabPosition,
} from '@coze-common/text-grab';
import { NO_MESSAGE_ID_MARK } from '@coze-common/chat-uikit';
import { useEventCallback } from '@coze-common/chat-hooks';
import {
type OnLinkElementContext,
type useWriteablePlugin,
} from '@coze-common/chat-area';
import { getMouseNearbyRect } from '../utils/get-mouse-nearby-rect';
import {
EventNames,
type GrabPluginBizContext,
} from '../types/plugin-biz-context';
import { type MenuListRef } from '../custom-components/menu-list';
const DELAY_DISAPPEAR_TIME = 100;
const TIMEOUT = 100;
// eslint-disable-next-line max-lines-per-function, @coze-arch/max-line-per-function
export const useFloatMenuListener = ({
plugin,
}: {
plugin: ReturnType<typeof useWriteablePlugin<GrabPluginBizContext>>;
}) => {
const floatMenuRef = useRef<MenuListRef>(null);
const [visible, setVisible] = useState(false);
const targetElement = useRef<HTMLElement | null>(null);
const targetInfo = useRef<{
source: string;
type: 'image' | 'link';
text: string;
} | null>(null);
/**
* 是否在 Scrolling 中
*/
const [isScrolling, setIsScrolling] = useState(false);
/**
* Scrolling 计时器
*/
const scrollingTimer = useRef<NodeJS.Timeout | null>(null);
const { pluginBizContext } = plugin;
const { eventCenter } = pluginBizContext;
const { on, off } = eventCenter;
const isMouseInMenu = useRef(false);
const [position, setPosition] = useState<GrabPosition | null>(null);
const timer = useRef<number | null>(null);
const mouseInfo = useRef<GrabPosition>({ x: 0, y: 0 });
const handleMenuMouseEnter = useEventCallback(() => {
isMouseInMenu.current = true;
if (timer.current) {
clearTimeout(timer.current);
}
});
const handleMenuMouseLeave = useEventCallback(() => {
isMouseInMenu.current = false;
handleCardLinkElementMouseLeave();
});
const isMessageFinished = () => {
const target = getAncestorAttributeNode(
targetElement.current,
CONTENT_ATTRIBUTE_NAME,
);
const messageId = target?.attributes.getNamedItem(
CONTENT_ATTRIBUTE_NAME,
)?.value;
const isSpecialMessage = messageId === NO_MESSAGE_ID_MARK;
if (isSpecialMessage) {
return true;
}
if (!messageId) {
return false;
}
const { is_finish } =
plugin.chatAreaPluginContext.readonlyAPI.message.findMessage(messageId) ??
{};
return is_finish;
};
const handleCardLinkElementMouseEnter = useEventCallback(
(ctx: OnLinkElementContext & { type: 'link' | 'image' }) => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
targetElement.current = ctx.element;
targetInfo.current = {
source: ctx.link,
type: ctx.type,
text: ctx.element.textContent ?? '',
};
const isFinished = isMessageFinished();
if (!isFinished) {
return;
}
setVisible(true);
handleViewScroll();
defer(() => {
floatMenuRef.current?.refreshOpacity();
});
},
);
const handleCardLinkElementMouseLeave = useEventCallback(() => {
timer.current = delay(() => {
if (isMouseInMenu.current) {
return;
}
targetElement.current = null;
setVisible(false);
}, DELAY_DISAPPEAR_TIME);
});
const handleViewScroll = useEventCallback(() => {
const menuRef = floatMenuRef.current?.getRef();
if (
!targetElement.current ||
!menuRef ||
!menuRef.current ||
!targetInfo.current
) {
return;
}
const target = targetElement.current;
const targetRect = target.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
let x = 0;
let y = 0;
if (targetInfo.current.type === 'image') {
x = targetRect.right;
y = targetRect.bottom - targetRect.height / 2 - menuRect.height / 2;
} else {
const targetRects = target.getClientRects();
const nearbyRect =
getMouseNearbyRect(Array.from(targetRects), mouseInfo.current) ??
targetRect;
x = mouseInfo.current.x;
y = nearbyRect.bottom;
}
setPosition({ x, y });
});
const handleMouseMove = useEventCallback((event: MouseEvent) => {
const [x, y] = [event.clientX, event.clientY];
mouseInfo.current = { x, y };
});
const handleSmartScreenChange = useEventCallback(() => {
if (scrollingTimer.current) {
clearTimeout(scrollingTimer.current);
}
setIsScrolling(true);
scrollingTimer.current = setTimeout(() => {
handleViewScroll();
setIsScrolling(false);
}, TIMEOUT);
});
useEffect(() => {
on(EventNames.OnLinkElementMouseEnter, handleCardLinkElementMouseEnter);
on(EventNames.OnLinkElementMouseLeave, handleCardLinkElementMouseLeave);
on(EventNames.OnViewScroll, handleSmartScreenChange);
on(EventNames.OnMessageUpdate, handleSmartScreenChange);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('resize', handleSmartScreenChange);
window.addEventListener('wheel', handleSmartScreenChange);
window.addEventListener('scroll', handleSmartScreenChange);
return () => {
off(EventNames.OnLinkElementMouseEnter, handleCardLinkElementMouseEnter);
off(EventNames.OnLinkElementMouseLeave, handleCardLinkElementMouseLeave);
off(EventNames.OnViewScroll, handleSmartScreenChange);
off(EventNames.OnMessageUpdate, handleSmartScreenChange);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('resize', handleSmartScreenChange);
window.removeEventListener('wheel', handleSmartScreenChange);
window.removeEventListener('scroll', handleSmartScreenChange);
};
}, []);
return {
handleMenuMouseEnter,
handleMenuMouseLeave,
targetElement,
targetInfo,
position,
floatMenuRef,
visible,
setVisible,
computePosition: handleSmartScreenChange,
isScrolling,
};
};

View File

@@ -0,0 +1,55 @@
/*
* 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 { useMemo, useState } from 'react';
import { type MessageSource } from '@coze-common/chat-area';
import { type Scene, createGrabPlugin } from '../create';
interface Params {
onQuote?: ({
botId,
source,
}: {
botId: string;
source: MessageSource | undefined;
}) => void;
// 目前只需要区分出 store 的场景
scene?: Scene;
}
export const useCreateGrabPlugin = (params?: Params) => {
const { onQuote, scene = 'other' } = params ?? {};
const [grabEnableUpload, setGrabEnableUpload] = useState(true);
// eslint-disable-next-line @typescript-eslint/naming-convention -- 符合预期的命名
const { grabPlugin: GrabPlugin, grabPluginId } = useMemo(
() =>
createGrabPlugin({
preference: {
enableGrab: true,
},
onQuote,
onQuoteChange: ({ isEmpty }) => {
setGrabEnableUpload(isEmpty);
},
scene,
}),
[],
);
return { grabEnableUpload, GrabPlugin, grabPluginId };
};

View File

@@ -0,0 +1,64 @@
/*
* 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 MutableRefObject, useEffect, useRef, useState } from 'react';
import {
CONTENT_ATTRIBUTE_NAME,
MESSAGE_SOURCE_ATTRIBUTE_NAME,
getAncestorAttributeNode,
} from '@coze-common/text-grab';
import { FILTER_MESSAGE_SOURCE } from '../constants/filter-message';
interface HideQuoteProps<T> {
containerRef?: MutableRefObject<T | null>;
}
export const useHideQuote = <T extends Element>(props?: HideQuoteProps<T>) => {
const containerRef = useRef<T | null>(null);
const targetRef = useRef<Element | null>(null);
const usedContainerRef = props?.containerRef?.current
? props.containerRef
: containerRef;
const [forceHidden, setForceHidden] = useState(false);
useEffect(() => {
const target = getAncestorAttributeNode(
usedContainerRef.current,
CONTENT_ATTRIBUTE_NAME,
);
const messageSource = target?.attributes.getNamedItem(
MESSAGE_SOURCE_ATTRIBUTE_NAME,
)?.value;
if (FILTER_MESSAGE_SOURCE.includes(Number(messageSource))) {
setForceHidden(true);
}
targetRef.current = target;
return () => {
setForceHidden(false);
targetRef.current = null;
};
}, [usedContainerRef.current]);
return { targetRef, containerRef: usedContainerRef, forceHidden };
};