From b18d561361951ef551571b995995f43826882ea7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 7 Oct 2025 21:31:12 +0200 Subject: [PATCH 1/4] feat: add codemirror --- package-lock.json | 296 ++++++++++++++++++ package.json | 13 + resources/js/app.js | 3 + resources/js/codemirror-alpine.js | 198 ++++++++++++ resources/js/codemirror-core.js | 255 +++++++++++++++ resources/views/livewire/codemirror.blade.php | 64 ++++ .../views/livewire/plugins/recipe.blade.php | 139 ++++++-- vite.config.js | 16 +- 8 files changed, 958 insertions(+), 26 deletions(-) create mode 100644 resources/js/codemirror-alpine.js create mode 100644 resources/js/codemirror-core.js create mode 100644 resources/views/livewire/codemirror.blade.php diff --git a/package-lock.json b/package-lock.json index 9732fe8..8f8edb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,22 @@ "packages": { "": { "dependencies": { + "@codemirror/commands": "^6.9.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-liquid": "^6.3.0", + "@codemirror/language": "^6.11.3", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.5", + "@fsegurai/codemirror-theme-github-light": "^6.2.2", "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", "axios": "^1.8.2", + "codemirror": "^6.0.2", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0", "puppeteer": "24.17.0", @@ -43,6 +56,170 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.0.tgz", + "integrity": "sha512-61Hfv3cF07XvUxNeC3E7jhG8XNi1Yom1G0lRC936oLnlF+jrbrv8rc/J98XlYzcsAoTVupfsf5fLej1aI8kyIg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.9.0.tgz", + "integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.0.tgz", + "integrity": "sha512-fY1YsUExcieXRTsCiwX/bQ9+PbCTA/Fumv7C7mTUZHoFkibfESnaXwpr2aKH6zZVwysEunsHHkaIpM/pl3xETQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/language": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.0.tgz", + "integrity": "sha512-wZxW+9XDytH3SKvS8cQzMyQCaaazH8XL1EMHleHe00wVzsv7NBQKVW2yzEHrRhmM7ZOhVdItPbvlRBvMp9ej7A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.5", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.5.tgz", + "integrity": "sha512-SFVsNAgsAoou+BjRewMqN+m9jaztB9wCWN9RSRgePqUbq8UVlvJfku5zB2KVhLPgH/h0RLk38tvd4tGeAhygnw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -459,6 +636,18 @@ "node": ">=18" } }, + "node_modules/@fsegurai/codemirror-theme-github-light": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@fsegurai/codemirror-theme-github-light/-/codemirror-theme-github-light-6.2.2.tgz", + "integrity": "sha512-YQr5MbhMlhRlAQcSCSbet4NDDkMvd5sbUyk9JmM0vfZhQbatvw4c56gNG/54JKGM0kWY5zRWzgLtFuz6D7yEsw==", + "license": "MIT", + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -516,6 +705,80 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz", + "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@puppeteer/browsers": { "version": "2.10.7", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz", @@ -1485,6 +1748,21 @@ "node": ">=12" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1565,6 +1843,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -3021,6 +3305,12 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3275,6 +3565,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 37b6dcf..4190067 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,22 @@ "dev": "vite" }, "dependencies": { + "@codemirror/commands": "^6.9.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-liquid": "^6.3.0", + "@codemirror/language": "^6.11.3", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.5", + "@fsegurai/codemirror-theme-github-light": "^6.2.2", "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", "axios": "^1.8.2", + "codemirror": "^6.0.2", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0", "puppeteer": "24.17.0", diff --git a/resources/js/app.js b/resources/js/app.js index e69de29..db3ebf3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1,3 @@ +import { codeEditorFormComponent } from './codemirror-alpine.js'; + +window.codeEditorFormComponent = codeEditorFormComponent; diff --git a/resources/js/codemirror-alpine.js b/resources/js/codemirror-alpine.js new file mode 100644 index 0000000..9ce12f1 --- /dev/null +++ b/resources/js/codemirror-alpine.js @@ -0,0 +1,198 @@ +import { createCodeMirror, getSystemTheme, watchThemeChange } from './codemirror-core.js'; +import { EditorView } from '@codemirror/view'; + +/** + * Alpine.js component for CodeMirror that integrates with textarea and Livewire + * Inspired by Filament's approach with proper state entanglement + * @param {Object} config - Configuration object + * @returns {Object} Alpine.js component object + */ +export function codeEditorFormComponent(config) { + return { + editor: null, + textarea: null, + isLoading: false, + unwatchTheme: null, + + // Configuration + isDisabled: config.isDisabled || false, + language: config.language || 'html', + state: config.state || '', + textareaId: config.textareaId || null, + + /** + * Initialize the component + */ + async init() { + this.isLoading = true; + + try { + // Wait for textarea if provided + if (this.textareaId) { + await this.waitForTextarea(); + } + + await this.$nextTick(); + this.createEditor(); + this.setupEventListeners(); + } finally { + this.isLoading = false; + } + }, + + /** + * Wait for textarea to be available in the DOM + */ + async waitForTextarea() { + let attempts = 0; + const maxAttempts = 50; // 5 seconds max wait + + while (attempts < maxAttempts) { + this.textarea = document.getElementById(this.textareaId); + if (this.textarea) { + return; + } + + // Wait 100ms before trying again + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + + console.error(`Textarea with ID "${this.textareaId}" not found after ${maxAttempts} attempts`); + }, + + /** + * Update both Livewire state and textarea with new value + */ + updateState(value) { + this.state = value; + if (this.textarea) { + this.textarea.value = value; + this.textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + }, + + /** + * Create the CodeMirror editor instance + */ + createEditor() { + // Clean up any existing editor first + if (this.editor) { + this.editor.destroy(); + } + + const effectiveTheme = this.getEffectiveTheme(); + const initialValue = this.textarea ? this.textarea.value : this.state; + + this.editor = createCodeMirror(this.$refs.editor, { + value: initialValue || '', + language: this.language, + theme: effectiveTheme, + readOnly: this.isDisabled, + onChange: (value) => this.updateState(value), + onUpdate: (value) => this.updateState(value), + onBlur: () => { + if (this.editor) { + this.updateState(this.editor.state.doc.toString()); + } + } + }); + }, + + /** + * Get effective theme + */ + getEffectiveTheme() { + return getSystemTheme(); + }, + + /** + * Update editor content with new value + */ + updateEditorContent(value) { + if (this.editor && value !== this.editor.state.doc.toString()) { + this.editor.dispatch({ + changes: { + from: 0, + to: this.editor.state.doc.length, + insert: value + } + }); + } + }, + + /** + * Setup event listeners for theme changes and state synchronization + */ + setupEventListeners() { + // Watch for state changes from Livewire + this.$watch('state', (newValue) => { + this.updateEditorContent(newValue); + }); + + // Watch for disabled state changes + this.$watch('isDisabled', (newValue) => { + if (this.editor) { + this.editor.dispatch({ + effects: EditorView.editable.reconfigure(!newValue) + }); + } + }); + + // Watch for textarea changes (from Livewire updates) + if (this.textarea) { + this.textarea.addEventListener('input', (event) => { + this.updateEditorContent(event.target.value); + this.state = event.target.value; + }); + + // Listen for Livewire updates that might change the textarea value + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'value') { + this.updateEditorContent(this.textarea.value); + this.state = this.textarea.value; + } + }); + }); + + observer.observe(this.textarea, { + attributes: true, + attributeFilter: ['value'] + }); + } + + // Listen for theme changes + this.unwatchTheme = watchThemeChange(() => { + this.recreateEditor(); + }); + }, + + /** + * Recreate the editor (useful for theme changes) + */ + async recreateEditor() { + if (this.editor) { + this.editor.destroy(); + this.editor = null; + await this.$nextTick(); + this.createEditor(); + } + }, + + + /** + * Clean up resources when component is destroyed + */ + destroy() { + if (this.editor) { + this.editor.destroy(); + this.editor = null; + } + if (this.unwatchTheme) { + this.unwatchTheme(); + } + } + }; +} + diff --git a/resources/js/codemirror-core.js b/resources/js/codemirror-core.js new file mode 100644 index 0000000..c77bf3d --- /dev/null +++ b/resources/js/codemirror-core.js @@ -0,0 +1,255 @@ +import { EditorView, lineNumbers, keymap } from '@codemirror/view'; +import { ViewPlugin } from '@codemirror/view'; +import { indentWithTab } from '@codemirror/commands'; +import { foldGutter, foldKeymap } from '@codemirror/language'; +import { history, historyKeymap } from '@codemirror/commands'; +import { html } from '@codemirror/lang-html'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { css } from '@codemirror/lang-css'; +import { liquid } from '@codemirror/lang-liquid'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { githubLight } from '@fsegurai/codemirror-theme-github-light'; + +// Language support mapping +const LANGUAGE_MAP = { + 'javascript': javascript, + 'js': javascript, + 'json': json, + 'css': css, + 'liquid': liquid, + 'html': html, +}; + +// Theme support mapping +const THEME_MAP = { + 'light': githubLight, + 'dark': oneDark, +}; + +/** + * Get language support based on language parameter + * @param {string} language - Language name or comma-separated list + * @returns {Array|Extension} Language extension(s) + */ +function getLanguageSupport(language) { + // Handle comma-separated languages + if (language.includes(',')) { + const languages = language.split(',').map(lang => lang.trim().toLowerCase()); + const languageExtensions = []; + + languages.forEach(lang => { + const languageFn = LANGUAGE_MAP[lang]; + if (languageFn) { + languageExtensions.push(languageFn()); + } + }); + + return languageExtensions; + } + + // Handle single language + const languageFn = LANGUAGE_MAP[language.toLowerCase()] || LANGUAGE_MAP.html; + return languageFn(); +} + +/** + * Get theme support + * @param {string} theme - Theme name + * @returns {Array} Theme extensions + */ +function getThemeSupport(theme) { + const themeFn = THEME_MAP[theme] || THEME_MAP.light; + return [themeFn]; +} + +/** + * Create a resize plugin that handles container resizing + * @returns {ViewPlugin} Resize plugin + */ +function createResizePlugin() { + return ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.resizeObserver = null; + this.setupResizeObserver(); + } + + setupResizeObserver() { + const container = this.view.dom.parentElement; + if (container) { + this.resizeObserver = new ResizeObserver(() => { + // Use requestAnimationFrame to ensure proper timing + requestAnimationFrame(() => { + this.view.requestMeasure(); + }); + }); + this.resizeObserver.observe(container); + } + } + + destroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + } + }); +} + +/** + * Get Flux-like theme styling based on theme + * @param {string} theme - Theme name ('light', 'dark', or 'auto') + * @returns {Object} Theme-specific styling + */ +function getFluxThemeStyling(theme) { + const isDark = theme === 'dark' || (theme === 'auto' && getSystemTheme() === 'dark'); + + if (isDark) { + return { + backgroundColor: 'oklab(0.999994 0.0000455678 0.0000200868 / 0.1)', + gutterBackgroundColor: 'oklch(26.9% 0 0)', + borderColor: '#374151', + focusBorderColor: 'rgb(224 91 68)', + }; + } else { + return { + backgroundColor: '#fff', // zinc-50 + gutterBackgroundColor: '#fafafa', // zinc-50 + borderColor: '#e5e7eb', // gray-200 + focusBorderColor: 'rgb(224 91 68)', // red-500 + }; + } +} + +/** + * Create CodeMirror editor instance + * @param {HTMLElement} element - DOM element to mount editor + * @param {Object} options - Editor options + * @returns {EditorView} CodeMirror editor instance + */ +export function createCodeMirror(element, options = {}) { + const { + value = '', + language = 'html', + theme = 'light', + readOnly = false, + onChange = () => {}, + onUpdate = () => {}, + onBlur = () => {} + } = options; + + // Get language and theme support + const languageSupport = getLanguageSupport(language); + const themeSupport = getThemeSupport(theme); + const fluxStyling = getFluxThemeStyling(theme); + + // Create editor + const editor = new EditorView({ + doc: value, + extensions: [ + lineNumbers(), + foldGutter(), + history(), + EditorView.lineWrapping, + createResizePlugin(), + ...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]), + ...themeSupport, + keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]), + EditorView.theme({ + '&': { + fontSize: '14px', + border: `1px solid ${fluxStyling.borderColor}`, + borderRadius: '0.375rem', + height: '100%', + maxHeight: '100%', + overflow: 'hidden', + backgroundColor: fluxStyling.backgroundColor + ' !important', + resize: 'vertical', + minHeight: '200px', + }, + '.cm-gutters': { + borderTopLeftRadius: '0.375rem', + backgroundColor: fluxStyling.gutterBackgroundColor + ' !important', + }, + '.cm-gutter': { + backgroundColor: fluxStyling.gutterBackgroundColor + ' !important', + }, + '&.cm-focused': { + outline: 'none', + borderColor: fluxStyling.focusBorderColor, + }, + '.cm-content': { + padding: '12px', + }, + '.cm-scroller': { + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + height: '100%', + overflow: 'auto', + }, + '.cm-editor': { + height: '100%', + }, + '.cm-editor .cm-scroller': { + height: '100%', + overflow: 'auto', + }, + '.cm-foldGutter': { + width: '12px', + }, + '.cm-foldGutter .cm-gutterElement': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + fontSize: '12px', + color: '#6b7280', + }, + '.cm-foldGutter .cm-gutterElement:hover': { + color: '#374151', + }, + '.cm-foldGutter .cm-gutterElement.cm-folded': { + color: '#3b82f6', + } + }), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const newValue = update.state.doc.toString(); + onChange(newValue); + onUpdate(newValue); + } + }), + EditorView.domEventHandlers({ + blur: onBlur + }), + EditorView.editable.of(!readOnly), + ], + parent: element + }); + + return editor; +} + +/** + * Auto-detect system theme preference + * @returns {string} 'dark' or 'light' + */ +export function getSystemTheme() { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; +} + +/** + * Watch for system theme changes + * @param {Function} callback - Callback function when theme changes + * @returns {Function} Unwatch function + */ +export function watchThemeChange(callback) { + if (window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', callback); + return () => mediaQuery.removeEventListener('change', callback); + } + return () => {}; +} diff --git a/resources/views/livewire/codemirror.blade.php b/resources/views/livewire/codemirror.blade.php new file mode 100644 index 0000000..fad3e53 --- /dev/null +++ b/resources/views/livewire/codemirror.blade.php @@ -0,0 +1,64 @@ +language = $language; + $this->theme = $theme; + $this->readonly = $readonly; + $this->placeholder = $placeholder; + $this->height = $height; + $this->id = $id; + } + + + public function toJSON() + { + return json_encode([ + 'model' => $this->model, + 'language' => $this->language, + 'theme' => $this->theme, + 'readonly' => $this->readonly, + 'placeholder' => $this->placeholder, + 'height' => $this->height, + 'id' => $this->id, + ]); + } +} ?> + + +
+ +
+
+ + + + + Loading editor... +
+
+ + +
+
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 86efec6..a579427 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -1022,14 +1022,48 @@ HTML;
- Data Payload - @isset($this->data_payload_updated_at) - {{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }} - @endisset +
+ Data Payload + @isset($this->data_payload_updated_at) + {{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }} + @endisset +
- + + @php + $textareaId = 'payload-' . uniqid(); + @endphp + + +
@@ -1041,15 +1075,44 @@ HTML; {{ $plugin->render_markup_view }} to update.
- + + @php + $textareaId = 'code-view-' . uniqid(); + @endphp + {{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }} + + + + +
@else
@@ -1071,15 +1134,41 @@ HTML; @if(!$plugin->render_markup_view)
- + + @php + $textareaId = 'code-' . uniqid(); + @endphp + {{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }} + +
diff --git a/vite.config.js b/vite.config.js index 937aae1..adf57b3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -15,4 +15,18 @@ export default defineConfig({ server: { cors: true, }, -}); \ No newline at end of file + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + // Create a separate chunk for CodeMirror + if (id.includes('codemirror') || + id.includes('@codemirror/') || + id.includes('@fsegurai/codemirror-theme-github-light')) { + return 'codemirror'; + } + } + } + } + }, +}); From 583d8b244011798416bf6fafd079a2acf20983a3 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 10 Oct 2025 16:35:10 +0200 Subject: [PATCH 2/4] feat: add support for configuration field `multi_string ` --- .../views/livewire/plugins/recipe.blade.php | 9 +++ .../PluginRequiredConfigurationTest.php | 76 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index a579427..5bbf27e 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -839,6 +839,15 @@ HTML; @endif
+ @elseif($field['field_type'] === 'multi_string') + @else Field type "{{ $field['field_type'] }}" not yet supported @endif diff --git a/tests/Feature/PluginRequiredConfigurationTest.php b/tests/Feature/PluginRequiredConfigurationTest.php index 83be449..51e1b76 100644 --- a/tests/Feature/PluginRequiredConfigurationTest.php +++ b/tests/Feature/PluginRequiredConfigurationTest.php @@ -268,3 +268,79 @@ test('hasMissingRequiredConfigurationFields returns false when required xhrSelec expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); }); + +test('hasMissingRequiredConfigurationFields returns true when required multi_string field is missing', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Tags', + 'description' => 'Enter tags separated by commas', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [], // Empty configuration + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); + +test('hasMissingRequiredConfigurationFields returns false when required multi_string field is set', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Tags', + 'description' => 'Enter tags separated by commas', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'tags' => 'tag1, tag2, tag3', // Required field is set with comma-separated values + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); +}); + +test('hasMissingRequiredConfigurationFields returns true when required multi_string field is empty string', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Tags', + 'description' => 'Enter tags separated by commas', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'tags' => '', // Empty string + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); From 627d9ad09b1c437fcd26a013e0fd876e1c1f2ce4 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 10 Oct 2025 16:44:01 +0200 Subject: [PATCH 3/4] chore: update dependencies --- composer.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.lock b/composer.lock index 079793c..4fe3e09 100644 --- a/composer.lock +++ b/composer.lock @@ -2842,7 +2842,7 @@ }, { "name": "livewire/flux", - "version": "v2.5.0", + "version": "v2.5.1", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", @@ -2902,7 +2902,7 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.5.0" + "source": "https://github.com/livewire/flux/tree/v2.5.1" }, "time": "2025-09-29T21:36:00+00:00" }, From a7e76f3c07a58360b38c7d0943529c8e75c2d113 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 10 Oct 2025 18:04:12 +0200 Subject: [PATCH 4/4] fix: remove label --- resources/views/livewire/plugins/recipe.blade.php | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 5bbf27e..832124f 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -1088,7 +1088,6 @@ HTML; @php $textareaId = 'code-view-' . uniqid(); @endphp - {{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}