From c5182e42d8d8ec755d4f68cdde801145683e7c2f Mon Sep 17 00:00:00 2001 From: wangxiang <1827945911@qq.com> Date: Tue, 27 Feb 2024 00:02:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=81=E8=A3=85ACE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 6 + src/components/AceEditor/index.ts | 6 + src/components/AceEditor/src/AceEditor.tsx | 192 +++++++++++++++++++ src/components/AceEditor/src/ace-config.ts | 55 ++++++ src/components/AceEditor/src/emits.ts | 12 ++ src/components/AceEditor/src/props.ts | 21 ++ src/components/AceEditor/src/types.ts | 38 ++++ src/components/AceEditor/src/useAceEditor.ts | 77 ++++++++ src/locales/lang/en/routes/demo.ts | 2 + src/locales/lang/zh-CN/routes/demo.ts | 2 + src/router/routes/demo/comp.ts | 20 ++ src/views/demo/editor/ace/index.vue | 69 +++++++ 13 files changed, 501 insertions(+) create mode 100644 src/components/AceEditor/index.ts create mode 100644 src/components/AceEditor/src/AceEditor.tsx create mode 100644 src/components/AceEditor/src/ace-config.ts create mode 100644 src/components/AceEditor/src/emits.ts create mode 100644 src/components/AceEditor/src/props.ts create mode 100644 src/components/AceEditor/src/types.ts create mode 100644 src/components/AceEditor/src/useAceEditor.ts create mode 100644 src/views/demo/editor/ace/index.vue diff --git a/package.json b/package.json index 65f7e6f..c176ef9 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@vueuse/core": "^9.1.1", "@vueuse/shared": "^9.1.1", "@zxcvbn-ts/core": "^2.0.1", + "ace-builds": "^1.32.6", "ant-design-vue": "^3.2.12", "axios": "^0.26.1", "cropperjs": "^1.5.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a380763..976dbc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,7 @@ specifiers: '@vueuse/core': ^9.1.1 '@vueuse/shared': ^9.1.1 '@zxcvbn-ts/core': ^2.0.1 + ace-builds: ^1.32.6 ant-design-vue: ^3.2.12 autoprefixer: ^10.4.4 axios: ^0.26.1 @@ -103,6 +104,7 @@ dependencies: '@vueuse/core': 9.13.0_vue@3.3.4 '@vueuse/shared': 9.13.0_vue@3.3.4 '@zxcvbn-ts/core': 2.2.1 + ace-builds: 1.32.6 ant-design-vue: 3.2.20_vue@3.3.4 axios: 0.26.1 cropperjs: 1.6.1 @@ -2480,6 +2482,10 @@ packages: through: 2.3.8 dev: true + /ace-builds/1.32.6: + resolution: {integrity: sha512-dO5BnyDOhCnznhOpILzXq4jqkbhRXxNkf3BuVTmyxGyRLrhddfdyk6xXgy+7A8LENrcYoFi/sIxMuH3qjNUN4w==} + dev: false + /acorn-jsx/5.3.2_acorn@8.10.0: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: diff --git a/src/components/AceEditor/index.ts b/src/components/AceEditor/index.ts new file mode 100644 index 0000000..fedd319 --- /dev/null +++ b/src/components/AceEditor/index.ts @@ -0,0 +1,6 @@ +import { withInstall } from '/@/utils'; +import aceEditor from './src/AceEditor'; + +export * from './src/types'; +export const AceEditor = withInstall(aceEditor); +export { useAceEditor } from './src/useAceEditor'; diff --git a/src/components/AceEditor/src/AceEditor.tsx b/src/components/AceEditor/src/AceEditor.tsx new file mode 100644 index 0000000..525bd8d --- /dev/null +++ b/src/components/AceEditor/src/AceEditor.tsx @@ -0,0 +1,192 @@ +import ace, { type Ace } from 'ace-builds'; +import { + defineComponent, + markRaw, + watch, + reactive, + onBeforeUnmount, + onMounted, + ref, + unref, + computed, + nextTick +} from 'vue'; +import ResizeObserver from 'resize-observer-polyfill'; +import { basicEmits } from './emits'; +import { basicProps } from './props'; +import type { AceEditorState, AceEditorMethods, AceEditorProps } from './types'; +import { deepMerge } from '/@/utils'; +import { useAppStore } from '/@/store/modules/app'; +import './ace-config'; + +export default defineComponent({ + name: 'AceEditor', + props: basicProps, + emits: basicEmits, + setup(props, { emit, attrs }) { + const aceEditorElRef = ref(); + const propsRef = ref>(); + const appStore = useAppStore(); + + const state = reactive({ + _editor: undefined!, + _ro: undefined!, + _contentBackup: '', + _isSettingContent: false, + }); + + const getProps = computed((): Recordable => { + return { + ...props, + ...(unref(propsRef) as any), + }; + }); + + onMounted(async () => { + await nextTick(); + const editor = state._editor = markRaw(ace.edit(aceEditorElRef.value, { + placeholder: props.placeholder, + readOnly: props.readonly, + value: props.value, + mode: 'ace/mode/' + props.lang, + theme: 'ace/theme/' + props.theme, + wrap: props.wrap, + printMargin: props.printMargin, + useWorker: false, + minLines: props.minLines, + maxLines: props.maxLines, + ...props.options, + })); + setTheme(); + state._contentBackup = props.value; + state._isSettingContent = false; + editor.on('change', () => { + // ref: https://github.com/CarterLi/vue3-ace-editor/issues/11 + if (state._isSettingContent) return; + const content = editor.getValue(); + state._contentBackup = content; + emit('update:value', content); + }); + // Event Binding + editor.on('blur', (e: Event) => emit('blur', e)); + editor.on('input', () => emit('input')); + editor.on('changeSelectionStyle', (obj: { data: string }) => emit('changeSelectionStyle', obj)); + editor.on('changeSession', (obj: { session: Ace.EditSession, oldSession: Ace.EditSession }) => emit('changeSelectionStyle', obj)); + editor.on('copy', (obj: { text: string }) => emit('copy', obj)); + editor.on('focus', (e: Event) => emit('focus', e)); + editor.on('paste', (obj: { text: string }) => emit('paste', obj)); + + state._ro = new ResizeObserver(() => editor.resize()); + state._ro.observe(aceEditorElRef.value); + emit('register', aceEditorMethods); + }); + + onBeforeUnmount(() => { + state._ro?.disconnect(); + state._editor?.destroy(); + }); + + watch(() => appStore.getDarkMode, async () => { + setTheme(); + }, + { + immediate: true, + }, + ); + + watch(() => unref(getProps).value, (val) => { + if (state._contentBackup !== val) { + try { + state._isSettingContent = true; + state._editor.setValue(val, 1); + } finally { + state._isSettingContent = false; + } + state._contentBackup = val; + } + }); + + watch(() => unref(getProps).theme, (val) => { + state._editor.setTheme('ace/theme/' + val); + }); + + watch(() => unref(getProps).options, (val: Partial) => { + state._editor.setOptions(val); + }); + + watch(() => unref(getProps).readonly, (val) => { + state._editor.setReadOnly(val); + }); + + watch(() => unref(getProps).placeholder, (val) => { + state._editor.setOption('placeholder', val); + }); + + watch(() => unref(getProps).wrap, (val) => { + state._editor.setWrapBehavioursEnabled(val); + }); + + watch(() => unref(getProps).printMargin, (val) => { + state._editor.setOption('printMargin', val); + }); + + watch(() => unref(getProps).lang, (val) => { + state._editor.setOption('mode', 'ace/mode/' + val); + }); + + watch(() => unref(getProps).minLines, (val) => { + state._editor.setOption('minLines', val); + }); + + watch(() => unref(getProps).maxLines, (val) => { + state._editor.setOption('maxLines', val); + }); + + function setTheme() { + const theme = appStore.getDarkMode === 'light' ? 'chrome' : 'monokai'; + state._editor?.setTheme('ace/theme/' + theme); + } + + function setProps(props: Partial) { + propsRef.value = deepMerge(unref(propsRef) || {}, props); + } + + function focus() { + state._editor.focus(); + } + + function blur() { + state._editor.blur(); + } + + function selectAll() { + state._editor.selectAll(); + } + + function getAceInstance(): Ace.Editor { + return state._editor; + } + + const aceEditorMethods: AceEditorMethods = { + setProps, + focus, + blur, + selectAll, + getAceInstance + }; + + return { + aceEditorElRef, + setProps, + focus, + blur, + selectAll, + getAceInstance, + }; + }, + render() { + return ( +
+ ); + } +}); diff --git a/src/components/AceEditor/src/ace-config.ts b/src/components/AceEditor/src/ace-config.ts new file mode 100644 index 0000000..4b9196a --- /dev/null +++ b/src/components/AceEditor/src/ace-config.ts @@ -0,0 +1,55 @@ +import ace from 'ace-builds'; + +import modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'; +ace.config.setModuleUrl('ace/mode/json', modeJsonUrl); + +import modeJavascriptUrl from 'ace-builds/src-noconflict/mode-javascript?url'; +ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl); + +import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'; +ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl); + +import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'; +ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl); + +import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'; +ace.config.setModuleUrl('ace/theme/github', themeGithubUrl); + +import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'; +ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl); + +import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'; +ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl); + +import themeDraculaUrl from 'ace-builds/src-noconflict/theme-dracula?url'; +ace.config.setModuleUrl('ace/theme/dracula', themeDraculaUrl); + +import workerBaseUrl from 'ace-builds/src-noconflict/worker-base?url'; +ace.config.setModuleUrl('ace/mode/base', workerBaseUrl); + +import workerJsonUrl from 'ace-builds/src-noconflict/worker-json?url'; +ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl); + +import workerJavascriptUrl from 'ace-builds/src-noconflict/worker-javascript?url'; +ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl); + +import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'; +ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl); + +import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'; +ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl); + +import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'; +ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl); + +import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'; +ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl); + +import snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'; +ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl); + +import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'; +ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl); + +import 'ace-builds/src-noconflict/ext-language_tools'; +ace.require('ace/ext/language_tools'); diff --git a/src/components/AceEditor/src/emits.ts b/src/components/AceEditor/src/emits.ts new file mode 100644 index 0000000..1702f3c --- /dev/null +++ b/src/components/AceEditor/src/emits.ts @@ -0,0 +1,12 @@ +export const basicEmits = [ + 'register', + 'update:value', + 'blur', + 'input', + 'change', + 'changeSelectionStyle', + 'changeSession', + 'copy', + 'focus', + 'paste', +]; diff --git a/src/components/AceEditor/src/props.ts b/src/components/AceEditor/src/props.ts new file mode 100644 index 0000000..1a7cc88 --- /dev/null +++ b/src/components/AceEditor/src/props.ts @@ -0,0 +1,21 @@ +import { propTypes } from '/@/utils/propTypes'; +import type { PropType } from 'vue'; +import { Ace } from 'ace-builds'; + +export const basicProps = { + value: propTypes.string.isRequired, + lang: propTypes.string.def('text'), + theme: propTypes.string.def('chrome'), + options: { + type: Object as PropType, + }, + placeholder: propTypes.string.def(''), + readonly: propTypes.bool, + wrap: propTypes.bool, + printMargin: { + type: [Boolean, Number] as PropType, + default: true, + }, + minLines: propTypes.number, + maxLines: propTypes.number, +}; diff --git a/src/components/AceEditor/src/types.ts b/src/components/AceEditor/src/types.ts new file mode 100644 index 0000000..f7f8d2e --- /dev/null +++ b/src/components/AceEditor/src/types.ts @@ -0,0 +1,38 @@ +import type { Ace } from 'ace-builds'; + +export interface AceEditorPrivate { + _editor: Ace.Editor; + _ro: ResizeObserver; + _contentBackup: string; + _isSettingContent: boolean; +} + +export interface AceEditorState extends AceEditorPrivate { + [key: string]: any; +} + +export interface AceEditorProps { + value: string; + lang: string; + theme: string; + options: Partial; + placeholder: string; + readonly: boolean; + wrap: boolean; + printMargin: boolean | number; + minLines: number; + maxLines: number; +} + +export interface AceEditorMethods { + focus: () => void; + blur: () => void; + selectAll: () => void; + setProps: (props: Partial) => void; + getAceInstance: () => Ace.Editor; +} + +export interface AceEditorInstance extends + AceEditorPrivate, + AceEditorProps, + AceEditorMethods {} diff --git a/src/components/AceEditor/src/useAceEditor.ts b/src/components/AceEditor/src/useAceEditor.ts new file mode 100644 index 0000000..8b006b3 --- /dev/null +++ b/src/components/AceEditor/src/useAceEditor.ts @@ -0,0 +1,77 @@ +import { AceEditorProps, AceEditorMethods, AceEditorInstance } from '/@/components/AceEditor/src/types'; +import { onUnmounted, ref, unref, watch, type WatchStopHandle } from 'vue'; +import { isProdMode } from '/@/utils/env'; +import { getDynamicProps } from '/@/utils'; +import { error } from '/@/utils/log'; +import type { DynamicProps } from '/#/utils'; + +type Props = Partial>; + +export function useAceEditor(aceEditorProps?: Props): [ + (instance: AceEditorInstance) => void, + AceEditorMethods +] { + + const aceEditorRef = ref>(null); + const loadedRef = ref>(false); + + let stopWatch: WatchStopHandle; + + async function register(instance: AceEditorInstance) { + isProdMode() && + onUnmounted(() => { + aceEditorRef.value = null; + loadedRef.value = null; + }); + + // 防止同一个组件重复注册 + if (unref(loadedRef) && isProdMode() && instance === unref(aceEditorRef)) return; + + aceEditorRef.value = instance; + aceEditorProps && instance.setProps(getDynamicProps(aceEditorProps)); + loadedRef.value = true; + + stopWatch?.(); + + stopWatch = watch( + () => aceEditorProps, + () => { + aceEditorProps && instance.setProps(getDynamicProps(aceEditorProps)); + }, + { + immediate: true, + deep: true, + }, + ); + } + + function getAceEditorInstance(): AceEditorInstance { + const aceEditor = unref(aceEditorRef); + if (!aceEditor) { + error( + 'The AceEditor instance has not been obtained yet, please make sure the AceEditor is presented when performing the AceEditor operation!', + ); + } + return aceEditor as AceEditorInstance; + } + + const methods: AceEditorMethods = { + focus: () => { + getAceEditorInstance().focus(); + }, + blur: () => { + getAceEditorInstance().blur(); + }, + selectAll: () => { + getAceEditorInstance().selectAll(); + }, + setProps: (props: Partial) => { + getAceEditorInstance().setProps(props); + }, + getAceInstance: () => { + return getAceEditorInstance().getAceInstance(); + } + }; + + return [register, methods]; +} diff --git a/src/locales/lang/en/routes/demo.ts b/src/locales/lang/en/routes/demo.ts index 6f4ae39..e4017e6 100644 --- a/src/locales/lang/en/routes/demo.ts +++ b/src/locales/lang/en/routes/demo.ts @@ -51,6 +51,8 @@ export default { editor: 'Editor', jsonEditor: 'Json editor', markdown: 'Markdown editor', + aceEditor: 'Ace editor', + aceEditorBasic: 'Ace Basic', tinymce: 'Rich text', tinymceBasic: 'Basic', diff --git a/src/locales/lang/zh-CN/routes/demo.ts b/src/locales/lang/zh-CN/routes/demo.ts index f6229de..949b625 100644 --- a/src/locales/lang/zh-CN/routes/demo.ts +++ b/src/locales/lang/zh-CN/routes/demo.ts @@ -50,6 +50,8 @@ export default { editor: '编辑器', jsonEditor: 'Json编辑器', markdown: 'markdown编辑器', + aceEditor: 'Ace编辑器', + aceEditorBasic: 'ACE基础使用', tinymce: '富文本', tinymceBasic: '基础使用', diff --git a/src/router/routes/demo/comp.ts b/src/router/routes/demo/comp.ts index f1dca1e..565bc14 100644 --- a/src/router/routes/demo/comp.ts +++ b/src/router/routes/demo/comp.ts @@ -335,6 +335,26 @@ const comp: AppRouteModule = { title: t('routes.demo.editor.editor'), }, children: [ + { + path: 'aceEditor', + component: getParentLayout('AceEditorDemo'), + name: 'AceEditorDemo', + meta: { + title: t('routes.demo.editor.aceEditor'), + }, + redirect: '/comp/editor/aceEditor/index', + children: [ + { + path: 'index', + name: 'AceEditorBasicDemo', + component: () => import('/@/views/demo/editor/ace/index.vue'), + meta: { + title: t('routes.demo.editor.aceEditorBasic'), + }, + }, + ], + }, + { path: 'markdown', component: getParentLayout('MarkdownDemo'), diff --git a/src/views/demo/editor/ace/index.vue b/src/views/demo/editor/ace/index.vue new file mode 100644 index 0000000..17a84a8 --- /dev/null +++ b/src/views/demo/editor/ace/index.vue @@ -0,0 +1,69 @@ + + + +