feat: add codemirror

This commit is contained in:
Benjamin Nussbaum 2025-10-07 21:31:12 +02:00
parent 4c65c015b9
commit b18d561361
8 changed files with 958 additions and 26 deletions

View file

@ -0,0 +1,3 @@
import { codeEditorFormComponent } from './codemirror-alpine.js';
window.codeEditorFormComponent = codeEditorFormComponent;

View file

@ -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();
}
}
};
}

View file

@ -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 () => {};
}