no suggestions ?
Issues with Managing Multiple Instances of a CodeMirror-Based File Editor in Alpine.js & Livewire
I'm working on a Livewire component that includes a repeater functionality for managing multiple file inputs, each with a CodeMirror editor embedded in an Alpine.js component. Here's a summary of my setup:
Create.php (livewire component class):
<?php
namespace App\Livewire\Components;
use App\Enums\Status;
use Livewire\Component;
use App\Models\Category;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
use App\Enums\FileExtension;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
class Create extends Component
{
public EloquentCollection $categories;
public ?string $name;
public ?string $status;
public ?int $categoryId;
public int $filesNbr = 1;
public Collection $files;
public function mount()
{
$this->categories = Category::select('id', 'name')->get();
$this->files = collect(range(1, $this->filesNbr))
->mapWithKeys(function () {
$uuid = $this->generateUuid();
return [$uuid => $this->fileStructure()];
});
}
public function create(){
// split the dbs and store files
}
private function fileStructure(): array
{
return [
'serverName' => '',
'clientName' => 'resources/views/livewire',
'extension' => FileExtension::PHP->value,
'content' => ''
];
}
public function addFile()
{
$uuid = $this->generateUuid();
$this->files[$uuid] = $this->fileStructure();
$this->filesNbr++;
// dd($this->files);
}
public function delete($uuid)
{
$this->files->forget($uuid);
}
public function duplicate($uuid)
{
$newUuid = $this->generateUuid();
$this->files[$newUuid] = $this->files[$uuid];
$this->filesNbr++;
}
private function generateUuid()
{
return (string) Str::uuid();
}
// syncing the state of files contents
public function updateContent($uuid,$content){
if ($this->files->has($uuid)) {
$this->files->put($uuid, array_merge($this->files->get($uuid), ['content' => $content]));
}
}
public function render()
{
return view('livewire.components.create',[
'extensions'=>FileExtension::cases(),
'statuses'=>Status::cases()
]);
}
}
Blade View Of The Class :
The Blade view loops through the files collection, rendering each file with a unique uuid. The file-editor is a custom Blade component that initializes the CodeMirror editor.
<x-slot:title>
Create Component
</x-slot>
<div class="mx-auto mt-16 max-w-2xl">
<x-fieldset >
<x-slot name="label">
<p class="text-md font-semibold text-blue-500">Component's details</p>
</x-slot>
<form wire:submit="create">
<div class="space-y-3">
<x-form.element label="name">
<x-input.wrapper>
<x-input wire:model="name" />
</x-input.wrapper>
<x-error class="mt-2" :messages="$errors->get('name')" />
</x-form.element>
<x-form.element label="status">
<x-input.wrapper>
<x-input.select class="h-9 w-full px-4" wire:model.live="categoryId">
@foreach ($statuses as $status)
<option class="text-white" value="{{ $status->value }}">{{ $status->value }}
</option>
@endforeach
</x-input.select>
</x-input.wrapper>
<x-error class="mt-2" :messages="$errors->get('status')" />
</x-form.element>
<x-form.element label="category">
<x-input.wrapper>
<x-input.select class="h-9 w-full px-4" wire:model.live="categoryId">
@foreach ($categories as $category)
<option class="text-white" value="{{ $category->id }}">{{ $category->name }}
</option>
@endforeach
</x-input.select>
</x-input.wrapper>
<x-error class="mt-2" :messages="$errors->get('category')" />
</x-form.element>
</div>
</form>
</x-fieldset>
<x-fieldset>
<x-slot name="label">
<p class="text-md font-semibold text-blue-500">Component's files</p>
</x-slot>
<div class="files-container ">
@foreach ($files as $uuid => $file )
<div class="bg-white/5 rounded-xl">
<div wire:key="file-{{ $uuid }}" class="flex space-x-4 rounded-xl p-4 mb-4 relative">
<button x-on:click="$wire.delete('{{ $uuid }}')" class="absolute right-3 text-red-500/50 hover:text-red-500/60 transition duration-300 top-3 ">
<svg xmlns="http://www.w3.org/2000/svg" wire:loading.class="hidden" wire:target="delete('{{ $uuid }}')" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
<div class="hidden pl-4" wire:loading wire:target="delete('{{ $uuid }}')">
<x-filament::loading-indicator class="h-5 w-5" />
</div>
</button>
<button x-on:click="$wire.duplicate('{{ $uuid }}')" class="absolute right-9 text-gray-500/50 hover:text-gray-500/60 transition duration-300 top-3 ">
<svg xmlns="http://www.w3.org/2000/svg" wire:loading.class="hidden" wire:target="duplicate('{{ $uuid }}')" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
<div class="hidden pl-4" wire:loading wire:target="duplicate('{{ $uuid }}')">
<x-filament::loading-indicator class="h-5 w-5" />
</div>
</button>
<x-form.element required label="server name">
<x-input.wrapper>
<x-input wire:model.live="files.{{ $uuid }}.serverName" />
</x-input.wrapper>
<x-error class="mt-2" :messages="$errors->get('files.{{ $uuid }}.serverName')" />
</x-form.element>
<x-form.element required label="client name">
<x-input.wrapper>
<x-input wire:model.live="files.{{ $uuid }}.clientName" />
</x-input.wrapper>
<x-error class="mt-2" :messages="$errors->get('files.{{ $uuid }}.clientName')" />
</x-form.element>
<x-form.element label="extension">
<x-input.wrapper>
<x-input.select class="h-9 w-full px-4" wire:model.live="files.{{ $uuid }}.extension" >
@foreach ($extensions as $extension)
<option class="" value="{{ $extension->value }}">{{ $extension->value }}
</option>
@endforeach
</x-input.select>
</x-input.wrapper>
<x-error class="mt-2" :messages="$errors->get('category')" />
</x-form.element>
</div>
<x-form.element class="flex-1 mb-4" label="file's content ">
<x-file-editor
:uuid="$uuid"
:content="$file['content']"
:language="$file['extension']"
/>
</x-form.element>
</div>
@endforeach
</div>
<div class="flex justify-center items-center pt-8">
<button x-on:click="$wire.addFile()" class="bg-white/15 items-center text-start text-white px-4 py-1 flex files-center rounded-lg">
<div wire:loading.class="hidden" wire:target="addFile">
@svg('heroicon-m-plus',['width'=>20,'height'=>20])
</div>
<div class="hidden pl-4" wire:loading wire:target="addFile">
<x-filament::loading-indicator class="h-5 w-5" />
</div>
<span>add another file</span>
</button>
</div>
</x-fieldset>
<button
class="mt-4 flex rounded-xl bg-white/15 px-4 py-2 text-white"
type="submit"
wire:loading.class="opacity-50 duration-300 transition"
wire:target="create"
>
<div class="between flex w-full items-center justify-between">
Submit
<div class="hidden" wire:loading wire:target="create">
<x-filament::loading-indicator class="h-5 w-5" />
</div>
</div>
</button>
</div>
File editor component
The file-editor Blade component initializes the Alpine.js editor component, passing the necessary props such as uuid, content, and language.
@use('App\Enums\FileExtension')
@props([
'language' => 'html',
'uuid' => null,
'content' => null,
])
@php
// Convert file extension to language
$mode = match ($language) {
FileExtension::PHP->value => 'php',
FileExtension::CSS->value => 'css',
FileExtension::JS->value => 'javascript',
FileExtension::HTML->value => 'html',
FileExtension::BLADE->value => 'html',
default => 'html',
};
@endphp
<div
class="border-white/15 w-full rounded-xl border"
x-bind:id="$id('editor')"
x-data="editor({
language: @js($mode),
uuid: @js($uuid),
state: @js($content)
})"
x-ref="editorContainer-{{ $uuid }}">
</div>
the alpine editor component
The Alpine.js editor component is responsible for initializing the CodeMirror editor and updating the Livewire component with the editor's content.
import { EditorState } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { php } from "@codemirror/lang-php";
import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { basicSetup } from "codemirror";
import { materialDark } from "@ddietr/codemirror-themes/material-dark";
export default ({ language, uuid = "", state = "" }) => ({
editor: null,
mode: null,
init() {
this.mode = this.getLanguageExtension(language);
let container = this.$refs[`editorContainer-${uuid}`];
if (container) {
const startState = EditorState.create({
doc: state || "",
extensions: [
materialDark,
this.mode,
basicSetup,
keymap.of(defaultKeymap),
EditorView.updateListener.of((update) => this.updated(update)),
],
});
this.editor = new EditorView({
state: startState,
parent: container,
});
} else {
console.error("CodeMirror initialization failed: container not found.");
}
},
updated(update) {
if (update.docChanged) {
this.$wire.updateContent(uuid, update.state.doc.toString());
}
},
getLanguageExtension(language) {
switch (language) {
case "php":
return php();
case "css":
return css();
case "javascript":
case "js":
return javascript();
case "html":
case "blade":
return html();
default:
console.warn(`Unknown language: ${language}, defaulting to HTML.`);
return html();
}
},
});
The Problem
When I have one instance of the component on the page, everything works perfectly. However, when I add multiple instances (since this is a repeater component), only the latest instance of the file editor works, while the others do not. I've ensured that each x-ref is unique by appending a UUID to it, but the issue persists.
Please or to participate in this conversation.