From fb5f6e28f402b361e2d227200972f11af80b562a Mon Sep 17 00:00:00 2001 From: lijisanxiong <1518062161@qq.com> Date: Wed, 19 Nov 2025 20:06:47 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BC=96=E8=BE=91=E5=99=A8=E6=94=AF=E6=8C=81AI?= =?UTF-8?q?=E8=A1=8C=E5=86=85=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + src/editor/code/code-editor.controller.ts | 230 +++++++++++++++++- .../code/monaco-editor/monaco-editor.scss | 37 +++ .../code/monaco-editor/monaco-editor.tsx | 137 ++++++++++- src/locale/en/index.ts | 4 + src/locale/zh-CN/index.ts | 4 + 6 files changed, 410 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1bd4d02..c128f2f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 新增markdown支持手动编辑模式 - 新增重复器表格样式2组件 +- 新增代码编辑器支持AI行内聊天 ## [0.7.41-alpha.37] - 2025-11-14 diff --git a/src/editor/code/code-editor.controller.ts b/src/editor/code/code-editor.controller.ts index ad6e1c37f..b6b32d5e8 100644 --- a/src/editor/code/code-editor.controller.ts +++ b/src/editor/code/code-editor.controller.ts @@ -1,5 +1,15 @@ -import { EditorController, getDeACMode } from '@ibiz-template/runtime'; +import { RuntimeError } from '@ibiz-template/core'; +import { + EditorController, + getDeACMode, + IInLineAiChatOptions, + IInLineAIEditor, + UIActionUtil, +} from '@ibiz-template/runtime'; import { IAppDEACMode, ICode } from '@ibiz/model-core'; +import type * as Monaco from 'monaco-editor'; + +type IMonaco = typeof import('monaco-editor'); /** * 代码框编辑器控制器 @@ -8,7 +18,10 @@ import { IAppDEACMode, ICode } from '@ibiz/model-core'; * @class CodeEditorController * @extends {EditorController} */ -export class CodeEditorController extends EditorController { +export class CodeEditorController + extends EditorController + implements IInLineAIEditor +{ /** * 自填模式 * @@ -17,6 +30,32 @@ export class CodeEditorController extends EditorController { */ deACMode?: IAppDEACMode; + /** + * editor 实例 + * + * @private + * @type {Monaco.editor.IStandaloneCodeEditor} + * @memberof HtmlEditorController + */ + private editor?: Monaco.editor.IStandaloneCodeEditor; + + /** + * monaco 实例 + * + * @private + * @type {IMonaco} + * @memberof CodeEditorController + */ + private monaco?: IMonaco; + + /** + * editor 当前选区 + * + * @type {monaco.Selection} + * @memberof CodeEditorController + */ + currentSelection?: Monaco.Selection; + /** * 语言类型 * @author lxm @@ -56,4 +95,191 @@ export class CodeEditorController extends EditorController { this.context.srfappid, ); } + + /** + * editor 创建完成 + * + * @private + * @param {Monaco.editor.IStandaloneCodeEditor} editor + */ + onCreated( + editor: Monaco.editor.IStandaloneCodeEditor, + monaco: IMonaco, + ): void { + this.editor = editor; + this.monaco = monaco; + } + + /** + * 获取选中文本 + * @return {*} {string} 选中文本 + */ + getSelectionText(): string { + const selection = this.editor?.getSelection(); + let selectedText; + if (selection) { + // 关键修正:通过模型获取选区内的文字 + selectedText = this.editor?.getModel()?.getValueInRange(selection); + } + return selectedText || ''; + } + + /** + * 插入文本 + * @param {string} text 文本 + */ + insertText(text: string): void { + const model = this.editor?.getModel(); + if (!model || !this.monaco) return; + + const lastLineNumber = model.getLineCount(); + const lastLineContent = model.getLineContent(lastLineNumber); + const lastColumn = lastLineContent.length + 1; + + // 执行插入操作 + this.editor?.executeEdits('', [ + { + range: new this.monaco!.Range( + lastLineNumber, + lastColumn, + lastLineNumber, + lastColumn, + ), + text, + }, + ]); + + // 计算插入后光标的新位置(在新增文本的末尾) + const newLastLine = lastLineNumber + (text.includes('\n') ? 1 : 0); // 若插入换行,行号+1 + const newLastColumn = text.split('\n').pop()?.length || 0; + + this.editor?.setPosition( + new this.monaco!.Position(newLastLine, newLastColumn), + ); + this.editor?.revealPositionInCenter( + new this.monaco!.Position(newLastLine, newLastColumn), + ); + this.editor?.focus(); + } + + /** + * 替换选中文本 + * @param {string} text + */ + replaceSelectionText(text: string): void { + // 获取当前选区和编辑器实例 + const selection = this.editor?.getSelection(); + if (!selection || selection.isEmpty() || !this.monaco || !this.editor) { + return; + } + + // 获取选区起始位置(作为计算基准) + const startPosition = selection.getStartPosition(); + + // 替换选区内容 + this.editor.executeEdits('', [ + { + range: selection, + text, + }, + ]); + + // 计算替换文本的行数和最后一行的字符数 + const textLines = text.split('\n'); + const lineCount = textLines.length; + const lastLineChars = textLines[lineCount - 1].length; + + // 计算光标最终位置 + let finalLineNumber: number; + let finalColumn: number; + + if (lineCount === 1) { + // 无换行:直接在起始列基础上增加文本长度 + finalLineNumber = startPosition.lineNumber; + finalColumn = startPosition.column + lastLineChars; + } else { + // 有换行:计算总行数偏移,最后一行从第1列开始计算 + finalLineNumber = startPosition.lineNumber + (lineCount - 1); + finalColumn = 1 + lastLineChars; + } + + // 创建空选区(取消选中状态)并定位光标 + const newSelection = new this.monaco.Range( + finalLineNumber, + finalColumn, + finalLineNumber, + finalColumn, + ); + + // 清除所有装饰(包括选区背景色) + this.editor.createDecorationsCollection().clear(); + + this.editor.setSelection(newSelection); + this.editor.focus(); + } + + /** + * 恢复选取 + */ + restoreSelection(): void { + this.editor?.focus(); + } + + /** + * 获取内联AI聊天参数 + */ + getInLineAiChatOptions(): IInLineAiChatOptions { + const contentArea = this.editor + ?.getDomNode() + ?.querySelector('.editor-scrollable') as HTMLElement; + if (!contentArea) { + throw new RuntimeError(ibiz.i18n.t('editor.code.noEditorArea')); + } + + const position = this.currentSelection?.getStartPosition(); + if (!position) { + throw new RuntimeError(ibiz.i18n.t('editor.code.noSelStart')); + } + + const coordinates = this.editor?.getScrolledVisiblePosition(position); + const editorRect = this.editor?.getDomNode()?.getBoundingClientRect(); + if (!editorRect) { + throw new RuntimeError(ibiz.i18n.t('editor.code.noEditorRect')); + } + if (!coordinates) { + throw new RuntimeError(ibiz.i18n.t('editor.code.noSelCoords')); + } + + const rect = contentArea.getBoundingClientRect(); + + return { + left: rect.left, + top: editorRect.top + coordinates.top + 28, // 减去 20px 的行高度及 8px 向下偏移 + width: rect.width - 160, // 减去 左侧占位 及 右侧代码预览 边距 + }; + } + + /** + * 执行内联AIUI操作 + * @param uiActionId + * @param appId + */ + async doInLineAIUIAction(uiActionId: string, appId: string): Promise { + const eventArgs = this.ctrl.getEventArgs(); + eventArgs.params.editor = this; + // 编辑器参数srfaiappendcurdata,是否传入对象参数,用于历史查询传参 + if ( + this.editorParams.srfaiappendcurdata && + this.editorParams.srfaiappendcurdata === 'true' + ) { + eventArgs.context.srfaiappendcurdata = true; + } + await UIActionUtil.exec( + uiActionId!, + { + ...eventArgs, + }, + appId, + ); + } } diff --git a/src/editor/code/monaco-editor/monaco-editor.scss b/src/editor/code/monaco-editor/monaco-editor.scss index 572b207b6..951a8da2c 100644 --- a/src/editor/code/monaco-editor/monaco-editor.scss +++ b/src/editor/code/monaco-editor/monaco-editor.scss @@ -1,7 +1,13 @@ /* stylelint-disable selector-class-pattern */ $code: ( + // Height/Width + 'height-text-editor-toolbar': 40px, 'footer-toolbar-height': 36px, 'footer-button-height': 36px, + // Other + 'text-editor-toolbar-z-index': 1, + 'text-editor-toolbar-left': 0, + 'text-editor-toolbar-top': 0, ); @include b(code) { @@ -182,4 +188,35 @@ $code: ( @include b('code-footer-dialog') { margin-top: 0; +} + +@include b('code-text-editor-toolbar') { + @include set-component-css-var('code', $code); + + position: fixed; + top: getCssVar('code', 'text-editor-toolbar-top'); + left: getCssVar('code', 'text-editor-toolbar-left'); + z-index: getCssVar('code', 'text-editor-toolbar-z-index'); + display: flex; + gap: getCssVar('spacing', 'tight'); + height: getCssVar('code', 'height-text-editor-toolbar'); + padding: getCssVar('spacing', 'extra-tight'); + font-size: getCssVar('font-size', 'regular'); + background-color: getCssVar(color, bg, 2); + border-radius: getCssVar('border-radius', 'extra-small'); + box-shadow: getCssVar(shadow, elevated); + + @include e('item') { + display: flex; + align-items: center; + justify-content: center; + padding: getCssVar('spacing', 'extra-tight') getCssVar('spacing', 'tight'); + cursor: pointer; + border-radius: getCssVar('border-radius', 'extra-small'); + + &:hover { + color: getCssVar(color, primary); + background-color: getCssVar(color, primary, light, default); + } + } } \ No newline at end of file diff --git a/src/editor/code/monaco-editor/monaco-editor.tsx b/src/editor/code/monaco-editor/monaco-editor.tsx index 9316c98a7..0ff60c551 100644 --- a/src/editor/code/monaco-editor/monaco-editor.tsx +++ b/src/editor/code/monaco-editor/monaco-editor.tsx @@ -21,6 +21,7 @@ import { ElMessageBox } from 'element-plus'; import * as monaco from 'monaco-editor'; import { AxiosProgressEvent } from 'axios'; import loader from '@monaco-editor/loader'; +import { MenuItem } from '@imengyu/vue3-context-menu'; import { SysUIActionTag, UIActionUtil } from '@ibiz-template/runtime'; import { StringUtil, @@ -62,6 +63,8 @@ export const IBizCode = defineComponent({ const UUID = createUUID(); const currentVal = ref(''); + const { UIStore, zIndex } = useUIStore(); + // 允许编辑 const enableEdit = ref(true); @@ -83,6 +86,17 @@ export const IBizCode = defineComponent({ // 窗口的自动关闭模式 let autoClose: IData | void; + // 文本编辑工具栏 + const textTBRef = ref(); + + // 文本编辑工具栏直接样式 + const textTBStyle = ref({ + [ns.cssVarBlockName('text-editor-toolbar-z-index')]: zIndex.increment(), + }); + + // 文本编辑工具栏可见状态 + const textTBVisible = ref(false); + const editorModel = c.model; if (editorModel.editorParams) { if (editorModel.editorParams.enableEdit) { @@ -127,7 +141,6 @@ export const IBizCode = defineComponent({ let decorationsCollection: monaco.editor.IEditorDecorationsCollection | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any let chatInstance: any; - const { UIStore, zIndex } = useUIStore(); // 编辑器主题 const getMonacoTheme = (name: string): string => { @@ -481,6 +494,81 @@ export const IBizCode = defineComponent({ return true; }; + // 工具栏更新相关逻辑 + const updateTextToolbarPos = (selection: IParams): void => { + // 获取选中文本的位置信息 + const position = selection.getStartPosition(); + if (position) { + // 计算工具栏位置 + const coordinates = editor?.getScrolledVisiblePosition(position); + const editorRect = editor?.getDomNode()?.getBoundingClientRect(); + if (!editorRect || !coordinates) return; + + textTBStyle.value = { + ...textTBStyle.value, + [ns.cssVarBlockName('text-editor-toolbar-left')]: `${ + editorRect.left + coordinates.left + }px`, + [ns.cssVarBlockName('text-editor-toolbar-top')]: `${ + editorRect.top + coordinates.top + 28 + }px`, + }; + } + }; + + // 设置工具栏显隐状态 + const setTextTBVisible = (): void => { + // 只读模式或无自填模式时始终隐藏 + if (props.readonly || !enableEdit.value || !c.deACMode) return; + + const selection = editor?.getSelection(); + textTBVisible.value = !!(selection && !selection.isEmpty()); + }; + + // 处理选中文本事件 + const onSelectionChange = (e: IParams): void => { + const selection = e.selection; + if (selection) { + updateTextToolbarPos(selection); + c.currentSelection = selection; + } + }; + + // 处理行内ai点击 + const handleLineAiClick = (_e: MouseEvent): void => { + const position = editor?.getSelection()?.getStartPosition(); + if (!position) return; + const coordinates = editor?.getScrolledVisiblePosition(position); + const editorRect = editor?.getDomNode()?.getBoundingClientRect(); + if (!coordinates || !editorRect) return; + const items: MenuItem[] = ibiz.inLineAIUtil.calcContextMenus( + c.deACMode, + (tag: string) => { + c.doInLineAIUIAction(tag, c.model.appId); + }, + ); + if (items.length === 0) return; + ibiz.inLineAIUtil.showContextMenus( + editorRect.left + coordinates.left, + editorRect.top + coordinates.top + 28 + 40 + 18, + items, + ); + }; + + // 编辑器内鼠标点击事件 + const handleEditorClick = (e: MouseEvent): void => { + if (!textTBRef.value?.contains(e.target)) { + setTimeout(setTextTBVisible, 100); // 延迟检查,确保选择已更新 + } + }; + + // 窗口鼠标按下事件 + const handleMousedown = (e: MouseEvent): void => { + if (textTBVisible.value && !textTBRef.value?.contains(e.target)) { + textTBVisible.value = false; + } + }; + const editorInit = (): void => { nextTick(() => { isLoading.value = true; @@ -545,6 +633,9 @@ export const IBizCode = defineComponent({ ); } } + + c.onCreated(editor, loaderMonaco); + setTimeout(() => { editor!.layout(); editor!.setValue(currentVal.value); @@ -589,12 +680,19 @@ export const IBizCode = defineComponent({ // 监听值的变化 editor.onDidChangeModelContent(() => { + setTextTBVisible(); if (!hasEnableEdit.value) { currentVal.value = editor!.getValue(); emit('change', currentVal.value); } }); + // 监听选择区变化事件 + editor.onDidChangeCursorSelection(onSelectionChange); + + // 点击编辑器其他区域时隐藏工具栏 + editor.getDomNode()?.addEventListener('click', handleEditorClick); + window.addEventListener('resize', () => { editor!.layout(); }); @@ -750,12 +848,45 @@ export const IBizCode = defineComponent({ ); }; + // 绘制行内文本编辑工具栏 + const renderTextEditorToolbar = () => { + if (!textTBVisible.value) return null; + return ( +
+
+ + + +
+
+ ); + }; + onMounted(() => { editorInit(); + window.addEventListener('mousedown', handleMousedown.bind(this)); }); onUnmounted(() => { unload(); + window.removeEventListener('mousedown', handleMousedown.bind(this)); }); return { @@ -766,8 +897,10 @@ export const IBizCode = defineComponent({ hasEnableEdit, readonlyState, isLoading, + textTBRef, renderFooter, renderHeaderToolbar, + renderTextEditorToolbar, renderCodeContent, changeFullScreenState, }; @@ -784,6 +917,7 @@ export const IBizCode = defineComponent({ ]} v-loading={isLoading} > + {this.renderTextEditorToolbar()} {this.renderHeaderToolbar()} {this.renderCodeContent()} {this.hasEnableEdit && !this.readonlyState ? this.renderFooter() : null} @@ -802,6 +936,7 @@ export const IBizCode = defineComponent({ ]} v-loading={isLoading} > + {this.renderTextEditorToolbar()} {this.renderHeaderToolbar()} {this.renderCodeContent()} {this.hasEnableEdit && !this.readonlyState diff --git a/src/locale/en/index.ts b/src/locale/en/index.ts index 67207ed9b..3760ab7a8 100644 --- a/src/locale/en/index.ts +++ b/src/locale/en/index.ts @@ -666,6 +666,10 @@ export default { }, code: { readOnlyPrompt: 'Currently in read-only mode, not editable', + noEditorArea: 'Editor content area not found', + noSelStart: 'No start position of current selection', + noEditorRect: 'No editor DOM node position info', + noSelCoords: 'No scroll coordinates of selection', }, dateRange: { rangeSeparator: 'To', diff --git a/src/locale/zh-CN/index.ts b/src/locale/zh-CN/index.ts index b523bb4af..a3c71a2e4 100644 --- a/src/locale/zh-CN/index.ts +++ b/src/locale/zh-CN/index.ts @@ -622,6 +622,10 @@ export default { }, code: { readOnlyPrompt: '当前为只读模式,不可编辑', + noEditorArea: '未找到编辑器内容区域', + noSelStart: '未获取到当前选中区域的起始位置', + noEditorRect: '未获取到编辑器DOM节点的位置信息', + noSelCoords: '未计算出选中位置的滚动可视坐标', }, dateRange: { rangeSeparator: '至', -- Gitee