From 31fa3a07a16c66b0465acfabf3b28b8fcfa2c6bf Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 9 Jun 2025 14:41:30 +0100 Subject: [PATCH 01/12] Extract code fieldtype into UI component --- .../components/fieldtypes/CodeFieldtype.vue | 175 ++++---------- resources/js/components/ui/CodeEditor.vue | 222 ++++++++++++++++++ resources/js/components/ui/index.js | 2 + 3 files changed, 264 insertions(+), 135 deletions(-) create mode 100644 resources/js/components/ui/CodeEditor.vue diff --git a/resources/js/components/fieldtypes/CodeFieldtype.vue b/resources/js/components/fieldtypes/CodeFieldtype.vue index d14b91c55a..662dae5c44 100644 --- a/resources/js/components/fieldtypes/CodeFieldtype.vue +++ b/resources/js/components/fieldtypes/CodeFieldtype.vue @@ -1,6 +1,5 @@ + + diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 86f3e62ef8..fab27fdba9 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -9,6 +9,7 @@ import { default as CardPanel } from './Card/Panel.vue'; import { default as CharacterCounter } from './CharacterCounter.vue'; import { default as Checkbox } from './Checkbox/Item.vue'; import { default as CheckboxGroup } from './Checkbox/Group.vue'; +import { default as CodeEditor } from './CodeEditor.vue'; import { default as Combobox } from './Combobox.vue'; import { default as Context } from './Context/Context.vue'; import { default as ContextFooter } from './Context/Footer.vue'; @@ -63,6 +64,7 @@ export { CharacterCounter, Checkbox, CheckboxGroup, + CodeEditor, Combobox, Context, ContextFooter, From 6a6214ee24e30f08188f563013b110ad053078fa Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 9 Jun 2025 17:39:49 +0100 Subject: [PATCH 02/12] wip --- .../components/fieldtypes/CodeFieldtype.vue | 17 ++++- resources/js/components/ui/CodeEditor.vue | 64 ++++--------------- 2 files changed, 27 insertions(+), 54 deletions(-) diff --git a/resources/js/components/fieldtypes/CodeFieldtype.vue b/resources/js/components/fieldtypes/CodeFieldtype.vue index 662dae5c44..bfbe32ed41 100644 --- a/resources/js/components/fieldtypes/CodeFieldtype.vue +++ b/resources/js/components/fieldtypes/CodeFieldtype.vue @@ -36,8 +36,8 @@ this.$refs.codeEditor?.refresh()); + }, + methods: { modeUpdated(mode) { this.updateDebounced({ code: this.value.code, mode }); @@ -140,5 +145,15 @@ export default { this.fullScreenMode = !this.fullScreenMode; }, }, + + watch: { + fullScreenMode(fullScreenMode) { + this.$refs.codeEditor?.codemirror.setOption('fullScreen', fullScreenMode); + + if (! fullScreenMode) { + this.$refs.codeEditor.$el.removeAttribute('style'); + } + } + } }; diff --git a/resources/js/components/ui/CodeEditor.vue b/resources/js/components/ui/CodeEditor.vue index ee9d6702cc..ffbab12593 100644 --- a/resources/js/components/ui/CodeEditor.vue +++ b/resources/js/components/ui/CodeEditor.vue @@ -39,43 +39,13 @@ const emit = defineEmits(['update:modelValue', 'focus', 'blur']); const props = defineProps({ mode: String, - modes: { - type: Array, - default: () => [ - { value: 'clike', label: 'C-Like' }, - { value: 'css', label: 'CSS' }, - { value: 'diff', label: 'Diff' }, - { value: 'go', label: 'Go' }, - { value: 'haml', label: 'HAML' }, - { value: 'handlebars', label: 'Handlebars' }, - { value: 'htmlmixed', label: 'HTML' }, - { value: 'less', label: 'LESS' }, - { value: 'markdown', label: 'Markdown' }, - { value: 'gfm', label: 'Markdown (GHF)' }, - { value: 'nginx', label: 'Nginx' }, - { value: 'text/x-java', label: 'Java' }, - { value: 'javascript', label: 'JavaScript' }, - { value: 'jsx', label: 'JSX' }, - { value: 'text/x-objectivec', label: 'Objective-C' }, - { value: 'php', label: 'PHP' }, - { value: 'python', label: 'Python' }, - { value: 'ruby', label: 'Ruby' }, - { value: 'scss', label: 'SCSS' }, - { value: 'shell', label: 'Shell' }, - { value: 'sql', label: 'SQL' }, - { value: 'twig', label: 'Twig' }, - { value: 'vue', label: 'Vue' }, - { value: 'xml', label: 'XML' }, - { value: 'yaml-frontmatter', label: 'YAML Frontmatter' } - ], - }, theme: { type: String, default: 'material', }, rulers: { - type: Array, - default: () => [], + type: Object, + default: () => {}, }, disabled: Boolean, keyMap: { @@ -101,12 +71,17 @@ const props = defineProps({ modelValue: String, }); +const codemirror = ref(null); +const codemirrorElement = useTemplateRef('codemirror'); + defineOptions({ inheritAttrs: false, }); -const codemirror = ref(null); -const codemirrorElement = useTemplateRef('codemirror'); +defineExpose({ + codemirror, + refresh, +}) onMounted(() => { nextTick(() => initCodeMirror()); @@ -120,7 +95,7 @@ function initCodeMirror() { direction: document.querySelector('html').getAttribute('dir') ?? 'ltr', addModeClass: true, keyMap: props.keyMap, - tabSize: props.indentSize, + tabSize: props.tabSize, indentWithTabs: props.indentType !== 'spaces', lineNumbers: props.lineNumbers, lineWrapping: props.lineWrapping, @@ -142,27 +117,10 @@ function initCodeMirror() { // Refresh to ensure CodeMirror visible and the proper size // Most applicable when loaded by another field like Bard refresh(); - - // todo - // codemirror.value.setOption('fullScreen', this.fullScreenMode); - - // if (this.fullScreenMode === false) { - // document.documentElement.removeAttribute('style'); - // } - - // CodeMirror also needs to be manually refreshed when made visible in the DOM - // $events.$on('tab-switched', this.refresh); - // todo -} - -function focus() { - codemirror.value.focus(); } function refresh() { - nextTick(() => { - codemirror.value.refresh(); - }) + nextTick(() => codemirror.value.refresh()); } watch( From 026dec12c3aa347f42c4dc55ab8e3828e6450da8 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 9 Jun 2025 17:39:52 +0100 Subject: [PATCH 03/12] wip --- src/Fieldtypes/Code.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Fieldtypes/Code.php b/src/Fieldtypes/Code.php index 4b916685e1..dc7066f477 100644 --- a/src/Fieldtypes/Code.php +++ b/src/Fieldtypes/Code.php @@ -123,7 +123,6 @@ protected function configFieldItems(): array 'instructions' => __('statamic::fieldtypes.any.config.antlers'), 'type' => 'toggle', ], - ], ], ]; From daf1279d93b95752da245a03f612b34cbf15f600 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 9 Jun 2025 17:41:34 +0100 Subject: [PATCH 04/12] wip --- resources/js/components/ui/CodeEditor.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/js/components/ui/CodeEditor.vue b/resources/js/components/ui/CodeEditor.vue index ffbab12593..4010bc9da2 100644 --- a/resources/js/components/ui/CodeEditor.vue +++ b/resources/js/components/ui/CodeEditor.vue @@ -38,7 +38,10 @@ import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter'; const emit = defineEmits(['update:modelValue', 'focus', 'blur']); const props = defineProps({ - mode: String, + mode: { + type: String, + required: true, + }, theme: { type: String, default: 'material', @@ -47,7 +50,10 @@ const props = defineProps({ type: Object, default: () => {}, }, - disabled: Boolean, + disabled: { + type: Boolean, + default: false, + }, keyMap: { type: String, default: 'sublime', From 0d6728fc6cdfc1d4a60cb9ecdfb06423a9f256c5 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 10 Jun 2025 10:02:04 +0100 Subject: [PATCH 05/12] Move the mode selector into the component --- .../components/fieldtypes/CodeFieldtype.vue | 127 +++--------------- resources/js/components/ui/CodeEditor.vue | 126 ++++++++++++++--- 2 files changed, 127 insertions(+), 126 deletions(-) diff --git a/resources/js/components/fieldtypes/CodeFieldtype.vue b/resources/js/components/fieldtypes/CodeFieldtype.vue index bfbe32ed41..59645b0fab 100644 --- a/resources/js/components/fieldtypes/CodeFieldtype.vue +++ b/resources/js/components/fieldtypes/CodeFieldtype.vue @@ -1,113 +1,36 @@ diff --git a/resources/js/components/ui/CodeEditor.vue b/resources/js/components/ui/CodeEditor.vue index 4010bc9da2..3142f944cc 100644 --- a/resources/js/components/ui/CodeEditor.vue +++ b/resources/js/components/ui/CodeEditor.vue @@ -2,6 +2,7 @@ import CodeMirror from 'codemirror'; import { computed, markRaw, nextTick, onMounted, ref, useAttrs, useTemplateRef, watch } from 'vue'; import ElementContainer from '@statamic/components/ElementContainer.vue'; +import { Select } from '@statamic/ui'; // Addons import 'codemirror/addon/edit/matchbrackets'; @@ -35,13 +36,9 @@ import 'codemirror/mode/xml/xml'; import 'codemirror/mode/yaml/yaml'; import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter'; -const emit = defineEmits(['update:modelValue', 'focus', 'blur']); +const emit = defineEmits(['update:mode', 'update:code', 'focus', 'blur']); const props = defineProps({ - mode: { - type: String, - required: true, - }, theme: { type: String, default: 'material', @@ -74,18 +71,50 @@ const props = defineProps({ type: Boolean, default: true, }, - modelValue: String, + allowModeSelection: { + type: Boolean, + default: true, + }, + mode: String, + code: String, }); +const modes = ref([ + { value: 'clike', label: 'C-Like' }, + { value: 'css', label: 'CSS' }, + { value: 'diff', label: 'Diff' }, + { value: 'go', label: 'Go' }, + { value: 'haml', label: 'HAML' }, + { value: 'handlebars', label: 'Handlebars' }, + { value: 'htmlmixed', label: 'HTML' }, + { value: 'less', label: 'LESS' }, + { value: 'markdown', label: 'Markdown' }, + { value: 'gfm', label: 'Markdown (GHF)' }, + { value: 'nginx', label: 'Nginx' }, + { value: 'text/x-java', label: 'Java' }, + { value: 'javascript', label: 'JavaScript' }, + { value: 'jsx', label: 'JSX' }, + { value: 'text/x-objectivec', label: 'Objective-C' }, + { value: 'php', label: 'PHP' }, + { value: 'python', label: 'Python' }, + { value: 'ruby', label: 'Ruby' }, + { value: 'scss', label: 'SCSS' }, + { value: 'shell', label: 'Shell' }, + { value: 'sql', label: 'SQL' }, + { value: 'twig', label: 'Twig' }, + { value: 'vue', label: 'Vue' }, + { value: 'xml', label: 'XML' }, + { value: 'yaml-frontmatter', label: 'YAML' }, +]); + const codemirror = ref(null); -const codemirrorElement = useTemplateRef('codemirror'); +const codemirrorElement = useTemplateRef('codemirrorElement'); defineOptions({ inheritAttrs: false, }); defineExpose({ - codemirror, refresh, }) @@ -96,7 +125,7 @@ onMounted(() => { function initCodeMirror() { codemirror.value = markRaw( CodeMirror(codemirrorElement.value, { - value: props.modelValue || '', + value: props.code || '', mode: props.mode, direction: document.querySelector('html').getAttribute('dir') ?? 'ltr', addModeClass: true, @@ -114,7 +143,7 @@ function initCodeMirror() { ); codemirror.value.on('change', (cm) => { - emit('update:modelValue', cm.doc.getValue()); + emit('update:code', cm.doc.getValue()); }); codemirror.value.on('focus', () => emit('focus')); @@ -130,17 +159,17 @@ function refresh() { } watch( - () => props.mode, + () => props.disabled, (value) => { - codemirror.value?.setOption('mode', value); + codemirror.value?.setOption('readOnly', value ? 'nocursor' : false); }, { immediate: true } ); watch( - () => props.disabled, + () => props.mode, (value) => { - codemirror.value?.setOption('readOnly', value ? 'nocursor' : false); + codemirror.value?.setOption('mode', value); }, { immediate: true } ); @@ -156,10 +185,18 @@ watch( { immediate: true } ); +const modeLabel = computed(() => { + return modes.find((m) => m.value === props.mode)?.label || props.mode; +}); + const exactTheme = computed(() => { return props.theme === 'light' ? 'default' : 'material'; }); +const themeClass = computed(() => { + return `theme-${props.theme}`; +}); + const rulers = computed(() => { if (! props.rulers) { return []; @@ -177,10 +214,65 @@ const rulers = computed(() => { }; }); }); + +const fullScreenMode = ref(false); + +function toggleFullscreen() { + fullScreenMode.value = !fullScreenMode.value; +} + +watch( + () => fullScreenMode.value, + (fullScreenMode) => { + codemirror.value.setOption('fullScreen', fullScreenMode); + + if (!fullScreenMode) { + codemirrorElement.value.removeAttribute('style'); + } + } +) From 18602604d85eba6e26da998941726981879944e8 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Jun 2025 09:59:52 -0400 Subject: [PATCH 06/12] value --- resources/js/components/ui/CodeEditor.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/ui/CodeEditor.vue b/resources/js/components/ui/CodeEditor.vue index 3142f944cc..437571f569 100644 --- a/resources/js/components/ui/CodeEditor.vue +++ b/resources/js/components/ui/CodeEditor.vue @@ -186,7 +186,7 @@ watch( ); const modeLabel = computed(() => { - return modes.find((m) => m.value === props.mode)?.label || props.mode; + return modes.value.find((m) => m.value === props.mode)?.label || props.mode; }); const exactTheme = computed(() => { From b114effcc9697ae535fbb559957ffae2aad092e1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Jun 2025 10:00:35 -0400 Subject: [PATCH 07/12] use modelValue so v-model works --- resources/js/components/fieldtypes/CodeFieldtype.vue | 4 ++-- resources/js/components/ui/CodeEditor.vue | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/js/components/fieldtypes/CodeFieldtype.vue b/resources/js/components/fieldtypes/CodeFieldtype.vue index 59645b0fab..141bc16eb4 100644 --- a/resources/js/components/fieldtypes/CodeFieldtype.vue +++ b/resources/js/components/fieldtypes/CodeFieldtype.vue @@ -11,9 +11,9 @@ :line-wrapping="config.line_wrapping" :allow-mode-selection="config.mode_selectable" :mode="mode" - :code="value.code" + :model-value="value.code" @update:mode="modeUpdated" - @update:code="codeUpdated" + @update:model-value="codeUpdated" /> diff --git a/resources/js/components/ui/CodeEditor.vue b/resources/js/components/ui/CodeEditor.vue index 437571f569..94e4c0016a 100644 --- a/resources/js/components/ui/CodeEditor.vue +++ b/resources/js/components/ui/CodeEditor.vue @@ -36,7 +36,7 @@ import 'codemirror/mode/xml/xml'; import 'codemirror/mode/yaml/yaml'; import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter'; -const emit = defineEmits(['update:mode', 'update:code', 'focus', 'blur']); +const emit = defineEmits(['update:mode', 'update:model-value', 'focus', 'blur']); const props = defineProps({ theme: { @@ -76,7 +76,7 @@ const props = defineProps({ default: true, }, mode: String, - code: String, + modelValue: String, }); const modes = ref([ @@ -125,7 +125,7 @@ onMounted(() => { function initCodeMirror() { codemirror.value = markRaw( CodeMirror(codemirrorElement.value, { - value: props.code || '', + value: props.modelValue || '', mode: props.mode, direction: document.querySelector('html').getAttribute('dir') ?? 'ltr', addModeClass: true, @@ -143,7 +143,7 @@ function initCodeMirror() { ); codemirror.value.on('change', (cm) => { - emit('update:code', cm.doc.getValue()); + emit('update:model-value', cm.doc.getValue()); }); codemirror.value.on('focus', () => emit('focus')); From c7c79dbd6707e1d121b74b2de7be460b7e3c58c7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Jun 2025 10:00:42 -0400 Subject: [PATCH 08/12] prettier --- resources/js/components/ui/CodeEditor.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/resources/js/components/ui/CodeEditor.vue b/resources/js/components/ui/CodeEditor.vue index 94e4c0016a..70896684a6 100644 --- a/resources/js/components/ui/CodeEditor.vue +++ b/resources/js/components/ui/CodeEditor.vue @@ -116,7 +116,7 @@ defineOptions({ defineExpose({ refresh, -}) +}); onMounted(() => { nextTick(() => initCodeMirror()); @@ -163,7 +163,7 @@ watch( (value) => { codemirror.value?.setOption('readOnly', value ? 'nocursor' : false); }, - { immediate: true } + { immediate: true }, ); watch( @@ -171,7 +171,7 @@ watch( (value) => { codemirror.value?.setOption('mode', value); }, - { immediate: true } + { immediate: true }, ); watch( @@ -182,7 +182,7 @@ watch( codemirror.value?.doc.setValue(value); }, - { immediate: true } + { immediate: true }, ); const modeLabel = computed(() => { @@ -198,7 +198,7 @@ const themeClass = computed(() => { }); const rulers = computed(() => { - if (! props.rulers) { + if (!props.rulers) { return []; } @@ -229,8 +229,8 @@ watch( if (!fullScreenMode) { codemirrorElement.value.removeAttribute('style'); } - } -) + }, +);