CodeEditor.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. <script setup lang="ts">
  2. import { nextTick, onMounted, reactive, ref, watch, onBeforeUnmount, computed } from 'vue'
  3. import { useLocalEditorTheme } from '@/utils/useLocalEditorTheme'
  4. import type { editor } from 'monaco-editor'
  5. import { ElMessage } from 'element-plus'
  6. import * as monaco from 'monaco-editor'
  7. import { IconButton } from '@repo/ui'
  8. import { debounce } from 'lodash-es'
  9. import { useI18n } from '@/composables/useI18n'
  10. import {
  11. clearCodeEditorCompletionConfig,
  12. shouldTriggerCodeEditorCompletion,
  13. syncCodeEditorCompletionConfig,
  14. type CodeEditorCompletionConfig
  15. } from './codeEditorCompletion'
  16. interface CodeEditorType {
  17. // 是否展示工具栏
  18. tools?: boolean
  19. // 是否展示全屏工具
  20. allowFullscreen?: boolean
  21. // 是否展示复制工具
  22. copyCode?: boolean
  23. // 是否自动切换主题
  24. autoToggleTheme?: boolean
  25. // 挂载DOM
  26. appendTo?: string | HTMLElement
  27. // 内容
  28. modelValue?: any
  29. // 语法
  30. language?: string
  31. // 主题
  32. theme?: 'vs-light' | 'vs-dark'
  33. // 是否只读
  34. readOnly?: boolean
  35. // 是否显示行号
  36. lineNumbers?: 'on' | 'off'
  37. // 设置代码模块高度,总高度多40px
  38. height?: number
  39. // 返回结果为string,也可转化为json
  40. formatValue?: 'string' | 'json'
  41. // 插件内部配置
  42. config?: editor.IStandaloneEditorConstructionOptions
  43. // 是否可以切换语言
  44. allowChangeLanguage?: boolean
  45. completionConfig?: CodeEditorCompletionConfig
  46. }
  47. const props = withDefaults(defineProps<CodeEditorType>(), {
  48. //默认配置
  49. tools: true,
  50. copyCode: true,
  51. readOnly: false,
  52. allowFullscreen: true,
  53. autoToggleTheme: true,
  54. allowChangeLanguage: true,
  55. language: 'javascript',
  56. lineNumbers: 'on',
  57. theme: 'vs-light',
  58. formatValue: 'string',
  59. height: 150,
  60. completionConfig: () => ({}),
  61. config: () => ({
  62. minimap: { enabled: false },
  63. selectOnLineNumbers: true
  64. })
  65. })
  66. const emit = defineEmits(['update:modelValue', 'update:language'])
  67. const { t } = useI18n()
  68. let monacoEditor: monaco.editor.IStandaloneCodeEditor | null = null
  69. let model: editor.ITextModel | null = null
  70. let layoutFrame = 0
  71. const editContainer = ref<HTMLElement>()
  72. const bodyHeight = ref(props.height)
  73. const resizeState = reactive({
  74. startY: 0,
  75. startHeight: props.height,
  76. isDragging: false
  77. })
  78. const isFullScreen = ref(false)
  79. const { monacoTheme, themeClass, toggleTheme } = useLocalEditorTheme({
  80. defaultTheme: props.theme,
  81. autoToggleTheme: props.autoToggleTheme
  82. })
  83. let componentConfig = reactive({
  84. language: props.language
  85. })
  86. const languageSource = [
  87. { id: 'javascript', name: 'javascript' },
  88. { id: 'python', name: 'python' },
  89. { id: 'json', name: 'json' },
  90. { id: 'java', name: 'java' }
  91. ]
  92. /**
  93. * @description: formatValue为json 格式,需要转换处理
  94. * @return
  95. */
  96. const formatValue = (value: any) => {
  97. if (props.formatValue === 'json') {
  98. return JSON.stringify(value ?? {}, null, 2)
  99. }
  100. return value ?? ''
  101. }
  102. const syncCompletionConfig = () => {
  103. if (!model) return
  104. syncCodeEditorCompletionConfig(model, componentConfig.language, props.completionConfig)
  105. }
  106. onMounted(() => {
  107. // 处理代码转换
  108. model = monaco.editor.createModel(formatValue(props.modelValue), componentConfig.language)
  109. syncCompletionConfig()
  110. // 接入配置
  111. monacoEditor = monaco.editor.create(editContainer.value!, {
  112. model,
  113. wordWrap: 'on',
  114. automaticLayout: true,
  115. theme: monacoTheme.value,
  116. readOnly: props.readOnly,
  117. lineNumbers: props.lineNumbers,
  118. ...props.config
  119. })
  120. // 添加空格
  121. monacoEditor.onKeyDown((e) => {
  122. if (e.keyCode === monaco.KeyCode.Space) {
  123. e.preventDefault()
  124. monacoEditor?.trigger('keyboard', 'type', { text: ' ' })
  125. }
  126. })
  127. // 监听代码输入
  128. monacoEditor.onDidChangeModelContent(updateModelValue)
  129. monacoEditor.onDidChangeModelContent((event) => {
  130. if (!monacoEditor || !model) return
  131. const lastChange = event.changes[event.changes.length - 1]
  132. if (!lastChange?.text) return
  133. const triggerCharacters = new Set(['.'])
  134. const languageRules = props.completionConfig?.[componentConfig.language] || []
  135. languageRules.forEach((rule) => {
  136. ;(rule.triggerCharacters || []).forEach((char) => triggerCharacters.add(char))
  137. })
  138. if (![...triggerCharacters].some((char) => lastChange.text.endsWith(char))) {
  139. return
  140. }
  141. const position = monacoEditor.getPosition()
  142. if (!position) return
  143. if (
  144. shouldTriggerCodeEditorCompletion(
  145. model,
  146. position,
  147. componentConfig.language,
  148. props.completionConfig
  149. )
  150. ) {
  151. monacoEditor.trigger('completion', 'editor.action.triggerSuggest', {})
  152. }
  153. })
  154. })
  155. // 读取传值数据
  156. watch(
  157. () => props.modelValue,
  158. (value) => {
  159. nextTick(() => {
  160. if (!model) return
  161. const next = formatValue(value)
  162. if (model.getValue() !== next) {
  163. model.setValue(next)
  164. }
  165. })
  166. },
  167. { immediate: true }
  168. )
  169. // 切换语法,触发回调
  170. watch(
  171. () => componentConfig.language,
  172. (lang) => {
  173. nextTick(() => {
  174. if (!model) return
  175. monaco.editor.setModelLanguage(model, lang)
  176. syncCompletionConfig()
  177. emit('update:language', lang)
  178. })
  179. },
  180. { immediate: true }
  181. )
  182. watch(
  183. () => props.completionConfig,
  184. () => {
  185. syncCompletionConfig()
  186. },
  187. { deep: true }
  188. )
  189. // 监听全屏, 重新计算layout
  190. watch(isFullScreen, () => {
  191. nextTick(() => {
  192. monacoEditor?.layout()
  193. })
  194. })
  195. watch(
  196. () => props.height,
  197. (height) => {
  198. if (bodyHeight.value < height) {
  199. bodyHeight.value = height
  200. }
  201. nextTick(() => {
  202. monacoEditor?.layout()
  203. })
  204. }
  205. )
  206. /**
  207. * @description: 发送回调(formatValue:json,需要转换处理)
  208. * @return {*}
  209. */
  210. const updateModelValue = debounce(() => {
  211. if (!model) return
  212. const value = model.getValue()
  213. if (props.formatValue === 'json') {
  214. try {
  215. emit('update:modelValue', JSON.parse(value))
  216. } catch {
  217. return ElMessage.warning(t('common.nodeBase.codeEditor.jsonSyntaxError'))
  218. }
  219. } else {
  220. emit('update:modelValue', value)
  221. }
  222. }, 1000)
  223. /**
  224. * @description: 复制代码到粘贴板
  225. * @return {*}
  226. */
  227. const copyCode = () => {
  228. const text = model?.getValue()
  229. if (!text) return
  230. try {
  231. navigator.clipboard.writeText(text).then(() => {
  232. ElMessage.success(t('common.nodeBase.codeEditor.copySuccess'))
  233. })
  234. } catch {
  235. const textarea = document.createElement('textarea')
  236. textarea.value = text
  237. document.body.appendChild(textarea)
  238. textarea.select()
  239. document.execCommand('copy')
  240. textarea.remove()
  241. ElMessage.success(t('common.nodeBase.codeEditor.copySuccess'))
  242. }
  243. }
  244. /**
  245. * @description: 设置全屏样式
  246. * @return {*}
  247. */
  248. const fullScreenStyle = computed(() => {
  249. if (isFullScreen.value) return {}
  250. return {
  251. height: `${bodyHeight.value + 40}px`
  252. }
  253. })
  254. /**
  255. * @description: 切换主题, 并设置对应样式
  256. * @return {*}
  257. */
  258. const toolTipClass = computed(() => {
  259. return `editor-tooltip editor-tooltip--${themeClass.value}`
  260. })
  261. const onToggleTheme = () => {
  262. toggleTheme()
  263. }
  264. const syncEditorLayout = () => {
  265. if (layoutFrame) {
  266. cancelAnimationFrame(layoutFrame)
  267. }
  268. layoutFrame = requestAnimationFrame(() => {
  269. layoutFrame = 0
  270. monacoEditor?.layout()
  271. })
  272. }
  273. const stopResize = () => {
  274. if (!resizeState.isDragging) return
  275. resizeState.isDragging = false
  276. document.body.style.userSelect = ''
  277. document.body.style.cursor = ''
  278. window.removeEventListener('mousemove', onResize)
  279. window.removeEventListener('mouseup', stopResize)
  280. }
  281. const onResize = (event: MouseEvent) => {
  282. if (!resizeState.isDragging || isFullScreen.value) return
  283. const nextHeight = resizeState.startHeight + event.clientY - resizeState.startY
  284. const targetHeight = Math.max(props.height, nextHeight)
  285. if (targetHeight === bodyHeight.value) return
  286. bodyHeight.value = targetHeight
  287. syncEditorLayout()
  288. }
  289. const onResizeStart = (event: MouseEvent) => {
  290. if (isFullScreen.value) return
  291. resizeState.startY = event.clientY
  292. resizeState.startHeight = bodyHeight.value
  293. resizeState.isDragging = true
  294. document.body.style.userSelect = 'none'
  295. document.body.style.cursor = 'ns-resize'
  296. window.addEventListener('mousemove', onResize)
  297. window.addEventListener('mouseup', stopResize)
  298. }
  299. /**
  300. * 设置文本
  301. * @param text
  302. */
  303. function setValue(text: string) {
  304. monacoEditor?.setValue(text || '')
  305. }
  306. /**
  307. * 光标处插入文本
  308. * @param text
  309. */
  310. function insertText(text: string) {
  311. // 获取光标位置
  312. const position = monacoEditor?.getPosition()
  313. // 未获取到光标位置信息
  314. if (!position) {
  315. return
  316. }
  317. // 插入
  318. monacoEditor?.executeEdits('', [
  319. {
  320. range: new monaco.Range(
  321. position.lineNumber,
  322. position.column,
  323. position.lineNumber,
  324. position.column
  325. ),
  326. text
  327. }
  328. ])
  329. // 设置新的光标位置
  330. monacoEditor?.setPosition({
  331. ...position,
  332. column: position.column + text.length
  333. })
  334. // 重新聚焦
  335. monacoEditor?.focus()
  336. }
  337. // 销毁model
  338. onBeforeUnmount(() => {
  339. stopResize()
  340. if (layoutFrame) {
  341. cancelAnimationFrame(layoutFrame)
  342. }
  343. if (model) {
  344. clearCodeEditorCompletionConfig(model)
  345. }
  346. monacoEditor?.dispose()
  347. model?.dispose()
  348. })
  349. defineExpose({
  350. insertText,
  351. setValue
  352. })
  353. </script>
  354. <template>
  355. <Teleport :disabled="!appendTo" :to="appendTo">
  356. <div
  357. class="monacoEditor !m-0"
  358. :class="[
  359. themeClass,
  360. { 'is-fullscreen': isFullScreen, 'is-resizing': resizeState.isDragging }
  361. ]"
  362. :style="fullScreenStyle"
  363. >
  364. <div class="tools h-[33px] flex items-center justify-between gap-2" v-if="props.tools">
  365. <!-- 语法切换 -->
  366. <ElTooltip
  367. :effect="themeClass"
  368. :popper-class="toolTipClass"
  369. placement="top"
  370. :content="t('common.nodeBase.codeEditor.switchLanguage')"
  371. >
  372. <div class="w-1/3">
  373. <ElSelect v-if="allowChangeLanguage" v-model="componentConfig.language">
  374. <ElOption v-for="value in languageSource" :label="value.name" :value="value.id" />
  375. </ElSelect>
  376. </div>
  377. </ElTooltip>
  378. <div class="flex-1 flex items-center justify-end gap-1">
  379. <!-- 放大/缩小 -->
  380. <ElTooltip
  381. :effect="themeClass"
  382. placement="top"
  383. :popper-class="toolTipClass"
  384. :content="
  385. !isFullScreen
  386. ? t('common.nodeBase.codeEditor.enterFullscreen')
  387. : t('common.nodeBase.codeEditor.exitFullscreen')
  388. "
  389. >
  390. <div
  391. class="cursor-pointer text-xl text-center px-2"
  392. @click="isFullScreen = !isFullScreen"
  393. v-if="props.allowFullscreen"
  394. >
  395. <IconButton
  396. :icon="isFullScreen ? 'lucide:minimize' : 'lucide:fullscreen'"
  397. link
  398. class="fullscreen"
  399. />
  400. </div>
  401. </ElTooltip>
  402. <!-- copy -->
  403. <ElTooltip
  404. :effect="themeClass"
  405. :popper-class="toolTipClass"
  406. placement="top"
  407. :content="t('common.nodeBase.codeEditor.copy')"
  408. >
  409. <div class="copy text-center px-2" @click="copyCode" v-if="props.copyCode">
  410. <IconButton icon="lucide:copy" link class="copyIcon"></IconButton>
  411. </div>
  412. </ElTooltip>
  413. <!-- 主题 -->
  414. <ElTooltip
  415. :effect="themeClass"
  416. :popper-class="toolTipClass"
  417. placement="top"
  418. :content="t('common.nodeBase.codeEditor.theme')"
  419. >
  420. <div class="copy text-center px-2" @click="onToggleTheme">
  421. <IconButton
  422. :icon="themeClass === 'dark' ? 'lucide:moon' : 'lucide:sun'"
  423. link
  424. class="themeIcon"
  425. >
  426. </IconButton>
  427. </div>
  428. </ElTooltip>
  429. </div>
  430. </div>
  431. <!-- 编辑器 -->
  432. <div class="editor-wrapper">
  433. <div ref="editContainer" class="code-editor"></div>
  434. <div v-if="!isFullScreen" class="resize-handle" @mousedown.prevent="onResizeStart"></div>
  435. </div>
  436. </div>
  437. </Teleport>
  438. </template>
  439. <style lang="less">
  440. .editor-tooltip {
  441. border-radius: 6px;
  442. font-size: 12px;
  443. padding: 6px 10px;
  444. }
  445. // light
  446. .editor-tooltip--light {
  447. background-color: var(--bg-base) !important;
  448. color: #1e1e1e !important;
  449. border: 1px solid #e5e7eb;
  450. .el-popper__arrow::before {
  451. background: var(--bg-base) !important;
  452. border: 1px solid #e5e7eb;
  453. }
  454. }
  455. // dark
  456. .editor-tooltip--dark {
  457. background-color: #1e1e1e !important;
  458. color: var(--bg-base) !important;
  459. .el-popper__arrow::before {
  460. background: #1e1e1e !important;
  461. }
  462. }
  463. </style>
  464. <style lang="less" scoped>
  465. .monacoEditor {
  466. border: 1px solid #dcdfe6;
  467. border-radius: 4px;
  468. overflow: hidden;
  469. display: flex;
  470. flex-direction: column;
  471. transition:
  472. height 0.25s ease,
  473. inset 0.25s ease,
  474. background-color 0.2s;
  475. &.is-resizing {
  476. transition:
  477. inset 0.25s ease,
  478. background-color 0.2s;
  479. }
  480. &.is-fullscreen {
  481. position: absolute;
  482. inset: 0;
  483. z-index: 1000;
  484. }
  485. .tools {
  486. border-bottom: 1px solid #eeeeee83;
  487. padding-bottom: 6px;
  488. ::v-deep(.el-select .el-select__wrapper) {
  489. border: none;
  490. box-shadow: none;
  491. background-color: var(--bg-base);
  492. }
  493. ::v-deep(.el-select .el-select__placeholder) {
  494. color: #1e1e1e;
  495. }
  496. }
  497. .editor-wrapper {
  498. flex: 1;
  499. overflow: hidden;
  500. position: relative;
  501. .resize-handle {
  502. position: absolute;
  503. bottom: 8px;
  504. left: 50%;
  505. transform: translateX(-50%);
  506. width: 18px;
  507. height: 4px;
  508. background-color: #d0d5dd;
  509. border-radius: 8px;
  510. cursor: ns-resize;
  511. }
  512. }
  513. .code-editor {
  514. width: 100%;
  515. height: 100%;
  516. ::v-deep(.monaco-editor) {
  517. height: 100%;
  518. background-color: #ffffff;
  519. }
  520. &.bordered {
  521. border: 1px solid var(--epic-border-color);
  522. }
  523. }
  524. }
  525. .light {
  526. background-color: #ffffff;
  527. color: #1e1e1e;
  528. &:hover {
  529. color: #1e1e1e;
  530. }
  531. .tools {
  532. ::v-deep(.el-select .el-select__wrapper) {
  533. border: none;
  534. box-shadow: none;
  535. background-color: #ffffff;
  536. }
  537. ::v-deep(.el-select .el-select__placeholder) {
  538. color: #1e1e1e;
  539. }
  540. .fullscreen,
  541. .fullscreen-exit,
  542. .themeIcon,
  543. .copyIcon {
  544. color: #1e1e1e;
  545. }
  546. }
  547. }
  548. .dark {
  549. background-color: #1e1e1e;
  550. color: #ffffff;
  551. &:hover {
  552. color: #ffffff;
  553. }
  554. .tools {
  555. ::v-deep(.el-select .el-select__wrapper) {
  556. border: none;
  557. box-shadow: none;
  558. background-color: #1e1e1e;
  559. }
  560. ::v-deep(.el-select .el-select__placeholder) {
  561. color: #ffffff;
  562. }
  563. .fullscreen,
  564. .fullscreen-exit,
  565. .themeIcon,
  566. .copyIcon {
  567. color: #ffffff;
  568. }
  569. }
  570. }
  571. </style>