| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633 |
- <script setup lang="ts">
- import { nextTick, onMounted, reactive, ref, watch, onBeforeUnmount, computed } from 'vue'
- import { useLocalEditorTheme } from '@/utils/useLocalEditorTheme'
- import type { editor } from 'monaco-editor'
- import { ElMessage } from 'element-plus'
- import * as monaco from 'monaco-editor'
- import { IconButton } from '@repo/ui'
- import { debounce } from 'lodash-es'
- import { useI18n } from '@/composables/useI18n'
- import {
- clearCodeEditorCompletionConfig,
- shouldTriggerCodeEditorCompletion,
- syncCodeEditorCompletionConfig,
- type CodeEditorCompletionConfig
- } from './codeEditorCompletion'
- interface CodeEditorType {
- // 是否展示工具栏
- tools?: boolean
- // 是否展示全屏工具
- allowFullscreen?: boolean
- // 是否展示复制工具
- copyCode?: boolean
- // 是否自动切换主题
- autoToggleTheme?: boolean
- // 挂载DOM
- appendTo?: string | HTMLElement
- // 内容
- modelValue?: any
- // 语法
- language?: string
- // 主题
- theme?: 'vs-light' | 'vs-dark'
- // 是否只读
- readOnly?: boolean
- // 是否显示行号
- lineNumbers?: 'on' | 'off'
- // 设置代码模块高度,总高度多40px
- height?: number
- // 返回结果为string,也可转化为json
- formatValue?: 'string' | 'json'
- // 插件内部配置
- config?: editor.IStandaloneEditorConstructionOptions
- // 是否可以切换语言
- allowChangeLanguage?: boolean
- completionConfig?: CodeEditorCompletionConfig
- }
- const props = withDefaults(defineProps<CodeEditorType>(), {
- //默认配置
- tools: true,
- copyCode: true,
- readOnly: false,
- allowFullscreen: true,
- autoToggleTheme: true,
- allowChangeLanguage: true,
- language: 'javascript',
- lineNumbers: 'on',
- theme: 'vs-light',
- formatValue: 'string',
- height: 150,
- completionConfig: () => ({}),
- config: () => ({
- minimap: { enabled: false },
- selectOnLineNumbers: true
- })
- })
- const emit = defineEmits(['update:modelValue', 'update:language'])
- const { t } = useI18n()
- let monacoEditor: monaco.editor.IStandaloneCodeEditor | null = null
- let model: editor.ITextModel | null = null
- let layoutFrame = 0
- const editContainer = ref<HTMLElement>()
- const bodyHeight = ref(props.height)
- const resizeState = reactive({
- startY: 0,
- startHeight: props.height,
- isDragging: false
- })
- const isFullScreen = ref(false)
- const { monacoTheme, themeClass, toggleTheme } = useLocalEditorTheme({
- defaultTheme: props.theme,
- autoToggleTheme: props.autoToggleTheme
- })
- let componentConfig = reactive({
- language: props.language
- })
- const languageSource = [
- { id: 'javascript', name: 'javascript' },
- { id: 'python', name: 'python' },
- { id: 'json', name: 'json' },
- { id: 'java', name: 'java' }
- ]
- /**
- * @description: formatValue为json 格式,需要转换处理
- * @return
- */
- const formatValue = (value: any) => {
- if (props.formatValue === 'json') {
- return JSON.stringify(value ?? {}, null, 2)
- }
- return value ?? ''
- }
- const syncCompletionConfig = () => {
- if (!model) return
- syncCodeEditorCompletionConfig(model, componentConfig.language, props.completionConfig)
- }
- onMounted(() => {
- // 处理代码转换
- model = monaco.editor.createModel(formatValue(props.modelValue), componentConfig.language)
- syncCompletionConfig()
- // 接入配置
- monacoEditor = monaco.editor.create(editContainer.value!, {
- model,
- wordWrap: 'on',
- automaticLayout: true,
- theme: monacoTheme.value,
- readOnly: props.readOnly,
- lineNumbers: props.lineNumbers,
- ...props.config
- })
- // 添加空格
- monacoEditor.onKeyDown((e) => {
- if (e.keyCode === monaco.KeyCode.Space) {
- e.preventDefault()
- monacoEditor?.trigger('keyboard', 'type', { text: ' ' })
- }
- })
- // 监听代码输入
- monacoEditor.onDidChangeModelContent(updateModelValue)
- monacoEditor.onDidChangeModelContent((event) => {
- if (!monacoEditor || !model) return
- const lastChange = event.changes[event.changes.length - 1]
- if (!lastChange?.text) return
- const triggerCharacters = new Set(['.'])
- const languageRules = props.completionConfig?.[componentConfig.language] || []
- languageRules.forEach((rule) => {
- ;(rule.triggerCharacters || []).forEach((char) => triggerCharacters.add(char))
- })
- if (![...triggerCharacters].some((char) => lastChange.text.endsWith(char))) {
- return
- }
- const position = monacoEditor.getPosition()
- if (!position) return
- if (
- shouldTriggerCodeEditorCompletion(
- model,
- position,
- componentConfig.language,
- props.completionConfig
- )
- ) {
- monacoEditor.trigger('completion', 'editor.action.triggerSuggest', {})
- }
- })
- })
- // 读取传值数据
- watch(
- () => props.modelValue,
- (value) => {
- nextTick(() => {
- if (!model) return
- const next = formatValue(value)
- if (model.getValue() !== next) {
- model.setValue(next)
- }
- })
- },
- { immediate: true }
- )
- // 切换语法,触发回调
- watch(
- () => componentConfig.language,
- (lang) => {
- nextTick(() => {
- if (!model) return
- monaco.editor.setModelLanguage(model, lang)
- syncCompletionConfig()
- emit('update:language', lang)
- })
- },
- { immediate: true }
- )
- watch(
- () => props.completionConfig,
- () => {
- syncCompletionConfig()
- },
- { deep: true }
- )
- // 监听全屏, 重新计算layout
- watch(isFullScreen, () => {
- nextTick(() => {
- monacoEditor?.layout()
- })
- })
- watch(
- () => props.height,
- (height) => {
- if (bodyHeight.value < height) {
- bodyHeight.value = height
- }
- nextTick(() => {
- monacoEditor?.layout()
- })
- }
- )
- /**
- * @description: 发送回调(formatValue:json,需要转换处理)
- * @return {*}
- */
- const updateModelValue = debounce(() => {
- if (!model) return
- const value = model.getValue()
- if (props.formatValue === 'json') {
- try {
- emit('update:modelValue', JSON.parse(value))
- } catch {
- return ElMessage.warning(t('common.nodeBase.codeEditor.jsonSyntaxError'))
- }
- } else {
- emit('update:modelValue', value)
- }
- }, 1000)
- /**
- * @description: 复制代码到粘贴板
- * @return {*}
- */
- const copyCode = () => {
- const text = model?.getValue()
- if (!text) return
- try {
- navigator.clipboard.writeText(text).then(() => {
- ElMessage.success(t('common.nodeBase.codeEditor.copySuccess'))
- })
- } catch {
- const textarea = document.createElement('textarea')
- textarea.value = text
- document.body.appendChild(textarea)
- textarea.select()
- document.execCommand('copy')
- textarea.remove()
- ElMessage.success(t('common.nodeBase.codeEditor.copySuccess'))
- }
- }
- /**
- * @description: 设置全屏样式
- * @return {*}
- */
- const fullScreenStyle = computed(() => {
- if (isFullScreen.value) return {}
- return {
- height: `${bodyHeight.value + 40}px`
- }
- })
- /**
- * @description: 切换主题, 并设置对应样式
- * @return {*}
- */
- const toolTipClass = computed(() => {
- return `editor-tooltip editor-tooltip--${themeClass.value}`
- })
- const onToggleTheme = () => {
- toggleTheme()
- }
- const syncEditorLayout = () => {
- if (layoutFrame) {
- cancelAnimationFrame(layoutFrame)
- }
- layoutFrame = requestAnimationFrame(() => {
- layoutFrame = 0
- monacoEditor?.layout()
- })
- }
- const stopResize = () => {
- if (!resizeState.isDragging) return
- resizeState.isDragging = false
- document.body.style.userSelect = ''
- document.body.style.cursor = ''
- window.removeEventListener('mousemove', onResize)
- window.removeEventListener('mouseup', stopResize)
- }
- const onResize = (event: MouseEvent) => {
- if (!resizeState.isDragging || isFullScreen.value) return
- const nextHeight = resizeState.startHeight + event.clientY - resizeState.startY
- const targetHeight = Math.max(props.height, nextHeight)
- if (targetHeight === bodyHeight.value) return
- bodyHeight.value = targetHeight
- syncEditorLayout()
- }
- const onResizeStart = (event: MouseEvent) => {
- if (isFullScreen.value) return
- resizeState.startY = event.clientY
- resizeState.startHeight = bodyHeight.value
- resizeState.isDragging = true
- document.body.style.userSelect = 'none'
- document.body.style.cursor = 'ns-resize'
- window.addEventListener('mousemove', onResize)
- window.addEventListener('mouseup', stopResize)
- }
- /**
- * 设置文本
- * @param text
- */
- function setValue(text: string) {
- monacoEditor?.setValue(text || '')
- }
- /**
- * 光标处插入文本
- * @param text
- */
- function insertText(text: string) {
- // 获取光标位置
- const position = monacoEditor?.getPosition()
- // 未获取到光标位置信息
- if (!position) {
- return
- }
- // 插入
- monacoEditor?.executeEdits('', [
- {
- range: new monaco.Range(
- position.lineNumber,
- position.column,
- position.lineNumber,
- position.column
- ),
- text
- }
- ])
- // 设置新的光标位置
- monacoEditor?.setPosition({
- ...position,
- column: position.column + text.length
- })
- // 重新聚焦
- monacoEditor?.focus()
- }
- // 销毁model
- onBeforeUnmount(() => {
- stopResize()
- if (layoutFrame) {
- cancelAnimationFrame(layoutFrame)
- }
- if (model) {
- clearCodeEditorCompletionConfig(model)
- }
- monacoEditor?.dispose()
- model?.dispose()
- })
- defineExpose({
- insertText,
- setValue
- })
- </script>
- <template>
- <Teleport :disabled="!appendTo" :to="appendTo">
- <div
- class="monacoEditor !m-0"
- :class="[
- themeClass,
- { 'is-fullscreen': isFullScreen, 'is-resizing': resizeState.isDragging }
- ]"
- :style="fullScreenStyle"
- >
- <div class="tools h-[33px] flex items-center justify-between gap-2" v-if="props.tools">
- <!-- 语法切换 -->
- <ElTooltip
- :effect="themeClass"
- :popper-class="toolTipClass"
- placement="top"
- :content="t('common.nodeBase.codeEditor.switchLanguage')"
- >
- <div class="w-1/3">
- <ElSelect v-if="allowChangeLanguage" v-model="componentConfig.language">
- <ElOption v-for="value in languageSource" :label="value.name" :value="value.id" />
- </ElSelect>
- </div>
- </ElTooltip>
- <div class="flex-1 flex items-center justify-end gap-1">
- <!-- 放大/缩小 -->
- <ElTooltip
- :effect="themeClass"
- placement="top"
- :popper-class="toolTipClass"
- :content="
- !isFullScreen
- ? t('common.nodeBase.codeEditor.enterFullscreen')
- : t('common.nodeBase.codeEditor.exitFullscreen')
- "
- >
- <div
- class="cursor-pointer text-xl text-center px-2"
- @click="isFullScreen = !isFullScreen"
- v-if="props.allowFullscreen"
- >
- <IconButton
- :icon="isFullScreen ? 'lucide:minimize' : 'lucide:fullscreen'"
- link
- class="fullscreen"
- />
- </div>
- </ElTooltip>
- <!-- copy -->
- <ElTooltip
- :effect="themeClass"
- :popper-class="toolTipClass"
- placement="top"
- :content="t('common.nodeBase.codeEditor.copy')"
- >
- <div class="copy text-center px-2" @click="copyCode" v-if="props.copyCode">
- <IconButton icon="lucide:copy" link class="copyIcon"></IconButton>
- </div>
- </ElTooltip>
- <!-- 主题 -->
- <ElTooltip
- :effect="themeClass"
- :popper-class="toolTipClass"
- placement="top"
- :content="t('common.nodeBase.codeEditor.theme')"
- >
- <div class="copy text-center px-2" @click="onToggleTheme">
- <IconButton
- :icon="themeClass === 'dark' ? 'lucide:moon' : 'lucide:sun'"
- link
- class="themeIcon"
- >
- </IconButton>
- </div>
- </ElTooltip>
- </div>
- </div>
- <!-- 编辑器 -->
- <div class="editor-wrapper">
- <div ref="editContainer" class="code-editor"></div>
- <div v-if="!isFullScreen" class="resize-handle" @mousedown.prevent="onResizeStart"></div>
- </div>
- </div>
- </Teleport>
- </template>
- <style lang="less">
- .editor-tooltip {
- border-radius: 6px;
- font-size: 12px;
- padding: 6px 10px;
- }
- // light
- .editor-tooltip--light {
- background-color: var(--bg-base) !important;
- color: #1e1e1e !important;
- border: 1px solid #e5e7eb;
- .el-popper__arrow::before {
- background: var(--bg-base) !important;
- border: 1px solid #e5e7eb;
- }
- }
- // dark
- .editor-tooltip--dark {
- background-color: #1e1e1e !important;
- color: var(--bg-base) !important;
- .el-popper__arrow::before {
- background: #1e1e1e !important;
- }
- }
- </style>
- <style lang="less" scoped>
- .monacoEditor {
- border: 1px solid #dcdfe6;
- border-radius: 4px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- transition:
- height 0.25s ease,
- inset 0.25s ease,
- background-color 0.2s;
- &.is-resizing {
- transition:
- inset 0.25s ease,
- background-color 0.2s;
- }
- &.is-fullscreen {
- position: absolute;
- inset: 0;
- z-index: 1000;
- }
- .tools {
- border-bottom: 1px solid #eeeeee83;
- padding-bottom: 6px;
- ::v-deep(.el-select .el-select__wrapper) {
- border: none;
- box-shadow: none;
- background-color: var(--bg-base);
- }
- ::v-deep(.el-select .el-select__placeholder) {
- color: #1e1e1e;
- }
- }
- .editor-wrapper {
- flex: 1;
- overflow: hidden;
- position: relative;
- .resize-handle {
- position: absolute;
- bottom: 8px;
- left: 50%;
- transform: translateX(-50%);
- width: 18px;
- height: 4px;
- background-color: #d0d5dd;
- border-radius: 8px;
- cursor: ns-resize;
- }
- }
- .code-editor {
- width: 100%;
- height: 100%;
- ::v-deep(.monaco-editor) {
- height: 100%;
- background-color: #ffffff;
- }
- &.bordered {
- border: 1px solid var(--epic-border-color);
- }
- }
- }
- .light {
- background-color: #ffffff;
- color: #1e1e1e;
- &:hover {
- color: #1e1e1e;
- }
- .tools {
- ::v-deep(.el-select .el-select__wrapper) {
- border: none;
- box-shadow: none;
- background-color: #ffffff;
- }
- ::v-deep(.el-select .el-select__placeholder) {
- color: #1e1e1e;
- }
- .fullscreen,
- .fullscreen-exit,
- .themeIcon,
- .copyIcon {
- color: #1e1e1e;
- }
- }
- }
- .dark {
- background-color: #1e1e1e;
- color: #ffffff;
- &:hover {
- color: #ffffff;
- }
- .tools {
- ::v-deep(.el-select .el-select__wrapper) {
- border: none;
- box-shadow: none;
- background-color: #1e1e1e;
- }
- ::v-deep(.el-select .el-select__placeholder) {
- color: #ffffff;
- }
- .fullscreen,
- .fullscreen-exit,
- .themeIcon,
- .copyIcon {
- color: #ffffff;
- }
- }
- }
- </style>
|