feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
.action-bar {
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.icon-button {
|
||||
font-size: 16px;
|
||||
// rgb mark
|
||||
color: #060709;
|
||||
|
||||
&.icon-button-active {
|
||||
// rgb mark
|
||||
background-color: var(--semi-color-fill-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/no-deep-relative-import -- svg */
|
||||
import { useState, type ComponentProps, type CSSProperties } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Toast, Tooltip, UIButton, Upload } from '@coze-arch/bot-semi';
|
||||
import { IconLinkStroked } from '@coze-arch/bot-icons';
|
||||
|
||||
import {
|
||||
InsertLinkPopover,
|
||||
type InsertLinkPopoverProps,
|
||||
} from '../insert-link-popover';
|
||||
import { getFixedVariableTemplate } from '../../utils/onboarding-variable';
|
||||
import { type TriggerAction } from '../../type';
|
||||
import { getIsFileFormatValid } from '../../helpers/get-is-file-format-valid';
|
||||
import { OnboardingVariable } from '../../constant/onboarding-variable';
|
||||
import { getFileSizeReachLimitI18n, MAX_FILE_SIZE } from '../../constant/file';
|
||||
import { ReactComponent as IconMemberOutlined } from '../../../../assets/icon_member_outlined.svg';
|
||||
import { ReactComponent as IconImageOutlined } from '../../../../assets/icon_image_outlined.svg';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
const SHOW_ADD_NAME_BTN = false;
|
||||
|
||||
export interface ActionBarProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onTriggerAction?: (action: TriggerAction) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconButtonProps: ComponentProps<typeof UIButton> = {
|
||||
size: 'small',
|
||||
type: 'tertiary',
|
||||
theme: 'borderless',
|
||||
className: styles['icon-button'],
|
||||
};
|
||||
export const ActionBar: React.FC<ActionBarProps> = ({
|
||||
className,
|
||||
style,
|
||||
onTriggerAction,
|
||||
disabled,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const togglePopoverVisible = () => {
|
||||
setVisible(e => !e);
|
||||
};
|
||||
|
||||
const closePopover = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const onConfirmInsertLink: InsertLinkPopoverProps['onConfirm'] = param => {
|
||||
closePopover();
|
||||
onTriggerAction?.({ type: 'link', sync: true, payload: param });
|
||||
};
|
||||
|
||||
const onInsertImage = (file: File) => {
|
||||
onTriggerAction?.({ type: 'image', payload: { file }, sync: false });
|
||||
};
|
||||
|
||||
const onInsertVariable = () => {
|
||||
onTriggerAction?.({
|
||||
type: 'variable',
|
||||
payload: {
|
||||
variableTemplate: getFixedVariableTemplate(
|
||||
OnboardingVariable.USER_NAME,
|
||||
),
|
||||
},
|
||||
sync: true,
|
||||
});
|
||||
};
|
||||
|
||||
const showFileTypeInvalidToast = () =>
|
||||
Toast.warning({
|
||||
showClose: false,
|
||||
content: I18n.t('file_format_not_supported'),
|
||||
});
|
||||
|
||||
const showFileSizeInvalidToast = () =>
|
||||
Toast.warning({
|
||||
showClose: false,
|
||||
content: getFileSizeReachLimitI18n(),
|
||||
});
|
||||
const onFileChange = (files: File[]) => {
|
||||
const file = files.at(0);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getIsFileFormatValid(file)) {
|
||||
showFileTypeInvalidToast();
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showFileSizeInvalidToast();
|
||||
return;
|
||||
}
|
||||
onInsertImage(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(className, styles['action-bar'])} style={style}>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
limit={1}
|
||||
onFileChange={onFileChange}
|
||||
action="/"
|
||||
fileList={[]}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Tooltip disableFocusListener content={I18n.t('add_image')}>
|
||||
<UIButton
|
||||
{...iconButtonProps}
|
||||
icon={<IconImageOutlined />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
|
||||
<InsertLinkPopover
|
||||
visible={visible}
|
||||
onClickOutSide={closePopover}
|
||||
onConfirm={onConfirmInsertLink}
|
||||
>
|
||||
<span>
|
||||
<Tooltip disableFocusListener content={I18n.t('add_link')}>
|
||||
<UIButton
|
||||
onClick={togglePopoverVisible}
|
||||
{...iconButtonProps}
|
||||
className={classNames(
|
||||
visible && styles['icon-button-active'],
|
||||
styles['icon-button'],
|
||||
)}
|
||||
icon={<IconLinkStroked />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</InsertLinkPopover>
|
||||
|
||||
{/* 暂时禁用,后续放开 */}
|
||||
{SHOW_ADD_NAME_BTN && (
|
||||
<Tooltip content={I18n.t('add_nickname')}>
|
||||
<UIButton
|
||||
{...iconButtonProps}
|
||||
icon={<IconMemberOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={onInsertVariable}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
.popover-content {
|
||||
display: flex;
|
||||
column-gap: 12px;
|
||||
justify-content: space-between;
|
||||
|
||||
box-sizing: content-box;
|
||||
width: 398px;
|
||||
height: 128px;
|
||||
padding: 24px;
|
||||
|
||||
// rgb mark
|
||||
background: #F4F4F6;
|
||||
|
||||
|
||||
.input-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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 ComponentProps, type PropsWithChildren } from 'react';
|
||||
|
||||
import { Form, Popover, UIButton, UIInput } from '@coze-arch/bot-semi';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export interface InsertLinkPopoverProps
|
||||
extends Pick<ComponentProps<typeof Popover>, 'onClickOutSide' | 'visible'> {
|
||||
onConfirm?: (param: { link: string; text: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全受控
|
||||
*/
|
||||
export const InsertLinkPopover: React.FC<
|
||||
PropsWithChildren<InsertLinkPopoverProps>
|
||||
> = ({ children, visible, onClickOutSide, onConfirm }) => (
|
||||
<Popover
|
||||
trigger="custom"
|
||||
visible={visible}
|
||||
onClickOutSide={onClickOutSide}
|
||||
showArrow={false}
|
||||
position="topRight"
|
||||
content={<Content onConfirm={onConfirm} />}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const Content: React.FC<Pick<InsertLinkPopoverProps, 'onConfirm'>> = ({
|
||||
onConfirm: inputOnConfirm,
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [link, setLink] = useState('');
|
||||
const onConfirm = () => {
|
||||
clearInput();
|
||||
inputOnConfirm?.({ text, link });
|
||||
};
|
||||
const clearInput = () => {
|
||||
setLink('');
|
||||
setText('');
|
||||
};
|
||||
return (
|
||||
<div className={styles['popover-content']}>
|
||||
<div className={styles['input-content']}>
|
||||
<div className={styles['input-row']}>
|
||||
<Form.Label required text="Text" />
|
||||
<UIInput value={text} onChange={setText} />
|
||||
</div>
|
||||
<div className={styles['input-row']}>
|
||||
<Form.Label required text="Link" />
|
||||
<UIInput value={link} onChange={setLink} />
|
||||
</div>
|
||||
</div>
|
||||
<UIButton
|
||||
onClick={onConfirm}
|
||||
disabled={!text || !link}
|
||||
theme="solid"
|
||||
className={styles['confirm-button']}
|
||||
>
|
||||
Confirm
|
||||
</UIButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: rgb(255 255 255 / 90%);
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
color: rgba(6, 7, 9, 50%);
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 250px;
|
||||
height: 6px;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Progress } from '@coze-arch/bot-semi';
|
||||
|
||||
import { type UploadState } from '../../type';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export const UploadProgressMask: React.FC<UploadState> = ({
|
||||
fileName,
|
||||
percent,
|
||||
}) => (
|
||||
<div className={styles.mask}>
|
||||
<div className={styles.text}>
|
||||
{I18n.t('uploading_filename', { filename: fileName })}
|
||||
</div>
|
||||
<Progress className={styles.progress} percent={percent} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 { I18n } from '@coze-arch/i18n';
|
||||
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
export const getFileSizeReachLimitI18n = () =>
|
||||
I18n.t('file_too_large', {
|
||||
max_size: '20MB',
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 enum OnboardingVariable {
|
||||
USER_NAME = 'user_name',
|
||||
}
|
||||
|
||||
export type OnboardingVariableMap = Record<OnboardingVariable, string>;
|
||||
@@ -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 getInsertTextAtPosition = ({
|
||||
text,
|
||||
insertText,
|
||||
position,
|
||||
}: {
|
||||
text: string;
|
||||
insertText: string;
|
||||
position: number;
|
||||
}): string => `${text.slice(0, position)}${insertText}${text.slice(position)}`;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { primitiveExhaustiveCheck } from '../utils/exhaustive-check';
|
||||
import { type SyncAction } from '../type';
|
||||
import { getMarkdownLink } from './get-markdown-link';
|
||||
|
||||
export const getSyncInsertText = (action: SyncAction): string => {
|
||||
const { type, payload } = action;
|
||||
if (type === 'link') {
|
||||
const { text, link } = payload;
|
||||
return getMarkdownLink({ text, link });
|
||||
}
|
||||
if (type === 'variable') {
|
||||
return payload.variableTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不应该走到这里
|
||||
*/
|
||||
primitiveExhaustiveCheck(type);
|
||||
return '';
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 getIsFileFormatValid = (file: File) =>
|
||||
file.type.startsWith('image/') && file.type !== 'image/svg+xml';
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 getMarkdownImageLink = ({
|
||||
fileName,
|
||||
link,
|
||||
}: {
|
||||
fileName: string;
|
||||
link: string;
|
||||
}) => ``;
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 getMarkdownLink = ({
|
||||
text,
|
||||
link,
|
||||
}: {
|
||||
text: string;
|
||||
link: string;
|
||||
}) => `[${text}](${link})`;
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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 {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEventHandler,
|
||||
} from 'react';
|
||||
|
||||
import useEventCallback from 'use-event-callback';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { useDragAndPasteUpload } from '@coze-arch/bot-hooks';
|
||||
|
||||
import { primitiveExhaustiveCheck } from '../utils/exhaustive-check';
|
||||
import { type AsyncAction, type SyncAction } from '../type';
|
||||
import { getMarkdownImageLink } from '../helpers/get-markdown-image-link';
|
||||
import { getIsFileFormatValid } from '../helpers/get-is-file-format-valid';
|
||||
import { getInsertTextAtPosition } from '../helpers/get-insert-text-at-position';
|
||||
import { getSyncInsertText } from '../helpers/get-insert-text';
|
||||
import { MAX_FILE_SIZE, getFileSizeReachLimitI18n } from '../constant/file';
|
||||
import { type ActionBarProps } from '../components/action-bar';
|
||||
import { type MarkdownEditorProps } from '..';
|
||||
import { useUpload } from './use-upload-file';
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export const useMarkdownEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
getUserId = () => '',
|
||||
customUpload,
|
||||
}: MarkdownEditorProps) => {
|
||||
const onTriggerSyncAction = (action: SyncAction) => {
|
||||
handleInsertText(getSyncInsertText(action));
|
||||
};
|
||||
|
||||
const onTriggerAsyncAction = (action: AsyncAction) => {
|
||||
const { type, payload } = action;
|
||||
if (type === 'image') {
|
||||
const { file } = payload;
|
||||
return uploadFileList([file]);
|
||||
}
|
||||
|
||||
primitiveExhaustiveCheck(type);
|
||||
};
|
||||
|
||||
const onTriggerAction: ActionBarProps['onTriggerAction'] = props => {
|
||||
if (props.sync) {
|
||||
return onTriggerSyncAction(props);
|
||||
}
|
||||
return onTriggerAsyncAction(props);
|
||||
};
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const dragTargetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onUploadAllSuccess = useEventCallback(
|
||||
({ url, fileName }: { url: string; fileName: string }) => {
|
||||
handleInsertText(
|
||||
getMarkdownImageLink({
|
||||
fileName,
|
||||
link: url,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 判断使用内置上传方法 or 自定义
|
||||
const selectUploadMethod = () => {
|
||||
if (customUpload) {
|
||||
return customUpload({
|
||||
onUploadAllSuccess,
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- linter-disable-autofix
|
||||
return useUpload({
|
||||
getUserId,
|
||||
onUploadAllSuccess,
|
||||
});
|
||||
}
|
||||
};
|
||||
const { uploadFileList, uploadState } = selectUploadMethod();
|
||||
|
||||
const { isDragOver } = useDragAndPasteUpload({
|
||||
ref: dragTargetRef,
|
||||
onUpload: fileList => {
|
||||
const file = fileList.at(0);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
onTriggerAction({ type: 'image', sync: false, payload: { file } });
|
||||
},
|
||||
disableDrag: Boolean(uploadState),
|
||||
disablePaste: Boolean(uploadState),
|
||||
fileLimit: 1,
|
||||
maxFileSize: MAX_FILE_SIZE,
|
||||
isFileFormatValid: getIsFileFormatValid,
|
||||
getExistingFileCount: () => 0,
|
||||
closeDelay: undefined,
|
||||
invalidFormatMessage: I18n.t('file_format_not_supported'),
|
||||
invalidSizeMessage: getFileSizeReachLimitI18n(),
|
||||
fileExceedsMessage: I18n.t('files_exceeds_limit'),
|
||||
});
|
||||
|
||||
const [wrapInsertionIndex, setWrapInsertionIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (wrapInsertionIndex === null || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.current.selectionStart = wrapInsertionIndex;
|
||||
ref.current.selectionEnd = wrapInsertionIndex;
|
||||
setWrapInsertionIndex(null);
|
||||
}, [ref.current, wrapInsertionIndex, value]);
|
||||
console.log('outter value', { value });
|
||||
|
||||
const onTextareaChange: ChangeEventHandler<HTMLTextAreaElement> = e => {
|
||||
console.log('onTextareaChange', { value: e.target.value });
|
||||
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
const handleInsertText = (insertText: string) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
ref.current.focus();
|
||||
const { selectionEnd } = ref.current;
|
||||
/**
|
||||
* 选中文字时点击 action bar, 将内容插入到文字的末尾
|
||||
*/
|
||||
console.log('handleInsertText', { value, insertText, selectionEnd });
|
||||
|
||||
const insertTextAtPosition = getInsertTextAtPosition({
|
||||
text: value,
|
||||
insertText,
|
||||
position: selectionEnd,
|
||||
});
|
||||
onChange(insertTextAtPosition);
|
||||
setWrapInsertionIndex(selectionEnd + insertText.length);
|
||||
};
|
||||
|
||||
return {
|
||||
textAreaRef: ref,
|
||||
onTextareaChange,
|
||||
onTriggerAction,
|
||||
dragTargetRef,
|
||||
uploadState,
|
||||
isDragOver,
|
||||
};
|
||||
};
|
||||
@@ -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 { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { withSlardarIdButton } from '@coze-studio/bot-utils';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Toast } from '@coze-arch/bot-semi';
|
||||
|
||||
import { type UploadState } from '../type';
|
||||
import { UploadController } from '../service/upload-controller';
|
||||
|
||||
/**
|
||||
* 暂时没有场景,所以这里将多实例、一次行上传多文件的能力屏蔽了
|
||||
*/
|
||||
export const useUpload = ({
|
||||
getUserId,
|
||||
onUploadAllSuccess,
|
||||
}: {
|
||||
getUserId: () => string;
|
||||
onUploadAllSuccess: (param: { url: string; fileName: string }) => void;
|
||||
}) => {
|
||||
const [uploadState, setUploadState] = useState<UploadState | null>(null);
|
||||
|
||||
const uploadControllerMap = useRef<Record<string, UploadController>>({});
|
||||
|
||||
const clearState = () => setUploadState(null);
|
||||
|
||||
const deleteUploadControllerById = (id: string) => {
|
||||
delete uploadControllerMap.current[id];
|
||||
};
|
||||
const cancelUploadById = (id: string) => {
|
||||
const controller = uploadControllerMap.current[id];
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
controller.cancel();
|
||||
deleteUploadControllerById(id);
|
||||
};
|
||||
|
||||
const handleError = (_e: unknown, controllerId: string) => {
|
||||
clearState();
|
||||
cancelUploadById(controllerId);
|
||||
Toast.error({
|
||||
content: withSlardarIdButton(I18n.t('Upload_failed')),
|
||||
showClose: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
setUploadState(null);
|
||||
};
|
||||
|
||||
const handleProgress = (percent: number) => {
|
||||
setUploadState(state => {
|
||||
if (!state) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, percent };
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartUpload = (fileName: string) =>
|
||||
setUploadState({ fileName, percent: 0 });
|
||||
|
||||
const uploadFileList = (fileList: File[]) => {
|
||||
if (uploadState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controllerId = nanoid();
|
||||
|
||||
const file = fileList.at(0);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
handleStartUpload(file.name);
|
||||
|
||||
uploadControllerMap.current[controllerId] = new UploadController({
|
||||
fileList,
|
||||
controllerId,
|
||||
userId: getUserId(),
|
||||
onProgress: event => {
|
||||
handleProgress(event.percent);
|
||||
},
|
||||
onComplete: event => {
|
||||
handleUploadSuccess();
|
||||
onUploadAllSuccess(event);
|
||||
},
|
||||
onUploadError: handleError,
|
||||
onGetTokenError: handleError,
|
||||
onGetUploadInstanceError: handleError,
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllSideEffect = () => {
|
||||
Object.entries(uploadControllerMap.current).forEach(([, controller]) =>
|
||||
controller.cancel(),
|
||||
);
|
||||
uploadControllerMap.current = {};
|
||||
};
|
||||
|
||||
useEffect(() => clearAllSideEffect, []);
|
||||
|
||||
return {
|
||||
uploadState,
|
||||
uploadFileList,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
.markdown-editor {
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 6px 12px;
|
||||
|
||||
background-color: #fff;
|
||||
// rgb mark
|
||||
border: 1px solid;
|
||||
border-color: rgb(6 7 9 / 10%);
|
||||
border-radius: 8px;
|
||||
|
||||
&.markdown-editor-drag {
|
||||
box-shadow: 0 0 0 2px #DADCFB;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #34F;
|
||||
}
|
||||
|
||||
.markdown-action-bar {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
border-bottom: 1px solid rgb(6 7 9 / 10%);
|
||||
}
|
||||
|
||||
.markdown-editor-content {
|
||||
resize: none;
|
||||
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
padding: 0;
|
||||
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
|
||||
// rgb mark
|
||||
color: #383743;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&::selection {
|
||||
background: rgb(77 83 232 / 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 CSSProperties } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { type CustomUploadParams, type CustomUploadRes } from './type';
|
||||
import { useMarkdownEditor } from './hooks/use-markdown-editor';
|
||||
import { UploadProgressMask } from './components/upload-progress-mask';
|
||||
import { ActionBar } from './components/action-bar';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
getUserId?: () => string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
customUpload?: (params: CustomUploadParams) => CustomUploadRes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全受控组件
|
||||
*/
|
||||
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
|
||||
value = '',
|
||||
placeholder = '',
|
||||
className,
|
||||
disabled,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
textAreaRef,
|
||||
dragTargetRef,
|
||||
onTextareaChange,
|
||||
onTriggerAction,
|
||||
isDragOver,
|
||||
uploadState,
|
||||
} = useMarkdownEditor({
|
||||
value,
|
||||
...props,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles['markdown-editor'],
|
||||
isDragOver && styles['markdown-editor-drag'],
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
ref={dragTargetRef}
|
||||
>
|
||||
<ActionBar
|
||||
className={styles['markdown-action-bar']}
|
||||
onTriggerAction={onTriggerAction}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onTextareaChange}
|
||||
className={styles['markdown-editor-content']}
|
||||
/>
|
||||
{uploadState && <UploadProgressMask {...uploadState} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import {
|
||||
uploadFileV2,
|
||||
type EventPayloadMaps as BaseEventPayloadMap,
|
||||
type UploaderInstance,
|
||||
type UploadFileV2Param,
|
||||
type FileItem,
|
||||
} from '@coze-arch/bot-utils/upload-file-v2';
|
||||
import { PlaygroundApi } from '@coze-arch/bot-api';
|
||||
|
||||
export type EventPayloadMap = BaseEventPayloadMap & {
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
export interface UploadControllerProps {
|
||||
controllerId: string;
|
||||
fileList: File[];
|
||||
userId: string;
|
||||
onProgress?: (
|
||||
event: EventPayloadMap['progress'],
|
||||
controllerId: string,
|
||||
) => void;
|
||||
onComplete?: (
|
||||
event: {
|
||||
url: string;
|
||||
fileName: string;
|
||||
},
|
||||
controllerId: string,
|
||||
) => void;
|
||||
onUploadError?: (event: Error, controllerId: string) => void;
|
||||
onUploaderReady?: (
|
||||
event: EventPayloadMap['ready'],
|
||||
controllerId: string,
|
||||
) => void;
|
||||
onStartUpload?: (
|
||||
param: Parameters<Required<UploadFileV2Param>['onStartUpload']>[number],
|
||||
controllerId: string,
|
||||
) => void;
|
||||
onGetUploadInstanceError?: (error: Error, controllerId: string) => void;
|
||||
onGetTokenError?: (error: Error, controllerId: string) => void;
|
||||
}
|
||||
|
||||
const isImage = (file: File) => file.type.startsWith('image/');
|
||||
|
||||
export class UploadController {
|
||||
controllerId: string;
|
||||
abortController: AbortController;
|
||||
uploader: UploaderInstance | null;
|
||||
fileItemList: FileItem[];
|
||||
|
||||
constructor({
|
||||
controllerId,
|
||||
fileList,
|
||||
userId,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onUploadError,
|
||||
onUploaderReady,
|
||||
onStartUpload,
|
||||
onGetTokenError,
|
||||
onGetUploadInstanceError,
|
||||
}: UploadControllerProps) {
|
||||
this.fileItemList = fileList.map(file => ({
|
||||
file,
|
||||
fileType: isImage(file) ? 'image' : 'object',
|
||||
}));
|
||||
this.controllerId = controllerId;
|
||||
this.abortController = new AbortController();
|
||||
this.uploader = null;
|
||||
uploadFileV2({
|
||||
fileItemList: this.fileItemList,
|
||||
userId,
|
||||
signal: this.abortController.signal,
|
||||
timeout: undefined,
|
||||
onUploaderReady: uploader => {
|
||||
this.uploader = uploader;
|
||||
onUploaderReady?.(true, controllerId);
|
||||
},
|
||||
onProgress: event => onProgress?.(event, controllerId),
|
||||
onSuccess: async event => {
|
||||
const uri = event.uploadResult.Uri;
|
||||
try {
|
||||
if (!uri) {
|
||||
throw new Error(
|
||||
`upload success without uri, uploadID ${event.uploadID}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await PlaygroundApi.GetImagexShortUrl({
|
||||
uris: [uri],
|
||||
});
|
||||
|
||||
const url = result.data?.url_info?.[uri]?.url;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(`failed to get url, uri: ${uri}`);
|
||||
}
|
||||
|
||||
onComplete?.(
|
||||
{ url, fileName: event.uploadResult.FileName ?? '' },
|
||||
controllerId,
|
||||
);
|
||||
} catch (e) {
|
||||
onUploadError?.(
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
controllerId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onUploadError: event => onUploadError?.(event.extra.error, controllerId),
|
||||
onStartUpload: event => onStartUpload?.(event, controllerId),
|
||||
onGetUploadInstanceError: error =>
|
||||
onGetUploadInstanceError?.(error, controllerId),
|
||||
onGetTokenError: error => onGetTokenError?.(error, controllerId),
|
||||
});
|
||||
}
|
||||
|
||||
cancel = () => {
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
this.uploader?.pause();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 interface InsertImageAction {
|
||||
type: 'image';
|
||||
sync: false;
|
||||
payload: {
|
||||
file: File;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InsertLinkAction {
|
||||
type: 'link';
|
||||
sync: true;
|
||||
payload: {
|
||||
text: string;
|
||||
link: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InsertVariableAction {
|
||||
type: 'variable';
|
||||
sync: true;
|
||||
payload: {
|
||||
variableTemplate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SyncAction = InsertLinkAction | InsertVariableAction;
|
||||
|
||||
export type AsyncAction = InsertImageAction;
|
||||
|
||||
export type TriggerAction = SyncAction | AsyncAction;
|
||||
|
||||
export interface UploadState {
|
||||
percent: number;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
// 自定义上传方法入参
|
||||
export interface CustomUploadParams {
|
||||
onUploadAllSuccess: (param: { url: string; fileName: string }) => void;
|
||||
}
|
||||
// 自定义上传方法出参
|
||||
export interface CustomUploadRes {
|
||||
uploadFileList: (fileList: File[]) => void;
|
||||
//null表示已完成
|
||||
uploadState: UploadState | null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 primitiveExhaustiveCheck = (_: never) => 0;
|
||||
export const recordExhaustiveCheck = (_: Record<string, never>) => 0;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 OnboardingVariable,
|
||||
type OnboardingVariableMap,
|
||||
} from '../constant/onboarding-variable';
|
||||
import { typedKeys } from './typed-keys';
|
||||
|
||||
export interface VariableWithRange {
|
||||
range: [number, number];
|
||||
variable: OnboardingVariable;
|
||||
}
|
||||
|
||||
export const getFixedVariableTemplate = (template: string) => `{{${template}}}`;
|
||||
|
||||
export const matchAllTemplateRanges = (
|
||||
text: string,
|
||||
template: string,
|
||||
): { start: number; end: number }[] => {
|
||||
// 正则表达式,用于匹配双花括号内的内容
|
||||
const templateRegex = new RegExp(getFixedVariableTemplate(template), 'g');
|
||||
const matches: { start: number; end: number }[] = [];
|
||||
|
||||
// 循环查找所有匹配项
|
||||
while (true) {
|
||||
const match = templateRegex.exec(text);
|
||||
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
const templateString = match[0];
|
||||
const start = match.index;
|
||||
const end = templateString.length + start;
|
||||
|
||||
matches.push({ start, end });
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const getVariableRangeList = (
|
||||
content: string,
|
||||
variableMap: OnboardingVariableMap,
|
||||
) => {
|
||||
const result: VariableWithRange[] = [];
|
||||
typedKeys(variableMap).forEach(variable => {
|
||||
const allMatchedRanges = matchAllTemplateRanges(content, variable);
|
||||
const variableWithRangeList: VariableWithRange[] = allMatchedRanges.map(
|
||||
({ start, end }) => ({
|
||||
variable,
|
||||
range: [start, end],
|
||||
}),
|
||||
);
|
||||
result.push(...variableWithRangeList);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -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 typedKeys = <T extends Parameters<typeof Object.keys>[number]>(
|
||||
o: T,
|
||||
): Array<keyof T> => Object.keys(o) as Array<keyof T>;
|
||||
Reference in New Issue
Block a user