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,66 @@
/*
* 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 EditorAPI } from '@coze-editor/editor/preset-prompt';
import { I18n } from '@coze-arch/i18n';
import { EditorSelection } from '@codemirror/state';
import { TemplateParser } from '../../shared/utils/template-parser';
const templateParser = new TemplateParser({ mark: 'InputSlot' });
export const insertInputSlot = (
editor: EditorAPI,
options?: {
mode?: 'input' | 'configurable';
placeholder?: string;
},
) => {
if (!editor) {
return;
}
const {
mode = 'input',
placeholder = I18n.t('edit_block_guidance_text_placeholder'),
} = options ?? {};
const { selection } = editor.$view.state;
const selectionRange = editor.$view.state.selection.main;
const content = editor.$view.state.sliceDoc(
selectionRange.from,
selectionRange.to,
);
const extractedContent = templateParser.extractTemplateContent(content);
const { open, template, textContent } = templateParser.generateTemplateJson({
content: extractedContent,
data: {
placeholder,
mode,
},
});
const from = selectionRange.from + open.length;
const to = from + textContent.length;
editor.$view.dispatch({
changes: {
from: selectionRange.from,
to: selectionRange.to,
insert: template,
},
});
setTimeout(() => {
editor.$view.dispatch({
selection: selection.replaceRange(EditorSelection.range(from, to)),
});
}, 100);
};

View File

@@ -0,0 +1,83 @@
/*
* 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 { TemplateParser } from '../../shared/utils/template-parser';
const templateParser = new TemplateParser({ mark: 'InputSlot' });
export const useCursorInInputSlot = () => {
const editor = useEditor<EditorAPI>();
const [inInputSlot, setInInputSlot] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
const handleViewUpdate = (update: ViewUpdate) => {
if (update.selectionSet) {
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
update.state.selection.main,
update.state,
);
if (markRangeInfo) {
setInInputSlot(true);
return;
}
setInInputSlot(false);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
return inInputSlot;
};
export const useSelectionInInputSlot = () => {
const editor = useEditor<EditorAPI>();
const [inInputSlot, setInInputSlot] = useState(false);
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) {
setInInputSlot(true);
return;
}
setInInputSlot(false);
}
};
editor.$on('viewUpdate', handleViewUpdate);
return () => {
editor.$off('viewUpdate', handleViewUpdate);
};
}, [editor]);
return inInputSlot;
};

View File

@@ -0,0 +1,69 @@
/*
* 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 { syntaxTree } from '@codemirror/language';
export const useSelectionInJinjaRaw = () => {
const editor = useEditor<EditorAPI>();
const [inJinjaRaw, setInJinjaRaw] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
const checkInJinjaRaw = () => {
const selection = editor.getSelection();
if (!selection) {
setInJinjaRaw(false);
return;
}
const { state } = editor.$view;
const tree = syntaxTree(state);
const cursor = tree.cursor();
let isInRaw = false;
do {
if (cursor.name === 'RawText') {
const isSelectionWithinNode =
cursor.from <= selection.from && cursor.to >= selection.to;
if (isSelectionWithinNode) {
isInRaw = true;
break;
}
}
} while (cursor.next());
setInJinjaRaw(isInRaw);
};
editor.$on('viewUpdate', checkInJinjaRaw);
// 初始检查
checkInJinjaRaw();
return () => {
editor.$off('viewUpdate', checkInJinjaRaw);
};
}, [editor]);
return inJinjaRaw;
};

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 { InputSlotWidget } from './input-slot-widget';
export { insertInputSlot } from './action/insert-input-slot-action';
export {
useCursorInInputSlot,
useSelectionInInputSlot,
} from './hooks/use-in-input-slot';
export { useSelectionInJinjaRaw } from './hooks/use-in-jinja-raw';

View File

@@ -0,0 +1,80 @@
/*
* 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 } from 'react';
import { PositionMirror } from '@coze-editor/editor/react';
import { I18n } from '@coze-arch/i18n';
import { Popover, Input, type PopoverProps } from '@coze-arch/coze-design';
interface InputConfigPopoverProps {
visible: boolean;
onVisibleChange?: (visible: boolean) => void;
positon: number;
direction?: PopoverProps['position'];
placeholder: string;
value: string;
onPlaceholderChange?: (placeholder: string) => void;
onValueChange?: (value: string) => void;
}
export const InputConfigPopover = (props: InputConfigPopoverProps) => {
const [reposKey, setReposKey] = useState('');
const { direction, placeholder, value } = props;
return (
<>
<Popover
rePosKey={reposKey}
visible={props.visible}
trigger="custom"
position={direction}
autoAdjustOverflow
content={
<div className="flex flex-col gap-2 pt-3 pb-4 px-4 w-[320px]">
<div>
<div>{I18n.t('edit_block_guidance_text_when_empty')}</div>
<Input
value={placeholder}
placeholder={I18n.t('edit_block_guidance_text_placeholder')}
onChange={v => props.onPlaceholderChange?.(v)}
onBlur={() => {
if (!placeholder) {
props.onPlaceholderChange?.(
I18n.t('edit_block_guidance_text_placeholder'),
);
}
}}
/>
</div>
<div>
<div>{I18n.t('edit_block_prefilled_text')}</div>
<Input
value={value}
placeholder={I18n.t('edit_block_default_guidance_text')}
onChange={v => props.onValueChange?.(v)}
/>
</div>
</div>
}
>
<PositionMirror
position={props.positon}
onChange={() => setReposKey(String(Math.random()))}
/>
</Popover>
</>
);
};

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