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,106 @@
/*
* 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, useState } from 'react';
import { useEditor } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import { type ViewUpdate } from '@codemirror/view';
import { InputConfigPopover } from '../input-config-popover';
import { type TemplateParser } from '../../shared/utils/template-parser';
export const ConfigModeWidgetPopover = (props: {
direction: 'bottomLeft' | 'topLeft' | 'bottomRight' | 'topRight';
templateParser: TemplateParser;
}) => {
const { direction, templateParser } = props;
const editor = useEditor<EditorAPI>();
const [placeholder, setPlaceholder] = useState('');
const [value, setValue] = useState('');
const [configPopoverVisible, setConfigPopoverVisible] = useState(false);
const [popoverPosition, setPopoverPosition] = useState(-1);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (e: ViewUpdate) => {
if (e.docChanged) {
// 判断当前光标是否在 slot 节点内
const { state } = e;
const range = templateParser.getCursorInMarkNodeRange(state);
if (!range) {
return;
}
const content = state.sliceDoc(range.open.to, range.close.from);
if (content === value) {
return;
}
setValue(content);
}
if (e.selectionSet) {
const { state } = e;
const range = templateParser.getCursorInMarkNodeRange(state);
if (!range) {
setPopoverPosition(-1);
setConfigPopoverVisible(false);
return;
}
const content = templateParser.getCursorTemplateContent(editor);
const { placeholder: configPlaceholder } =
templateParser.getCursorTemplateData(state) ?? {};
setPlaceholder(configPlaceholder);
setValue(content ?? '');
setPopoverPosition(range.open.from);
setConfigPopoverVisible(true);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor, value]);
const handlePlaceholderChange = (configPlaceholder: string) => {
if (!editor || !configPopoverVisible) {
return;
}
setPlaceholder(configPlaceholder);
templateParser.updateCursorTemplateData(editor, {
placeholder: configPlaceholder,
});
};
const handleValueChange = (configValue: string) => {
if (!editor || !configPopoverVisible) {
return;
}
setValue(configValue);
templateParser.updateCursorTemplateContent(editor, configValue);
};
return (
<InputConfigPopover
visible={configPopoverVisible}
positon={popoverPosition}
direction={direction}
placeholder={placeholder}
value={value}
onPlaceholderChange={handlePlaceholderChange}
onValueChange={handleValueChange}
/>
);
};

View File

@@ -0,0 +1,34 @@
.cm-content {
.slot-content {
color: #4E40E5;
word-break: break-all;
background-color: rgba(186, 192, 255, 20%);
/* padding: 2px 0; */
}
.slot-side-left {
/* padding: 2px 0 2px 5px; */
padding-left: 5px;
background-color: rgba(186, 192, 255, 20%);
border-radius: 4px 0 0 4px;
}
.slot-side-right {
margin-right: 4px;
/* padding: 2px 5px 2px 0; */
padding-right: 5px;
background-color: rgba(186, 192, 255, 20%);
border-radius: 0 5px 5px 0;
}
.slot-placeholder {
color: rgba(148, 152, 247, 70%);
word-break: break-all;
background-color: rgba(186, 192, 255, 20%);
/* padding: 2px 0; */
}
}

View File

@@ -0,0 +1,169 @@
/*
* 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 { ConfigModeWidgetPopover } from './config-mode-popover';
import './index.css';
import { useEffect, useLayoutEffect } from 'react';
import { useEditor, useInjector } from '@coze-editor/editor/react';
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
import {
astDecorator,
SpanWidget,
autoSelectRanges,
selectionEnlarger,
deletionEnlarger,
} from '@coze-editor/editor';
import { type ViewUpdate } from '@codemirror/view';
import {
type MarkRangeInfo,
TemplateParser,
} from '../../shared/utils/template-parser';
import { useReadonly } from '../../shared/hooks/use-editor-readonly';
interface InputSlotWidgetProps {
mode?: 'input' | 'configurable';
onSelectionInInputSlot?: (selection: MarkRangeInfo | undefined) => void;
}
const templateParser = new TemplateParser({ mark: 'InputSlot' });
export const InputSlotWidget = (props: InputSlotWidgetProps) => {
const { mode, onSelectionInInputSlot } = props;
const injector = useInjector();
const editor = useEditor<EditorAPI>();
const readonly = useReadonly();
useLayoutEffect(() => {
const { markInfoField } = templateParser;
return injector.inject([
astDecorator.whole.of((cursor, state) => {
if (templateParser.isOpenNode(cursor.node, state)) {
const open = cursor.node;
const close = templateParser.findCloseNode(open, state);
if (close) {
const openTemplate = state.sliceDoc(open.from, open.to);
const data = templateParser.getData(openTemplate);
const from = open.to;
const to = close.from;
if (from === to) {
return [
{
type: 'replace',
widget: new SpanWidget({
className: 'slot-side-left',
}),
atomicRange: true,
from: open.from,
to: open.to,
},
{
type: 'widget',
widget: new SpanWidget({
text: data?.placeholder || '',
className: 'slot-placeholder',
}),
from,
atomicRange: true,
side: 1,
},
{
type: 'replace',
widget: new SpanWidget({
className: 'slot-side-right',
}),
atomicRange: true,
from: close.from,
to: close.to,
},
];
}
return [
{
type: 'replace',
widget: new SpanWidget({
className: 'slot-side-left',
}),
atomicRange: true,
from: open.from,
to: open.to,
},
{
type: 'className',
className: 'slot-content',
from,
to,
},
{
type: 'replace',
widget: new SpanWidget({ className: 'slot-side-right' }),
atomicRange: true,
from: close.from,
to: close.to,
},
];
}
}
}),
markInfoField,
autoSelectRanges.of(state => state.field(markInfoField).contents),
selectionEnlarger.of(state => state.field(markInfoField).specs),
deletionEnlarger.of(state => state.field(markInfoField).specs),
]);
}, [injector]);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (update: ViewUpdate) => {
if (!update.state.selection.main.empty) {
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
update.state.selection.main,
update.state,
);
if (markRangeInfo) {
onSelectionInInputSlot?.(markRangeInfo);
return;
}
onSelectionInInputSlot?.(undefined);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
if (mode === 'configurable' && !readonly) {
return (
<ConfigModeWidgetPopover
direction="bottomLeft"
templateParser={templateParser}
/>
);
}
return null;
};