CodeEditor.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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. interface CodeEditorType {
  10. // 是否展示工具栏
  11. tools?: boolean
  12. // 是否展示全屏工具
  13. allowFullscreen?: boolean
  14. // 是否展示复制工具
  15. copyCode?: boolean
  16. // 是否自动切换主题
  17. autoToggleTheme?: boolean
  18. // 挂载DOM
  19. appendTo?: string | HTMLElement
  20. // 内容
  21. modelValue?: any
  22. // 语法
  23. language?: string
  24. // 主题
  25. theme?: 'vs-light' | 'vs-dark'
  26. // 是否只读
  27. readOnly?: boolean
  28. // 是否显示行号
  29. lineNumbers?: 'on' | 'off'
  30. // 设置代码模块高度,总高度多40px
  31. height?: number
  32. // 返回结果为string,也可转化为json
  33. formatValue?: 'string' | 'json'
  34. // 插件内部配置
  35. config?: editor.IStandaloneEditorConstructionOptions
  36. }
  37. const props = withDefaults(defineProps<CodeEditorType>(),
  38. {
  39. //默认配置
  40. tools: true,
  41. copyCode: true,
  42. readOnly: false,
  43. allowFullscreen: true,
  44. autoToggleTheme: true,
  45. language: 'javascript',
  46. lineNumbers: 'on',
  47. theme: 'vs-light',
  48. formatValue: 'string',
  49. height: 150,
  50. config: () => ({
  51. minimap: { enabled: false },
  52. selectOnLineNumbers: true
  53. }),
  54. }
  55. )
  56. const emit = defineEmits(['update:modelValue', 'update:language'])
  57. let monacoEditor: monaco.editor.IStandaloneCodeEditor | null = null
  58. let model: editor.ITextModel | null = null
  59. const editContainer = ref<HTMLElement>()
  60. const isFullScreen = ref(false)
  61. const {
  62. monacoTheme,
  63. themeClass,
  64. toggleTheme
  65. } = useLocalEditorTheme({
  66. defaultTheme: props.theme,
  67. autoToggleTheme: props.autoToggleTheme
  68. })
  69. let componentConfig = reactive({
  70. language: props.language
  71. })
  72. const languageSource = [
  73. { id: 'javascript', name: 'javascript' },
  74. { id: 'python', name: 'python' },
  75. { id: 'json', name: 'json' }
  76. ]
  77. /**
  78. * @description: formatValue为json 格式,需要转换处理
  79. * @return
  80. */
  81. const formatValue = (value: any) => {
  82. if (props.formatValue === 'json') {
  83. return JSON.stringify(value ?? {}, null, 2)
  84. }
  85. return value ?? ''
  86. }
  87. onMounted(() => {
  88. // 处理代码转换
  89. model = monaco.editor.createModel(
  90. formatValue(props.modelValue),
  91. componentConfig.language
  92. )
  93. // 接入配置
  94. monacoEditor = monaco.editor.create(editContainer.value!, {
  95. model,
  96. wordWrap: 'on',
  97. automaticLayout: true,
  98. theme: monacoTheme.value,
  99. readOnly: props.readOnly,
  100. lineNumbers: props.lineNumbers,
  101. ...props.config,
  102. })
  103. // 添加空格
  104. monacoEditor.onKeyDown((e) => {
  105. if (e.keyCode === monaco.KeyCode.Space) {
  106. e.preventDefault();
  107. monacoEditor?.trigger('keyboard', 'type', { text: ' ' });
  108. }
  109. });
  110. // 监听代码输入
  111. monacoEditor.onDidChangeModelContent(updateModelValue)
  112. })
  113. // 读取传值数据
  114. watch(() => props.modelValue, (value) => {
  115. nextTick(() => {
  116. if (!model) return
  117. const next = formatValue(value)
  118. if (model.getValue() !== next) {
  119. model.setValue(next)
  120. }
  121. })
  122. }, { immediate: true })
  123. // 切换语法,触发回调
  124. watch(() => componentConfig.language, (lang) => {
  125. nextTick(() => {
  126. if (!model) return
  127. monaco.editor.setModelLanguage(model, lang)
  128. emit('update:language', lang)
  129. })
  130. }, { immediate: true })
  131. // 监听全屏, 重新计算layout
  132. watch(isFullScreen, () => {
  133. nextTick(() => {
  134. monacoEditor?.layout()
  135. })
  136. })
  137. /**
  138. * @description: 发送回调(formatValue:json,需要转换处理)
  139. * @return {*}
  140. */
  141. const updateModelValue = debounce(() => {
  142. if (!model) return
  143. const value = model.getValue()
  144. if (props.formatValue === 'json') {
  145. try {
  146. emit('update:modelValue', JSON.parse(value))
  147. } catch {
  148. return ElMessage.warning('JSON 语法错误')
  149. }
  150. } else {
  151. emit('update:modelValue', value)
  152. }
  153. }, 1000)
  154. /**
  155. * @description: 复制代码到粘贴板
  156. * @return {*}
  157. */
  158. const copyCode = () => {
  159. const text = model?.getValue()
  160. if (!text) return
  161. try {
  162. navigator.clipboard.writeText(text).then(() => {
  163. ElMessage.success('复制成功!')
  164. })
  165. } catch {
  166. const textarea = document.createElement('textarea')
  167. textarea.value = text
  168. document.body.appendChild(textarea)
  169. textarea.select()
  170. document.execCommand('copy')
  171. textarea.remove()
  172. ElMessage.success('复制成功!')
  173. }
  174. }
  175. /**
  176. * @description: 设置全屏样式
  177. * @return {*}
  178. */
  179. const fullScreenStyle = computed(() => {
  180. if (isFullScreen.value) return {}
  181. return {
  182. height: `${props.height + 40}px`
  183. }
  184. })
  185. /**
  186. * @description: 切换主题, 并设置对应样式
  187. * @return {*}
  188. */
  189. const toolTipClass = computed(() => {
  190. return `editor-tooltip editor-tooltip--${themeClass.value}`
  191. })
  192. const onToggleTheme = () => {
  193. toggleTheme()
  194. }
  195. /**
  196. * 设置文本
  197. * @param text
  198. */
  199. function setValue(text: string) {
  200. monacoEditor?.setValue(text || '')
  201. }
  202. /**
  203. * 光标处插入文本
  204. * @param text
  205. */
  206. function insertText(text: string) {
  207. // 获取光标位置
  208. const position = monacoEditor?.getPosition()
  209. // 未获取到光标位置信息
  210. if (!position) {
  211. return
  212. }
  213. // 插入
  214. monacoEditor?.executeEdits('', [
  215. {
  216. range: new monaco.Range(
  217. position.lineNumber,
  218. position.column,
  219. position.lineNumber,
  220. position.column
  221. ),
  222. text
  223. }
  224. ])
  225. // 设置新的光标位置
  226. monacoEditor?.setPosition({
  227. ...position,
  228. column: position.column + text.length
  229. })
  230. // 重新聚焦
  231. monacoEditor?.focus()
  232. }
  233. // 销毁model
  234. onBeforeUnmount(() => {
  235. monacoEditor?.dispose()
  236. model?.dispose()
  237. })
  238. defineExpose({
  239. insertText,
  240. setValue
  241. })
  242. </script>
  243. <template>
  244. <Teleport :disabled="!appendTo" :to="appendTo">
  245. <div class="monacoEditor !m-0" :class="[themeClass, { 'is-fullscreen': isFullScreen }]"
  246. :style="fullScreenStyle">
  247. <div class="tools h-[33px] flex items-center justify-between gap-2" v-if="props.tools">
  248. <!-- 语法切换 -->
  249. <ElTooltip :effect="themeClass" :popper-class="toolTipClass" placement="top" content="切换语法">
  250. <div class="w-1/3">
  251. <ElSelect v-model="componentConfig.language">
  252. <ElOption v-for="value in languageSource" :label="value.name" :value="value.id" />
  253. </ElSelect>
  254. </div>
  255. </ElTooltip>
  256. <div class="flex-1 flex items-center justify-end gap-1">
  257. <!-- 放大/缩小 -->
  258. <ElTooltip :effect="themeClass" placement="top" :popper-class="toolTipClass"
  259. :content="!isFullScreen ? '放大' : '恢复正常'">
  260. <div class="cursor-pointer text-xl text-center px-2" @click="isFullScreen = !isFullScreen"
  261. v-if="props.allowFullscreen">
  262. <IconButton :icon="isFullScreen ? 'lucide:minimize' : 'lucide:fullscreen'" link
  263. class="fullscreen" />
  264. </div>
  265. </ElTooltip>
  266. <!-- copy -->
  267. <ElTooltip :effect="themeClass" :popper-class="toolTipClass" placement="top" content="复制">
  268. <div class="copy text-center px-2" @click="copyCode" v-if="props.copyCode">
  269. <IconButton icon="lucide:copy" link class="copyIcon"></IconButton>
  270. </div>
  271. </ElTooltip>
  272. <!-- 主题 -->
  273. <ElTooltip :effect="themeClass" :popper-class="toolTipClass" placement="top" content="主题">
  274. <div class="copy text-center px-2" @click="onToggleTheme">
  275. <IconButton :icon="themeClass === 'dark' ? 'lucide:moon' : 'lucide:sun'" link
  276. class="themeIcon">
  277. </IconButton>
  278. </div>
  279. </ElTooltip>
  280. </div>
  281. </div>
  282. <!-- 编辑器 -->
  283. <div class="editor-wrapper">
  284. <div ref="editContainer" class="code-editor"></div>
  285. </div>
  286. </div>
  287. </Teleport>
  288. </template>
  289. <style lang="less">
  290. .editor-tooltip {
  291. border-radius: 6px;
  292. font-size: 12px;
  293. padding: 6px 10px;
  294. }
  295. // light
  296. .editor-tooltip--light {
  297. background-color: #ffffff !important;
  298. color: #1e1e1e !important;
  299. border: 1px solid #e5e7eb;
  300. .el-popper__arrow::before {
  301. background: #ffffff !important;
  302. border: 1px solid #e5e7eb;
  303. }
  304. }
  305. // dark
  306. .editor-tooltip--dark {
  307. background-color: #1e1e1e !important;
  308. color: #ffffff !important;
  309. .el-popper__arrow::before {
  310. background: #1e1e1e !important;
  311. }
  312. }
  313. </style>
  314. <style lang="less" scoped>
  315. .monacoEditor {
  316. border: 1px solid #eee;
  317. display: flex;
  318. flex-direction: column;
  319. transition: height 0.25s ease, inset 0.25s ease, background-color 0.2s;
  320. &.is-fullscreen {
  321. position: absolute;
  322. inset: 0;
  323. z-index: 1000;
  324. }
  325. .tools {
  326. border-bottom: 1px solid #eeeeee83;
  327. padding-bottom: 6px;
  328. ::v-deep(.el-select .el-select__wrapper) {
  329. border: none;
  330. box-shadow: none;
  331. background-color: #ffff;
  332. }
  333. ::v-deep(.el-select .el-select__placeholder) {
  334. color: #1e1e1e;
  335. }
  336. }
  337. .editor-wrapper {
  338. flex: 1;
  339. overflow: hidden;
  340. }
  341. .code-editor {
  342. width: 100%;
  343. height: 100%;
  344. ::v-deep(.monaco-editor) {
  345. height: 100%;
  346. background-color: #ffffff;
  347. }
  348. &.bordered {
  349. border: 1px solid var(--epic-border-color);
  350. }
  351. }
  352. }
  353. .light {
  354. background-color: #ffffff;
  355. color: #1e1e1e;
  356. &:hover {
  357. color: #1e1e1e;
  358. }
  359. .tools {
  360. ::v-deep(.el-select .el-select__wrapper) {
  361. border: none;
  362. box-shadow: none;
  363. background-color: #ffffff;
  364. }
  365. ::v-deep(.el-select .el-select__placeholder) {
  366. color: #1e1e1e;
  367. }
  368. .fullscreen,
  369. .fullscreen-exit,
  370. .themeIcon,
  371. .copyIcon {
  372. color: #1e1e1e;
  373. }
  374. }
  375. }
  376. .dark {
  377. background-color: #1e1e1e;
  378. color: #ffffff;
  379. &:hover {
  380. color: #ffffff;
  381. }
  382. .tools {
  383. ::v-deep(.el-select .el-select__wrapper) {
  384. border: none;
  385. box-shadow: none;
  386. background-color: #1e1e1e;
  387. }
  388. ::v-deep(.el-select .el-select__placeholder) {
  389. color: #ffffff;
  390. }
  391. .fullscreen,
  392. .fullscreen-exit,
  393. .themeIcon,
  394. .copyIcon {
  395. color: #ffffff;
  396. }
  397. }
  398. }
  399. </style>