feat: manually mirror opencoze's code from bytedance

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

View File

@@ -0,0 +1,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);
}
}
}

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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',
});

View File

@@ -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>;

View File

@@ -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)}`;

View File

@@ -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 '';
};

View File

@@ -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';

View File

@@ -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;
}) => `![${fileName}](${link})`;

View File

@@ -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})`;

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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%);
}
}
}

View File

@@ -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>
);
};

View File

@@ -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();
};
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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>;