feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const CONTENT_ATTRIBUTE_NAME = 'data-grab-mark';
|
||||
|
||||
export const MESSAGE_SOURCE_ATTRIBUTE_NAME = 'data-grab-source';
|
||||
@@ -0,0 +1,345 @@
|
||||
/*
|
||||
* 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, type MutableRefObject } from 'react';
|
||||
|
||||
import { defer } from 'lodash-es';
|
||||
import { useEventCallback } from '@coze-common/chat-hooks';
|
||||
|
||||
import { isTouchDevice } from '../utils/is-touch-device';
|
||||
import { getSelectionData } from '../utils/get-selection-data';
|
||||
import { getRectData } from '../utils/get-rect-data';
|
||||
import {
|
||||
Direction,
|
||||
type GrabPosition,
|
||||
type SelectionData,
|
||||
} from '../types/selection';
|
||||
|
||||
const MAX_WIDTH = 40;
|
||||
const TIMEOUT = 100;
|
||||
|
||||
interface GrabParams {
|
||||
/**
|
||||
* 选择目标容器的 Ref
|
||||
*/
|
||||
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* 浮动菜单的 Ref
|
||||
*/
|
||||
floatMenuRef: MutableRefObject<HTMLDivElement | null> | undefined;
|
||||
/**
|
||||
* 选择事件的回调
|
||||
*/
|
||||
onSelectChange: (selectionData: SelectionData | null) => void;
|
||||
/**
|
||||
* 位置信息的回调
|
||||
*/
|
||||
onPositionChange: (position: GrabPosition | null) => void;
|
||||
/**
|
||||
* Resize/Scroll/Wheel 是节流的时间
|
||||
*/
|
||||
resizeThrottleTime?: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @coze-arch/max-line-per-function, max-lines-per-function
|
||||
export const useGrab = ({
|
||||
contentRef,
|
||||
floatMenuRef,
|
||||
onSelectChange,
|
||||
onPositionChange,
|
||||
}: GrabParams) => {
|
||||
const timeoutRef = useRef<number>();
|
||||
|
||||
/**
|
||||
* 选区对象存放(用于hooks内部流转状态用)
|
||||
*/
|
||||
const selection = useRef<Selection | null>(null);
|
||||
|
||||
/**
|
||||
* 选区最终计算结果的数据
|
||||
*/
|
||||
const selectionData = useRef<SelectionData | null>(null);
|
||||
|
||||
/**
|
||||
* 是否在 Scrolling 中
|
||||
*/
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
/**
|
||||
* Scrolling 计时器
|
||||
*/
|
||||
const scrollingTimer = useRef<number | null>(null);
|
||||
|
||||
/**
|
||||
* 是否有 SelectionData (优化挂载逻辑用)
|
||||
*/
|
||||
const hasSelectionData = useRef(false);
|
||||
|
||||
/**
|
||||
* 清除内部数据 + 触发回调
|
||||
*/
|
||||
const clearSelection = () => {
|
||||
onSelectChange(null);
|
||||
onPositionChange(null);
|
||||
selection.current?.removeAllRanges();
|
||||
selection.current = null;
|
||||
setIsScrolling(false);
|
||||
hasSelectionData.current = false;
|
||||
if (scrollingTimer.current) {
|
||||
clearTimeout(scrollingTimer.current);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理屏幕发生变化 Scroll + Resize + Wheel + SelectionChange(移动设备)
|
||||
*/
|
||||
const handleScreenChange = () => {
|
||||
// 获取选区
|
||||
const innerSelection = window.getSelection();
|
||||
|
||||
const { direction = Direction.Unknown } = selectionData.current ?? {};
|
||||
|
||||
// 如果选区为空,则返回
|
||||
if (!innerSelection) {
|
||||
onSelectChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rectData = getRectData({ selection: innerSelection });
|
||||
|
||||
if (!rectData) {
|
||||
onPositionChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认使用获取选区最后一行的位置信息 (既Forward的情况)
|
||||
const rangeRect = Array.from(rectData.rangeRects).at(
|
||||
direction === Direction.Backward ? 0 : -1,
|
||||
);
|
||||
|
||||
// 如果最后一行选区信息不正确则返回
|
||||
if (!rangeRect) {
|
||||
onPositionChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let [x, y] = [0, 0];
|
||||
// 判断如果选区是从前往后选择,则展示在最后一行的末尾,否则展示在开头
|
||||
if (direction === Direction.Backward) {
|
||||
x = rangeRect.left;
|
||||
y = rangeRect.top + rangeRect.height;
|
||||
} else {
|
||||
x = rangeRect.x + rangeRect.width;
|
||||
y = rangeRect.y + rangeRect.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加了一个避让屏幕的逻辑
|
||||
*/
|
||||
const position = {
|
||||
x: x > screen.width - MAX_WIDTH ? x - MAX_WIDTH : x,
|
||||
y:
|
||||
y > screen.height - MAX_WIDTH
|
||||
? y - MAX_WIDTH
|
||||
: rangeRect.y + rangeRect.height,
|
||||
};
|
||||
|
||||
onPositionChange(position);
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能处理屏幕发生变化 有一个计时器 + 滚动告知的逻辑
|
||||
*/
|
||||
const handleSmartScreenChange = useEventCallback(() => {
|
||||
if (scrollingTimer.current) {
|
||||
clearTimeout(scrollingTimer.current);
|
||||
}
|
||||
|
||||
setIsScrolling(true);
|
||||
scrollingTimer.current = setTimeout(() => {
|
||||
handleScreenChange();
|
||||
setIsScrolling(false);
|
||||
}, TIMEOUT);
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理获取选区的逻辑
|
||||
*/
|
||||
const handleGetSelection = () => {
|
||||
if (!contentRef.current) {
|
||||
onSelectChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取选区
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- 内部变量
|
||||
const _selection = window.getSelection();
|
||||
|
||||
// 如果选区为空,则返回
|
||||
if (!_selection) {
|
||||
onSelectChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
selection.current = _selection;
|
||||
|
||||
// 获取选区数据
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- 内部变量
|
||||
const _selectionData = getSelectionData({
|
||||
selection: _selection,
|
||||
});
|
||||
|
||||
// 选区如果为空,则隐藏浮层Button
|
||||
if (!_selectionData || !_selectionData.nodesAncestorIsMessageBox) {
|
||||
onSelectChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置展示和位置信息
|
||||
selectionData.current = _selectionData;
|
||||
hasSelectionData.current = Boolean(_selectionData);
|
||||
|
||||
handleScreenChange();
|
||||
onSelectChange(_selectionData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 鼠标抬起的动作
|
||||
*/
|
||||
const handleMouseUp = useEventCallback(() => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = defer(handleGetSelection);
|
||||
});
|
||||
|
||||
/**
|
||||
* 键盘按下的动作
|
||||
*/
|
||||
const handleKeyDown = useEventCallback((e: KeyboardEvent) => {
|
||||
const forbiddenKeyboardSelect = () => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
onSelectChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectionData.current) {
|
||||
forbiddenKeyboardSelect();
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
handleGetSelection();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 鼠标按下的动作
|
||||
*/
|
||||
const handleMouseDown = useEventCallback((e: MouseEvent) => {
|
||||
// 检查是否有选区,且点击事件的目标不在选区内
|
||||
|
||||
if (!contentRef.current || !floatMenuRef || !floatMenuRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
const floatMenuRect = floatMenuRef.current.getBoundingClientRect();
|
||||
|
||||
const isInFloatMenu =
|
||||
clientY >= floatMenuRect.top &&
|
||||
clientY <= floatMenuRect.bottom &&
|
||||
clientX >= floatMenuRect.left &&
|
||||
clientX <= floatMenuRect.right;
|
||||
|
||||
if (isInFloatMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSelectionData.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听鼠标down事件
|
||||
window.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, [hasSelectionData.current]);
|
||||
|
||||
// 当visible时,挂载监听事件,优化监听
|
||||
useEffect(() => {
|
||||
if (!hasSelectionData.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleSmartScreenChange);
|
||||
window.addEventListener('wheel', handleSmartScreenChange);
|
||||
window.addEventListener('scroll', handleSmartScreenChange);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
if (isTouchDevice()) {
|
||||
window.addEventListener('selectionchange', handleSmartScreenChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleSmartScreenChange);
|
||||
window.removeEventListener('wheel', handleSmartScreenChange);
|
||||
window.removeEventListener('scroll', handleSmartScreenChange);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('selectionchange', handleSmartScreenChange);
|
||||
};
|
||||
}, [hasSelectionData.current]);
|
||||
|
||||
// target上挂载监听
|
||||
useEffect(() => {
|
||||
const target = contentRef.current;
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听选择相关的鼠标抬起事件
|
||||
target.addEventListener('pointerup', handleMouseUp);
|
||||
|
||||
if (isTouchDevice()) {
|
||||
target.addEventListener('selectionchange', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
target.removeEventListener('pointerup', handleMouseUp);
|
||||
target.removeEventListener('selectionchange', handleMouseUp);
|
||||
};
|
||||
}, [contentRef.current]);
|
||||
|
||||
return {
|
||||
/**
|
||||
* 清除内置状态和选区
|
||||
*/
|
||||
clearSelection,
|
||||
/**
|
||||
* 是否在滚动中
|
||||
*/
|
||||
isScrolling,
|
||||
/**
|
||||
* 重新计算选区位置
|
||||
*/
|
||||
computePosition: handleSmartScreenChange,
|
||||
};
|
||||
};
|
||||
41
frontend/packages/common/chat-area/text-grab/src/index.ts
Normal file
41
frontend/packages/common/chat-area/text-grab/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { useGrab } from './hooks/use-grab';
|
||||
export { SelectionData } from './types/selection';
|
||||
export { type GrabPosition } from './types/selection';
|
||||
export { parseMarkdownToGrabNode } from './utils/parse-markdown-to-grab-node';
|
||||
export {
|
||||
GrabElement,
|
||||
GrabElementType,
|
||||
GrabImageElement,
|
||||
GrabLinkElement,
|
||||
GrabNode,
|
||||
GrabText,
|
||||
} from './types/node';
|
||||
export {
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
MESSAGE_SOURCE_ATTRIBUTE_NAME,
|
||||
} from './constants/range';
|
||||
export { isGrabTextNode } from './utils/normalizer/is-grab-text-node';
|
||||
export { isGrabLink } from './utils/normalizer/is-grab-link';
|
||||
export { isGrabImage } from './utils/normalizer/is-grab-image';
|
||||
export { getAncestorAttributeValue } from './utils/get-ancestor-attribute-value';
|
||||
export { getAncestorAttributeNode } from './utils/get-ancestor-attribute-node';
|
||||
export { getHumanizedContentText } from './utils/normalizer/get-humanize-content-text';
|
||||
export { getOriginContentText } from './utils/normalizer/get-origin-content-text';
|
||||
export { Direction } from './types/selection';
|
||||
export { isTouchDevice } from './utils/is-touch-device';
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const enum GrabElementType {
|
||||
IMAGE = 'image',
|
||||
LINK = 'link',
|
||||
}
|
||||
|
||||
export interface GrabText {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface GrabElement {
|
||||
children: GrabNode[];
|
||||
}
|
||||
|
||||
export interface GrabLinkElement extends GrabElement {
|
||||
url: string;
|
||||
type: GrabElementType.LINK;
|
||||
}
|
||||
|
||||
export interface GrabImageElement extends GrabElement {
|
||||
src: string;
|
||||
type: GrabElementType.IMAGE;
|
||||
}
|
||||
|
||||
export type GrabNode =
|
||||
| GrabElement
|
||||
| GrabLinkElement
|
||||
| GrabImageElement
|
||||
| GrabText;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 getOriginContentText } from '../utils/normalizer/get-origin-content-text';
|
||||
import { type getNormalizeNodeList } from '../utils/normalizer/get-normalize-node-list';
|
||||
import { type getHumanizedContentText } from '../utils/normalizer/get-humanize-content-text';
|
||||
|
||||
export interface SelectionData {
|
||||
humanizedContentText: ReturnType<typeof getHumanizedContentText>;
|
||||
originContentText: ReturnType<typeof getOriginContentText>;
|
||||
normalizeSelectionNodeList: ReturnType<typeof getNormalizeNodeList>;
|
||||
nodesAncestorIsMessageBox: boolean;
|
||||
ancestorAttributeValue: string | null;
|
||||
messageSource: number;
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
export interface GrabPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const enum Direction {
|
||||
Forward = 'forward',
|
||||
Backward = 'backward',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
17
frontend/packages/common/chat-area/text-grab/src/typings.d.ts
vendored
Normal file
17
frontend/packages/common/chat-area/text-grab/src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/// <reference types='@coze-arch/bot-typings' />
|
||||
@@ -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 { getPictureNodeUrl } from './get-picture-node-url';
|
||||
|
||||
/**
|
||||
* 获取 TagName 为 Picture 的节点的有效节点
|
||||
* @param childNodes NodeListOf<Node> 子节点列表
|
||||
* @returns Node | null
|
||||
*/
|
||||
export const findPictureValidChildNode = (childNodes: NodeListOf<Node>) =>
|
||||
Array.from(childNodes)
|
||||
.filter(node => getPictureNodeUrl(node))
|
||||
.at(0);
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const getAncestorAttributeNode = (
|
||||
node: Node | null,
|
||||
attributeName: string,
|
||||
): Element | null => {
|
||||
while (node && node !== document) {
|
||||
let attributeValue = null;
|
||||
if (node instanceof Element) {
|
||||
attributeValue = node.attributes.getNamedItem(attributeName)?.value;
|
||||
|
||||
if (attributeValue) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const getAncestorAttributeValue = (
|
||||
node: Node | null,
|
||||
attributeName: string,
|
||||
): string | null => {
|
||||
while (node && node !== document) {
|
||||
let attributeValue = null;
|
||||
if (node instanceof Element) {
|
||||
attributeValue = node.attributes.getNamedItem(attributeName)?.value;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE && attributeValue) {
|
||||
return attributeValue;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取图片 Node 中的 Url
|
||||
* @param node Node 任意Node
|
||||
* @returns string
|
||||
*/
|
||||
export const getPictureNodeUrl = (node: Node) => {
|
||||
if (!('src' in node) || !(typeof node.src === 'string')) {
|
||||
return;
|
||||
}
|
||||
|
||||
return node.src;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const getRectData = ({ selection }: { selection: Selection }) => {
|
||||
if (!selection.rangeCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeRects = range.getClientRects();
|
||||
|
||||
return {
|
||||
rangeRects,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 SelectionData } from '../types/selection';
|
||||
import {
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
MESSAGE_SOURCE_ATTRIBUTE_NAME,
|
||||
} from '../constants/range';
|
||||
import { shouldRefineRange } from './should-refine-range';
|
||||
import { refineRange } from './refine-range/refine-range';
|
||||
import { getOriginContentText } from './normalizer/get-origin-content-text';
|
||||
import { getNormalizeNodeList } from './normalizer/get-normalize-node-list';
|
||||
import { getHumanizedContentText } from './normalizer/get-humanize-content-text';
|
||||
import { hasVisibleSelection } from './helper/is-range-collapsed';
|
||||
import { getSelectionDirection } from './helper/get-selection-direction';
|
||||
import { getAncestorAttributeNode } from './get-ancestor-attribute-node';
|
||||
|
||||
export const getSelectionData = ({
|
||||
selection,
|
||||
hasFix,
|
||||
}: {
|
||||
selection: Selection;
|
||||
hasFix?: boolean;
|
||||
}): SelectionData | undefined => {
|
||||
if (!selection.rangeCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentFragment = range.cloneContents();
|
||||
|
||||
const direction = getSelectionDirection(selection);
|
||||
|
||||
/**
|
||||
* 通过获取特定标识 判断是否是可以划选的元素
|
||||
*/
|
||||
const ancestorNodeWithAttribute = getAncestorAttributeNode(
|
||||
range.commonAncestorContainer.parentNode,
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
);
|
||||
|
||||
// 特定标识
|
||||
const ancestorAttributeValue =
|
||||
ancestorNodeWithAttribute?.attributes.getNamedItem(CONTENT_ATTRIBUTE_NAME)
|
||||
?.value ?? null;
|
||||
|
||||
// 信息来源
|
||||
const messageSource = ancestorNodeWithAttribute?.attributes.getNamedItem(
|
||||
MESSAGE_SOURCE_ATTRIBUTE_NAME,
|
||||
)?.value;
|
||||
|
||||
if (!hasFix) {
|
||||
// 尝试修复选区
|
||||
const needFix = shouldRefineRange(range);
|
||||
|
||||
// 如果修复过,则重新获取执行并返回
|
||||
if (needFix) {
|
||||
const isFix = refineRange({ range });
|
||||
|
||||
if (!isFix) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getSelectionData({
|
||||
selection,
|
||||
hasFix: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasVisibleSelectionResult = hasVisibleSelection(range);
|
||||
|
||||
if (!hasVisibleSelectionResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式化的选区NodeList
|
||||
const normalizeSelectionNodeList = getNormalizeNodeList(
|
||||
documentFragment.childNodes,
|
||||
);
|
||||
|
||||
if (!normalizeSelectionNodeList.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 人性化文本内容
|
||||
const humanizedContentText = getHumanizedContentText(
|
||||
normalizeSelectionNodeList,
|
||||
);
|
||||
|
||||
// 原始文本内容
|
||||
const originContentText = getOriginContentText(normalizeSelectionNodeList);
|
||||
|
||||
// 如果修复选区成功了,那么他们的组件
|
||||
|
||||
return {
|
||||
humanizedContentText,
|
||||
originContentText,
|
||||
normalizeSelectionNodeList,
|
||||
nodesAncestorIsMessageBox: Boolean(ancestorAttributeValue),
|
||||
ancestorAttributeValue,
|
||||
messageSource: Number(messageSource),
|
||||
direction,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 判断节点包含关系
|
||||
* 参考文档「https://developer.mozilla.org/zh-CN/docs/Web/API/Node/compareDocumentPosition」
|
||||
* @param nodeA
|
||||
* @param nodeB
|
||||
*
|
||||
*/
|
||||
export const compareNodePosition = (nodeA: Node, nodeB: Node) => {
|
||||
const comparison = nodeA.compareDocumentPosition(nodeB);
|
||||
|
||||
// 之所以条件跟返回是反的,请参考官方文档,包含关系展示的是B - A的关系
|
||||
if (comparison & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||
return 'contains'; // nodeA 包含 nodeB
|
||||
} else if (comparison & Node.DOCUMENT_POSITION_CONTAINS) {
|
||||
return 'containedBy'; // nodeA 被 nodeB 包含
|
||||
} else if (comparison & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
return 'before'; // nodeA 在 nodeB 之前
|
||||
} else if (comparison & Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
return 'after'; // nodeA 在 nodeB 之后
|
||||
}
|
||||
|
||||
return 'none'; // 节点是相同的或者没有可比较的关系
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 通过 TagName 寻找祖先节点(包括自身)
|
||||
* @param node Node | null
|
||||
* @param tagName 目标 tagName
|
||||
* @returns Node | null
|
||||
*/
|
||||
export const findAncestorNodeByTagName = (
|
||||
node: Node | null,
|
||||
tagName: string,
|
||||
): Element | null => {
|
||||
// 将标签名转换为大写,因为 DOM 中的标签名通常是大写的
|
||||
const upperTagName = tagName.toUpperCase();
|
||||
|
||||
// 遍历节点的祖先节点直到找到匹配的标签名或到达根节点
|
||||
while (node) {
|
||||
// 确保当前节点是元素节点,并且标签名匹配
|
||||
if (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node as Element).tagName === upperTagName
|
||||
) {
|
||||
return node as Element;
|
||||
}
|
||||
// 移动到父节点
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
// 如果没有找到符合条件的祖先节点,返回 null
|
||||
return null;
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 寻找某节点最后一个子节点
|
||||
* @param node Node
|
||||
* @returns Node
|
||||
*/
|
||||
export const findLastChildNode = (node: Node): Node => {
|
||||
while (node.lastChild) {
|
||||
node = node.lastChild;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 { getAncestorAttributeValue } from '../get-ancestor-attribute-value';
|
||||
|
||||
/**
|
||||
* 寻找某节点的最后一个兄弟节点
|
||||
* @param node 寻找节点
|
||||
* @returns
|
||||
*/
|
||||
export const findLastSiblingNode = ({
|
||||
node,
|
||||
scopeAncestorAttributeName,
|
||||
targetAttributeValue,
|
||||
}: {
|
||||
node: Node | null;
|
||||
scopeAncestorAttributeName?: string;
|
||||
targetAttributeValue?: string | null;
|
||||
}): Node | null => {
|
||||
let lastValidSibling: Node | null = null;
|
||||
while (node) {
|
||||
if (
|
||||
scopeAncestorAttributeName &&
|
||||
getAncestorAttributeValue(node, scopeAncestorAttributeName) ===
|
||||
targetAttributeValue
|
||||
) {
|
||||
lastValidSibling = node;
|
||||
}
|
||||
node = node.nextSibling;
|
||||
}
|
||||
return lastValidSibling;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const findNearestAnchor = (
|
||||
node: Node | null,
|
||||
): HTMLAnchorElement | null => {
|
||||
// 从当前节点开始向上遍历
|
||||
while (node) {
|
||||
// 如果当前节点是元素节点并且是<a>标签
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'A') {
|
||||
// 返回这个<a>标签
|
||||
return node as HTMLAnchorElement;
|
||||
}
|
||||
// 向上移动到父节点
|
||||
node = node.parentNode;
|
||||
}
|
||||
// 如果遍历到根节点还没有找到<a>标签,返回null
|
||||
return null;
|
||||
};
|
||||
@@ -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 { compareNodePosition } from './compare-node-position';
|
||||
|
||||
export const findNotContainsPreviousSibling = (
|
||||
node: Node | null,
|
||||
): Node | null => {
|
||||
if (!node || node === document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sibling: Node | null = node.previousSibling ?? node.parentNode;
|
||||
|
||||
while (sibling) {
|
||||
if (sibling === document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取两个节点之间的关系
|
||||
const relationship = compareNodePosition(sibling, node);
|
||||
|
||||
// 如果两个节点之间没有包含关系,则返回当前兄弟节点
|
||||
if (!['containedBy', 'contains'].includes(relationship)) {
|
||||
return sibling;
|
||||
}
|
||||
|
||||
if (!sibling.previousSibling) {
|
||||
sibling = sibling.parentNode;
|
||||
} else {
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// 辅助函数,用于获取选区内的所有节点
|
||||
export const getAllChildNodesInNode = (node: Node): Node[] => {
|
||||
const nodes: Node[] = [];
|
||||
const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ALL, {
|
||||
acceptNode: _node =>
|
||||
node.contains(_node)
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring -- 符合预期,因为要改数据并且允许为空
|
||||
let currentNode: Node | null = treeWalker.currentNode;
|
||||
|
||||
while (currentNode) {
|
||||
nodes.push(currentNode);
|
||||
currentNode = treeWalker.nextNode();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// 辅助函数,用于获取选区内的所有节点
|
||||
export const getAllNodesInRange = (range: Range): Node[] => {
|
||||
const nodes: Node[] = [];
|
||||
const treeWalker = document.createTreeWalker(
|
||||
range.commonAncestorContainer,
|
||||
NodeFilter.SHOW_ALL,
|
||||
{
|
||||
acceptNode: node =>
|
||||
range.intersectsNode(node)
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT,
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring -- 符合预期,因为要改数据并且允许为空
|
||||
let currentNode: Node | null = treeWalker.currentNode;
|
||||
|
||||
while (currentNode) {
|
||||
nodes.push(currentNode);
|
||||
currentNode = treeWalker.nextNode();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const getRangeDirection = (range: Range) => {
|
||||
const position = range.compareBoundaryPoints(Range.START_TO_END, range);
|
||||
|
||||
if (position === 0) {
|
||||
return 'none'; // 选区起点和终点相同,即没有选择文本
|
||||
}
|
||||
|
||||
return position === -1 ? 'backward' : 'forward';
|
||||
};
|
||||
@@ -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 { Direction } from '../../types/selection';
|
||||
import { compareNodePosition } from './compare-node-position';
|
||||
|
||||
export const getSelectionDirection = (selection: Selection): Direction => {
|
||||
// 确保有选区存在
|
||||
if (!selection || selection.isCollapsed) {
|
||||
return Direction.Unknown; // 没有选区或选区未展开
|
||||
}
|
||||
|
||||
const { anchorNode } = selection;
|
||||
const { focusNode } = selection;
|
||||
|
||||
// 确保 anchorNode 和 focusNode 都不为 null
|
||||
if (!anchorNode || !focusNode) {
|
||||
return Direction.Unknown; // 无法确定方向
|
||||
}
|
||||
|
||||
const { anchorOffset } = selection;
|
||||
const { focusOffset } = selection;
|
||||
// 比较 anchor 和 focus 的位置
|
||||
if (anchorNode === focusNode) {
|
||||
// 如果 anchor 和 focus 在同一个节点,通过偏移量判断方向
|
||||
return anchorOffset <= focusOffset ? Direction.Forward : Direction.Backward;
|
||||
} else {
|
||||
// 如果不在同一个节点,使用 Document Position 来判断
|
||||
const position = compareNodePosition(anchorNode, focusNode);
|
||||
|
||||
if (position === 'before') {
|
||||
return Direction.Forward;
|
||||
} else if (position === 'after') {
|
||||
return Direction.Backward;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无法确定方向,返回 'unknown'
|
||||
return Direction.Unknown;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const hasAncestorWithAttribute = (
|
||||
node: Node | null,
|
||||
attributeName: string,
|
||||
): boolean => {
|
||||
while (node && node !== document) {
|
||||
if (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node as Element).attributes.getNamedItem(attributeName)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const hasVisibleSelection = (range: Range): boolean => {
|
||||
// 克隆Range内的所有节点
|
||||
const documentFragment = range.cloneContents();
|
||||
const textNodes: Text[] = [];
|
||||
|
||||
// 递归函数来收集所有文本节点
|
||||
function collectTextNodes(node: Node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
textNodes.push(node as Text);
|
||||
} else {
|
||||
node.childNodes.forEach(collectTextNodes);
|
||||
}
|
||||
}
|
||||
|
||||
// 从文档片段的根节点开始收集文本节点
|
||||
collectTextNodes(documentFragment);
|
||||
|
||||
// 检查收集到的文本节点中是否有非空白的文本
|
||||
return textNodes.some(textNode => /\S/.test(textNode.textContent || ''));
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export function isTouchDevice(): boolean {
|
||||
return 'ontouchend' in document;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 GrabNode } from '../../types/node';
|
||||
import { isGrabTextNode } from './is-grab-text-node';
|
||||
import { isGrabLink } from './is-grab-link';
|
||||
import { isGrabImage } from './is-grab-image';
|
||||
|
||||
/**
|
||||
* 获取人性化文本内容
|
||||
*/
|
||||
export const getHumanizedContentText = (normalizeNodeList: GrabNode[]) => {
|
||||
let content = '';
|
||||
|
||||
for (const node of normalizeNodeList) {
|
||||
if (isGrabTextNode(node)) {
|
||||
content += node.text;
|
||||
} else if (isGrabLink(node)) {
|
||||
content += `[${getHumanizedContentText(node.children)}]`;
|
||||
} else if (isGrabImage(node)) {
|
||||
content += `![${getHumanizedContentText(node.children)}]`;
|
||||
} else {
|
||||
content += getHumanizedContentText(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 { processSpecialNode } from '../process-node/process-special-node';
|
||||
import {
|
||||
GrabElementType,
|
||||
type GrabLinkElement,
|
||||
type GrabImageElement,
|
||||
type GrabNode,
|
||||
type GrabText,
|
||||
} from '../../types/node';
|
||||
|
||||
/**
|
||||
* 获取格式化的 NodeList
|
||||
* @param childNodeList NodeListOf<Node>
|
||||
*/
|
||||
export const getNormalizeNodeList = (childNodeList: NodeListOf<Node>) => {
|
||||
const normalizedNodeList: GrabNode[] = [];
|
||||
|
||||
if (!childNodeList.length) {
|
||||
return normalizedNodeList;
|
||||
}
|
||||
|
||||
for (const childNode of childNodeList) {
|
||||
const specialNode = processSpecialNode(childNode);
|
||||
|
||||
if (specialNode) {
|
||||
const grabNode = generateGrabNode(specialNode);
|
||||
|
||||
grabNode && normalizedNodeList.push(grabNode);
|
||||
continue;
|
||||
} else {
|
||||
const grabNode = generateGrabNode(childNode);
|
||||
grabNode && normalizedNodeList.push(grabNode);
|
||||
}
|
||||
|
||||
const normalizedNodeListInLoop = getNormalizeNodeList(childNode.childNodes);
|
||||
normalizedNodeList.push(...normalizedNodeListInLoop);
|
||||
}
|
||||
|
||||
return normalizedNodeList;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 Grab Node
|
||||
* @param node Node | null
|
||||
*/
|
||||
export const generateGrabNode = (node: Node | null) => {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isTable = ['TH', 'TD'].includes(node.nodeName.toUpperCase());
|
||||
|
||||
// 文本节点
|
||||
if (node.nodeType === node.TEXT_NODE || isTable) {
|
||||
return generateGrabText(node, isTable);
|
||||
}
|
||||
|
||||
// 元素节点
|
||||
if (node.nodeType === node.ELEMENT_NODE && node instanceof Element) {
|
||||
return generateGrabElement(node);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 Text 节点
|
||||
* @param node Node | null
|
||||
*/
|
||||
export const generateGrabText = (node: Node | null, isTable?: boolean) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTextContent = node.textContent ?? '';
|
||||
|
||||
const text = isTable ? ` ${safeTextContent} ` : safeTextContent;
|
||||
|
||||
const grabText: GrabText = {
|
||||
text,
|
||||
};
|
||||
|
||||
return grabText;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 Element 节点
|
||||
* @param node Element | null
|
||||
*/
|
||||
export const generateGrabElement = (node: Element | null) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['IMG', 'SOURCE'].includes(node.tagName.toUpperCase())) {
|
||||
const grabText = generateGrabText(node);
|
||||
|
||||
const grabImageElement: GrabImageElement = {
|
||||
type: GrabElementType.IMAGE,
|
||||
src: 'src' in node && typeof node.src === 'string' ? node.src : '',
|
||||
children: grabText ? [grabText] : [],
|
||||
};
|
||||
|
||||
return grabImageElement;
|
||||
}
|
||||
|
||||
if (node.tagName.toUpperCase() === 'A') {
|
||||
const grabText = generateGrabText(node);
|
||||
const grabLinkElement: GrabLinkElement = {
|
||||
type: GrabElementType.LINK,
|
||||
url: 'href' in node && typeof node.href === 'string' ? node.href : '',
|
||||
children: grabText ? [grabText] : [],
|
||||
};
|
||||
|
||||
return grabLinkElement;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取格式化的 SelectionNodeList 数据
|
||||
* 获取喂给大模型的文本内容
|
||||
*/
|
||||
import { type GrabNode } from '../../types/node';
|
||||
import { isGrabTextNode } from './is-grab-text-node';
|
||||
import { isGrabLink } from './is-grab-link';
|
||||
import { isGrabImage } from './is-grab-image';
|
||||
|
||||
export const getOriginContentText = (normalizeNodeList: GrabNode[]) => {
|
||||
let content = '';
|
||||
|
||||
for (const node of normalizeNodeList) {
|
||||
if (isGrabTextNode(node)) {
|
||||
content += node.text;
|
||||
} else if (isGrabLink(node)) {
|
||||
content += `[${getOriginContentText(node.children)}](${node.url})`;
|
||||
} else if (isGrabImage(node)) {
|
||||
content += ``;
|
||||
} else {
|
||||
content += getOriginContentText(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 GrabElement, type GrabNode } from '../../types/node';
|
||||
|
||||
export const isGrabElement = (node: GrabNode): node is GrabElement =>
|
||||
node && 'children' in node && 'type' in node;
|
||||
@@ -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 {
|
||||
GrabElementType,
|
||||
type GrabImageElement,
|
||||
type GrabNode,
|
||||
} from '../../types/node';
|
||||
|
||||
export const isGrabImage = (node: GrabNode): node is GrabImageElement =>
|
||||
node &&
|
||||
'src' in node &&
|
||||
'type' in node &&
|
||||
node.type === GrabElementType.IMAGE;
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 {
|
||||
GrabElementType,
|
||||
type GrabLinkElement,
|
||||
type GrabNode,
|
||||
} from '../../types/node';
|
||||
|
||||
export const isGrabLink = (node: GrabNode): node is GrabLinkElement =>
|
||||
node && 'url' in node && 'type' in node && node.type === GrabElementType.LINK;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 GrabNode, type GrabText } from '../../types/node';
|
||||
|
||||
export const isGrabTextNode = (node: GrabNode): node is GrabText =>
|
||||
node && 'text' in node;
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 { parseMarkdownHelper } from '@coze-common/chat-area-utils';
|
||||
import { parseMarkdown } from '@coze-arch/bot-md-box-adapter/lazy';
|
||||
|
||||
import { GrabElementType, type GrabNode } from '../types/node';
|
||||
|
||||
const { isImage, isLink, isParent, isText } = parseMarkdownHelper;
|
||||
|
||||
/**
|
||||
* 获取GrabNode节点
|
||||
* @param markdown string
|
||||
* @returns GrabNode[]
|
||||
*/
|
||||
export const parseMarkdownToGrabNode = (markdown: string) => {
|
||||
const ast = parseMarkdown(markdown);
|
||||
|
||||
return getGrabNodeFromAst(ast);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从Markdown的AST解析成GrabNode节点
|
||||
* @param ast markdown ast by parseMarkdown (md-box)
|
||||
* @returns GrabNode[]
|
||||
*/
|
||||
export const getGrabNodeFromAst = (ast: unknown): GrabNode[] => {
|
||||
const normalizedNodeList: GrabNode[] = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 符合预期
|
||||
const traverseAst = (_ast: any) => {
|
||||
if (isText(_ast)) {
|
||||
normalizedNodeList.push({
|
||||
text: _ast.value,
|
||||
});
|
||||
} else if (isLink(_ast)) {
|
||||
const children = _ast.children.map(getGrabNodeFromAst).flat(1);
|
||||
normalizedNodeList.push({
|
||||
type: GrabElementType.LINK,
|
||||
url: _ast.url,
|
||||
children,
|
||||
});
|
||||
} else if (isImage(_ast)) {
|
||||
normalizedNodeList.push({
|
||||
type: GrabElementType.IMAGE,
|
||||
src: _ast.url,
|
||||
children: [
|
||||
{
|
||||
text: _ast.alt ?? '',
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (isParent(_ast)) {
|
||||
_ast.children.forEach(traverseAst);
|
||||
}
|
||||
};
|
||||
|
||||
traverseAst(ast);
|
||||
|
||||
return normalizedNodeList;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 { processSpecialNode } from './process-special-node';
|
||||
|
||||
export const processChildNode = (childNodes: NodeListOf<Node>) => {
|
||||
const childNodeList: Node[] = [];
|
||||
|
||||
if (!childNodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childNode of childNodes) {
|
||||
const specialNode = processSpecialNode(childNode);
|
||||
|
||||
if (specialNode) {
|
||||
childNodeList.push(specialNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = processChildNode(childNode.childNodes);
|
||||
|
||||
if (!result) {
|
||||
childNodeList.push(childNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
childNodeList.push(...result);
|
||||
}
|
||||
|
||||
return childNodeList;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 { findPictureValidChildNode } from '../find-picture-valid-child-node';
|
||||
|
||||
/**
|
||||
* 处理特殊 Node 节点数据
|
||||
* @param node Node
|
||||
* @returns node | undefined
|
||||
*/
|
||||
export const processSpecialNode = (node: Node) => {
|
||||
// 针对picture类型的特殊优化
|
||||
if (node.nodeName.toUpperCase() === 'PICTURE') {
|
||||
const pictureNode = findPictureValidChildNode(node.childNodes);
|
||||
|
||||
if (pictureNode) {
|
||||
return pictureNode;
|
||||
}
|
||||
}
|
||||
|
||||
// 针对链接的特殊优化
|
||||
if (node.nodeName.toUpperCase() === 'A') {
|
||||
return node;
|
||||
}
|
||||
|
||||
// 针对表格的特殊优化
|
||||
if (['TH', 'TD'].includes(node.nodeName.toUpperCase())) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { getAllChildNodesInNode } from '../helper/get-all-child-nodes-in-node';
|
||||
import { findNotContainsPreviousSibling } from '../helper/find-not-contains-previous-sibling';
|
||||
|
||||
export const fixEndEmpty = ({
|
||||
range,
|
||||
startNode,
|
||||
endNode,
|
||||
endOffset,
|
||||
}: {
|
||||
range: Range;
|
||||
startNode: Node;
|
||||
endNode: Node;
|
||||
endOffset: number;
|
||||
}): boolean => {
|
||||
// 检查是否需要修复:结束节点和开始节点不同且结束偏移量为0
|
||||
if (startNode === endNode || endOffset !== 0) {
|
||||
return false; // 不需要修复
|
||||
}
|
||||
|
||||
// 初始化当前节点为结束节点的前一个兄弟节点
|
||||
let currentNode: Node | null = findNotContainsPreviousSibling(endNode);
|
||||
|
||||
// 寻找一个有效的非空前一个兄弟节点
|
||||
while (currentNode) {
|
||||
if (currentNode.nodeType === Node.TEXT_NODE) {
|
||||
// 如果是文本节点,检查是否非空
|
||||
const textContent = currentNode.textContent?.trim();
|
||||
if (textContent && currentNode.textContent?.length) {
|
||||
// 非空,修复选区结束位置
|
||||
range.setEnd(currentNode, currentNode.textContent.length);
|
||||
return true;
|
||||
}
|
||||
} else if (currentNode.nodeType === Node.ELEMENT_NODE) {
|
||||
// 如果是元素节点,检查是否有可见内容
|
||||
const textContent = currentNode.textContent?.trim();
|
||||
if (textContent) {
|
||||
// 有可见内容,尝试更精确地设置结束位置
|
||||
// 如果元素内部有文本节点,尝试定位到最后一个文本节点
|
||||
let lastTextNode: Node | null = null;
|
||||
|
||||
const allChildNodes = getAllChildNodesInNode(currentNode);
|
||||
|
||||
for (const child of allChildNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
|
||||
lastTextNode = child as Node;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastTextNode && lastTextNode.textContent) {
|
||||
range.setEnd(lastTextNode, lastTextNode.textContent.length);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果当前节点为空或不满足条件,继续向前/向上遍历
|
||||
currentNode = findNotContainsPreviousSibling(currentNode);
|
||||
}
|
||||
|
||||
// 如果遍历完所有前一个兄弟节点都没有找到合适的节点,返回false
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { findLastChildNode } from '../helper/find-last-child-node';
|
||||
import { getAncestorAttributeValue } from '../get-ancestor-attribute-value';
|
||||
|
||||
export const fixEndNode = ({
|
||||
range,
|
||||
targetAttributeName,
|
||||
targetAttributeValue,
|
||||
}: {
|
||||
range: Range;
|
||||
targetAttributeName: string;
|
||||
targetAttributeValue: string;
|
||||
}) => {
|
||||
let endNode: Node | null = range.endContainer;
|
||||
let { endOffset } = range;
|
||||
|
||||
// 确保结束节点符合条件
|
||||
while (
|
||||
endNode &&
|
||||
!(
|
||||
getAncestorAttributeValue(endNode, targetAttributeName) ===
|
||||
targetAttributeValue
|
||||
)
|
||||
) {
|
||||
if (endNode.nextSibling) {
|
||||
endNode = endNode.nextSibling;
|
||||
endOffset = 0; // 从下一个兄弟节点的开始位置开始
|
||||
} else if (endNode.parentNode && endNode.parentNode !== document) {
|
||||
endNode = endNode.parentNode;
|
||||
endOffset = endNode
|
||||
? findLastChildNode(endNode).textContent?.length ?? 0
|
||||
: 0; // 从父节点的最后位置开始
|
||||
} else {
|
||||
// 没有符合条件的结束节点
|
||||
endNode = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
endNode,
|
||||
endOffset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 { findNearestAnchor } from '../helper/find-nearest-link-node';
|
||||
|
||||
export const fixLink = (range: Range, startNode: Node, endNode: Node) => {
|
||||
const startAnchor = findNearestAnchor(startNode);
|
||||
const endAnchor = findNearestAnchor(endNode);
|
||||
|
||||
let isFix = false;
|
||||
// 如果起始节点在链接内,将选区的起点设置为链接的开始
|
||||
if (startAnchor) {
|
||||
range.setStartBefore(startAnchor);
|
||||
isFix = true;
|
||||
}
|
||||
|
||||
// 如果结束节点在链接内,将选区的终点设置为链接的结束
|
||||
if (endAnchor) {
|
||||
range.setEndAfter(endAnchor);
|
||||
isFix = true;
|
||||
}
|
||||
|
||||
return isFix;
|
||||
};
|
||||
|
||||
/**
|
||||
* 1. 链接[A ...文字... 链]接B
|
||||
* 2. 链接A ...文[字... 链]接B
|
||||
* 3. ...文[字 链]接B
|
||||
* 4. 链[接]B
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 { getAncestorAttributeValue } from '../get-ancestor-attribute-value';
|
||||
|
||||
export const fixStartNode = ({
|
||||
range,
|
||||
targetAttributeName,
|
||||
targetAttributeValue,
|
||||
}: {
|
||||
range: Range;
|
||||
targetAttributeName: string;
|
||||
targetAttributeValue: string;
|
||||
}) => {
|
||||
let startNode: Node | null = range.startContainer;
|
||||
let { startOffset } = range;
|
||||
|
||||
// 确保起始节点符合条件
|
||||
while (
|
||||
startNode &&
|
||||
!(
|
||||
getAncestorAttributeValue(startNode, targetAttributeName) ===
|
||||
targetAttributeValue
|
||||
)
|
||||
) {
|
||||
if (startNode.previousSibling) {
|
||||
startNode = startNode.previousSibling;
|
||||
startOffset = 0; // 从前一个兄弟节点的开始位置开始
|
||||
} else if (startNode.parentNode && startNode.parentNode !== document) {
|
||||
startNode = startNode.parentNode;
|
||||
startOffset = 0; // 从父节点的开始位置开始
|
||||
} else {
|
||||
// 没有符合条件的起始节点
|
||||
startNode = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startNode,
|
||||
startOffset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 修复选区的实际函数
|
||||
* @param range Range 选区
|
||||
*/
|
||||
import { findLastSiblingNode } from '../helper/find-last-sibling-node';
|
||||
import { findLastChildNode } from '../helper/find-last-child-node';
|
||||
import { findAncestorNodeByTagName } from '../helper/find-ancestor-node-by-tag-name';
|
||||
import { compareNodePosition } from '../helper/compare-node-position';
|
||||
import { getAncestorAttributeValue } from '../get-ancestor-attribute-value';
|
||||
import { CONTENT_ATTRIBUTE_NAME } from '../../constants/range';
|
||||
import { fixStartNode } from './fix-start-node';
|
||||
import { fixLink } from './fix-link';
|
||||
import { fixEndNode } from './fix-end-node';
|
||||
import { fixEndEmpty } from './fix-end-empty';
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
export const refineRange = ({ range }: { range: Range }): boolean => {
|
||||
// 初期算法只用StartNode当作选区的开始节点
|
||||
const targetAttributeValue = getAncestorAttributeValue(
|
||||
range.startContainer,
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
);
|
||||
|
||||
if (!targetAttributeValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { startNode, startOffset } = fixStartNode({
|
||||
range,
|
||||
targetAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
|
||||
let { endNode, endOffset } = fixEndNode({
|
||||
range,
|
||||
targetAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
|
||||
// 如果找到了起始节点,但是没找到结束节点,那么继续尝试使用开始节点的最后一个兄弟元素修复选区
|
||||
if (startNode && !endNode) {
|
||||
const { parentNode } = startNode;
|
||||
let lastSibling: Node | null = null;
|
||||
|
||||
// 尝试找到最近的<li>或<a>标签
|
||||
const liParentNode = findAncestorNodeByTagName(parentNode, 'LI');
|
||||
const aParentNode = liParentNode
|
||||
? findAncestorNodeByTagName(liParentNode, 'A')
|
||||
: findAncestorNodeByTagName(parentNode, 'A');
|
||||
// 找到最近的<div>标签
|
||||
const divParentNode = findAncestorNodeByTagName(parentNode, 'DIV');
|
||||
|
||||
// 根据找到的节点类型决定如何查找最后一个兄弟节点
|
||||
if (aParentNode) {
|
||||
// 如果找到了<a>,使用它的父节点
|
||||
lastSibling = findLastSiblingNode({
|
||||
node: aParentNode,
|
||||
scopeAncestorAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
} else if (liParentNode) {
|
||||
// 如果找到了<li>,使用它的父节点
|
||||
lastSibling = findLastSiblingNode({
|
||||
node: liParentNode,
|
||||
scopeAncestorAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
} else if (divParentNode) {
|
||||
// 如果找到了<div>,使用他的父节点(例如代码块的情况)
|
||||
lastSibling = findLastSiblingNode({
|
||||
node: divParentNode,
|
||||
scopeAncestorAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
} else {
|
||||
// 否则,使用起始节点
|
||||
lastSibling = findLastSiblingNode({
|
||||
node: startNode,
|
||||
scopeAncestorAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果起始节点和找到的兄弟节点一样,那么就用其实节点的父元素去找他最后的一个节点
|
||||
if (startNode === lastSibling) {
|
||||
lastSibling = findLastSiblingNode({
|
||||
node: parentNode,
|
||||
scopeAncestorAttributeName: CONTENT_ATTRIBUTE_NAME,
|
||||
targetAttributeValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (lastSibling) {
|
||||
endNode = lastSibling;
|
||||
endOffset =
|
||||
endNode.nodeType === Node.TEXT_NODE
|
||||
? (endNode as Text).length
|
||||
: findLastChildNode(endNode).textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果起始节点和结束节点都找到了,修正选区
|
||||
if (startNode && endNode) {
|
||||
const relation = compareNodePosition(startNode, endNode);
|
||||
|
||||
let isFix = false;
|
||||
|
||||
if (['before', 'none'].includes(relation)) {
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
|
||||
isFix = true;
|
||||
}
|
||||
|
||||
const isFixLink = fixLink(range, startNode, endNode);
|
||||
|
||||
const isFixEndEmpty = fixEndEmpty({ range, startNode, endNode, endOffset });
|
||||
|
||||
return isFix || isFixLink || isFixEndEmpty;
|
||||
}
|
||||
|
||||
range.collapse(false);
|
||||
return false;
|
||||
};
|
||||
@@ -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 { CONTENT_ATTRIBUTE_NAME } from '../constants/range';
|
||||
import { getAllNodesInRange } from './helper/get-all-nodes-in-range';
|
||||
import { findAncestorNodeByTagName } from './helper/find-ancestor-node-by-tag-name';
|
||||
import { getAncestorAttributeValue } from './get-ancestor-attribute-value';
|
||||
|
||||
export const shouldRefineRange = (range: Range): boolean => {
|
||||
// 获取选区的所有节点
|
||||
const nodes = getAllNodesInRange(range);
|
||||
|
||||
let validNodeLength = 0;
|
||||
|
||||
let hasNodeInLink = false;
|
||||
|
||||
// 遍历所有节点,检查它们的祖先是否都有特定类名属性
|
||||
for (const node of nodes) {
|
||||
const attributeValue = getAncestorAttributeValue(
|
||||
node,
|
||||
CONTENT_ATTRIBUTE_NAME,
|
||||
);
|
||||
|
||||
// 如果不存在才需要覆盖 hasNodeInLink,确保找到有节点在链接中
|
||||
if (!hasNodeInLink) {
|
||||
hasNodeInLink = Boolean(findAncestorNodeByTagName(node, 'A'));
|
||||
}
|
||||
|
||||
if (attributeValue) {
|
||||
validNodeLength++;
|
||||
}
|
||||
}
|
||||
|
||||
const { endOffset } = range;
|
||||
|
||||
return validNodeLength !== nodes.length || hasNodeInLink || endOffset === 0;
|
||||
};
|
||||
Reference in New Issue
Block a user