Laravel Flux UI Editor-Tabelle
Mittlerweile mag ich es sehr, im Laravel-Ökosystem zu arbeiten.
In den letzten Jahren habe ich auch die meisten meiner Oberflächen auf Livewire migriert, was mir die Arbeit mit diesen ebenfalls noch weiter erleichtert hat.
Als Flux UI Pro angekündigt wurde, habe ich mir eine Lizenz für unlimitierte Projekte geholt und diese seitdem des Öfteren eingesetzt.
Warum benötige ich jetzt Tabellen im Editor?
Eines der zuvor erwähnten Projekte ist mein selbstgeschriebenes Projektmanagement-System. An diesem arbeite ich fleißig, während ich es gleichzeitig nutze, um es selbst und andere Projekte damit zu verwalten.
Eines dieser Projekte ist die Webseite meines Vaters. Er schreibt fleißig Geschichten aus seinem interessanten Leben auf, welche wir auf der Seite präsentieren möchten.
Er ist gerade dabei, Fotos zu sammeln und zu digitalisieren, welche wir dann auf seiner Seite und in den Büchern verwenden möchten.
Gleich als ich das erste Set von Bildern erhalten habe, habe ich eine Tabelle angelegt um deren Inhalten (inklusive der darauf abgebildeten Personen) zu beschreiben.
Das habe ich in Markdown erledigt, da ich mit dem Format viel arbeite. Allerdings ließen sich diese Informationen so nicht einfach in mein Projektmanagement übertragen, da dieses nicht mit Tabellen arbeiten kann.
Also habe ich geprüft, ob ich dazu irgendwelche Anleitungen finden kann, bin damit aber leider nicht erfolgreich gewesen.
Zumindest nicht komplett.
Es gibt natürlich eine Dokumentation, um den TipTap-Editor zu erweitern, sowie ein (unvollständiges) GitHub-Issue, aber das war's.
Die (korrekten) Erweiterungen installieren
Gleich zu Beginn rannte ich in ein Problem.
Als ich npm install @tiptap/extension-table ausgeführt habe, bekam ich sofort eine Fehlermeldung. Flux nutzt weiterhin v2 beim TipTap-Editor, aber die tiptap/extension-table ist bereits bei v3. Also müssen wir nur die Version 2 pinnen, damit es doch klappt. npm install @tiptap/extension-table@^2.0hat dann glücklicherweise funktioniert.
Das benötigte ich für alle Pakete hier:
npm install @tiptap/extension-table-row@^2.0
npm install @tiptap/extension-table-header@^2.0
npm install @tiptap/extension-table-cell@^2.0
Das JavaScript
Als Nächstes habe ich die Erweiterungen bei meinem Flux Editor registriert:
import Table from '@tiptap/extension-table';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import TableRow from '@tiptap/extension-table-row';
// …
document.addEventListener('flux:editor', (e) => {
e.detail.registerExtensions([
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
]);
e.detail.init(({ editor }) => {
const root = editor.options.element?.closest?.('[data-flux-editor]');
if (root) {
root.__editor = editor;
}
});
});
Wie man in der zweiten Hälfte des Codes sieht, habe ich die TipTap-Instanz auf dem Root-Element des Editors hinterlegt.
Das hatte ich bereits zuvor für ein anderes Feature (Dateiupload per Drag & Drop) gemacht, und hier stellte es sich auch wieder als praktisch heraus.
Anschließend habe ich den Button zu der Toolbar meines Editors hinzugefügt:
<flux:editor wire:model="content" toolbar="heading | bold italic strike | bullet ordered blockquote | link table | align ~ undo redo" />
Natürlich gab das sofort eine Fehlermeldung: Flux component [editor.table] does not exist.
Das Button-Template
Okay, dann legen wir das halt an.
In resources/views/flux/editor/table.blade.php habe ich diese View angelegt:
<flux:dropdown class="contents">
<flux:tooltip content="{{ __('Insert table') }}" class="contents">
<flux:editor.button data-test="editor-table-button">
<flux:icon.table-cells variant="outline" class="size-5!" />
</flux:editor.button>
</flux:tooltip>
<flux:popover
x-data="editorTablePicker"
x-on:toggle="$event.newState === 'closed' && reset()"
class="p-2"
>
<div class="grid w-max grid-cols-8 gap-1" x-on:mouseleave="reset()">
<template x-for="cell in maxRows * maxCols" :key="cell">
<button
type="button"
class="size-4 rounded-xs border"
:class="isSelected(cell)
? 'border-accent bg-accent/20'
: 'border-zinc-300 hover:border-zinc-400 dark:border-zinc-500 dark:hover:border-zinc-400'"
x-on:mouseenter="highlight(cell)"
x-on:focus="highlight(cell)"
x-on:click="insert()"
:aria-label="rowOf(cell) + ' × ' + colOf(cell)"
:data-test="'table-size-' + rowOf(cell) + 'x' + colOf(cell)"
></button>
</template>
</div>
<div class="pt-1.5 text-center text-xs text-zinc-500 dark:text-zinc-400">
<span x-show="rows === 0">{{ __('Insert table') }}</span>
<span x-show="rows > 0" x-text="rows + ' × ' + cols"></span>
</div>
</flux:popover>
</flux:dropdown>
Das benötigt nun noch etwas mehr JavaScript, um zu funktionieren. Also habe ich das hier noch zu meiner app.js hinzugefügt:
/**
* Grid-size picker behind the editor toolbar's insert-table button
* (resources/views/flux/editor/table.blade.php).
*
* Cells are numbered row-major; hovering or focusing one highlights the
* rows × cols rectangle up to it, clicking inserts a table of that size at
* the cursor via the Tiptap instance stashed on the editor root by the
* `flux:editor` listener above.
*/
window.Alpine.data('editorTablePicker', () => ({
maxRows: 6,
maxCols: 8,
rows: 0,
cols: 0,
rowOf(cell) {
return Math.ceil(cell / this.maxCols);
},
colOf(cell) {
return ((cell - 1) % this.maxCols) + 1;
},
highlight(cell) {
this.rows = this.rowOf(cell);
this.cols = this.colOf(cell);
},
isSelected(cell) {
return this.rowOf(cell) <= this.rows && this.colOf(cell) <= this.cols;
},
reset() {
this.rows = 0;
this.cols = 0;
},
insert() {
const editor = this.$el.closest('[data-flux-editor]')?.__editor;
console.log('insert', editor, this.$el.closest('[data-flux-editor]'));
if (!editor || !this.rows) {
return;
}
editor
.chain()
.focus()
.insertTable({rows: this.rows, cols: this.cols, withHeaderRow: true})
.run();
this.$el.closest('[popover]')?.hidePopover();
},
}));
Hier sieht man auch die zuvor erwähnte Referenz auf den `__editor`. Wie bereits gesagt; Das ist nicht unbedingt notwendig, aber recht praktisch (insbesondere wenn man es auch noch an anderen Stellen verwendet).
Das CSS
Technisch funktioniert das schon, allerdings war die Tabelle damit noch unsichtbar. Also kam noch etwas CSS hinzu:
/* ---------------------------------------------------------------------------
Tables in rich text (editor content + rendered `prose` output)
--------------------------------------------------------------------------- */
/* Inside the Flux editor: Flux ships no table styles, so a freshly inserted
table would be invisible. TipTap wraps the table in a `.tableWrapper` div
(editor-view only — it is not part of the stored HTML). */
[data-flux-editor] [data-slot='content'] .tableWrapper {
@apply my-3 overflow-x-auto;
}
[data-flux-editor] [data-slot='content'] table {
@apply w-full border-collapse text-sm;
table-layout: fixed;
}
[data-flux-editor] [data-slot='content'] th,
[data-flux-editor] [data-slot='content'] td {
@apply relative border border-zinc-200 px-2.5 py-1.5 text-start align-top dark:border-zinc-600;
}
[data-flux-editor] [data-slot='content'] th {
@apply bg-zinc-50 font-semibold dark:bg-white/5;
}
/* TipTap marks cells in a drag/shift selection with `.selectedCell`. */
[data-flux-editor] [data-slot='content'] .selectedCell::after {
@apply pointer-events-none absolute inset-0 bg-accent/10 content-[''];
}
Um das Ergebnis zu rendern, nutze ich bereits die "prose" Klasse, aber damit die Tabelle auch so aussieht wie im Editor, habe ich noch ein paar Styles hinzugefügt:
/* Rendered rich text (the `prose` wrapper in x-rich-text): override the
typography plugin's rules-only table look to match the editor's grid. The
plugin styles via :where(), so these plain selectors take precedence. */
.prose table {
@apply my-3 text-sm;
}
.prose th,
.prose td {
@apply border border-zinc-200 px-2.5 py-1.5 text-start align-top dark:border-zinc-600;
}
.prose th {
@apply bg-zinc-50 font-semibold dark:bg-white/5;
}
/* Cell content is wrapped in <p> by the editor — without this, the paragraph
margins inflate every row. */
.prose th p,
.prose td p {
@apply my-0;
}
Das war's erstmal. Das größte Problem mit dieser Implementierung ist, dass es nur ein reines Einfügen ist. Damit kann ich noch keine Zeilen oder Spalten entfernen oder hinzufügen.
Das wird vermutlich etwas für Teil 2, sobald ich herausgefunden habe, wie ich das umsetzen kann.
Und während ich diesen Text von meinen Notizen in meine Webseite übertragen habe, bemerkte ich, dass Filament bereits einen derartigen Button in seinem Rich Text Editor hat.
Gegebenenfalls schaue ich mir an, ob ich mich an deren Implementierung orientieren kann.
Kommentare