Bei meiner aktuellen Anwendung müssen unter anderem Bilder im Browser angezeigt werden können, und zwar nicht in Form eines kompletten Albums bzw. einer Gallery, sondern als einzelne Bilder. Da diese Bilder durchaus auch größer sein können oder die falsche Ausrichtung besitzen, sollte das Bildbetrachtungs-Modul grundlegende Funktionen wie Zoom, Anzeige in Original-Auflösung sowie insbesondere Rotation zur Verfügung stellen.
Natürlich habe ich mich aufgrund dieser Anforderungen erstmal in der Welt der fertigen und als Open Source verfügbaren Vue.js-Komponenten umgeschaut, aber leider nicht das Richtige gefunden. Entweder wollten die vorhandenen Komponenten eine Lightbox-Ansicht und somit ganze Galleries implementieren, oder sie waren hoffnungslos veraltet bzw. seit längerer Zeit nicht mehr gepflegt, oder sie brachten ihre ganz eigene UI mit. Oder direkt alles zusammen. Daher erfolgte der Biss in den sauren Apfel, soll heißen, die Erstellung eines eigenen, kleinen ImageViewer-Moduls in Vue.js. Immerhin wurde mir dieses Obst durch Hinzuziehen von ChatGPT ein wenig versüßt, denn ich würde mich trotz inzwischen einigem in TypeScript entstandenen Code und der damit verbundenen Vue.js-Programmierung noch lange nicht als Frontend-Entwickler bezeichnen. Oder anders ausgedrückt: Für grafische „Spielereien“ wie Animationen, CSS-Tricks oder ähnliches habe ich bislang die Nutzung von Libraries, etwa Animate.css, gegenüber eigenen Experimenten bevorzugt.
Minimale Anforderungen an einen ImageViewer
Dennoch war das Grundgerüst schnell geschrieben, und nach kurzer Zeit gesellten sich auch die weiteren, benötigten und erwünschten Funktionen hinzu. Die minimalen Anforderungen kurz dargestellt:
- Anzeige von Bildern (logisch), einfacher Aufruf durch die Eltern-Komponente
- externe Steuerung (
defineExpose
) der Funktionen per Eltern-Komponente, keine Anzeige von Steuerungselementen innerhalb des ImageViewer-Bereiches - Toggle auf Original-Auflösung und zurück
- Zoom-Funktion, Anzeige in n %
- Rotation in 90°-Schritten im Uhrzeigersinn / gegen den Uhrzeigersinn
- Verschieben des angezeigten Bildausschnitts per Drag & Drop
- Zoom-Funktion per Drehen des Mausrads
Erste Irrtümer und Versuche
Den ersten, umfassenden Entwurf des ChatGPT-Praktikanten musste ich erstmal komplett verwerfen, über die nächsten Ansätze hülle ich lieber den Mantel des Schweigens (oder auch nicht – das Laden war ein einziges Gefrickel mit HTMLImageElement und ähnlichen Späßen, was jedoch die Übergabe der Credentials (Cookies/Token) verhinderte, woraufhin die nächste Idee eine eigene Loader-Funktion war, was bei mir schon ein leichtes WTF?!?-Gesicht verursachte). Nachdem ChatPrakti wieder auf den Pfad der Tugend geholt werden konnte, war die nächste, minimale Version dann wenigstens einigermaßen brauchbar und stellte die Grundlage für die weitere Entwicklung dar.
Dabei hat mir insbesondere der Code für die CSS-Transitions geholfen, denn genau damit hatte ich mich zuvor noch nie beschäftigt. Letztlich sind diese zwei Angaben („transform“ und „transition„) auch einfacher als gedacht, sofern man weiß, was sie bewirken. Denn natürlich sollten die Bewegungen, d.h. Zoom und Rotation, einigermaßen ansprechend aussehen, also weiche Animationen anstatt eines Marschs im Stechschritt bieten.
Animierte Rotation, aber zackig?!?
Genau dafür sorgte die Zeile „transition: 'transform 0.2s ease'
“ in der Style-Definition des <img>-Tags – eigentlich. Denn bei der Rotation ergaben sich unerwartete Probleme. Vom Code her betrachtet sind die folgenden Zeilen entscheidend:
transform: `scale(${scale.value}) rotate(${rotation.value}deg)`, transition: 'transform 0.2s ease',
Die Angabe „scale
“ ignoriere ich im Folgenden, diese bereitete keinerlei Schwierigkeiten. Die Angabe „rotate
“ bestimmt, in welcher Ausrichtung das jeweilige Element, d.h. in diesem Fall das Bild, angezeigt wird. Positive Werte sorgen für eine Drehung im Uhrzeigersinn, negative für das Gegenteil. Wenn das Bild geladen wird, ist die „rotation
„-Variable auf 0 gesetzt. Wird nun der entsprechende Button gedrückt, sorgt eine Funktion dafür, dass die Angabe von „rotate
“ geändert wird. Beispielsweise von 0 auf 90 (Grad), was in Kombination mit der transition-Definition zu einer Drehung um 90° nach rechts sorgt, die in 200ms mit „weicher“ Animation (ease
, d.h. langsamer Start, dann beschleunigt, langsames Ende) erfolgt. Jede weitere Aktion des Buttons dreht das Bild um weitere 90°, bis der Ausgangspunkt wieder erreicht ist.
Ein Kreis ist ein Kreis ist ein Kreis
Nun lehrt einen die Schul-Geometrie, dass ein Vollwinkel 360 Grad hat, dass ein Kreis in 360 Grad unterteilt ist, und dass somit der Ausgangspunkt einer Drehung um den Anfangspunkt nach 360 Grad erreicht ist. Genau dies müsste in letzter Konsequenz also dem Winkel von 0 Grad entsprechen. In der ImageViewer-Komponente würde dies also bedeuten, dass der User genau vier Mal auf den Drehungs-Button klickt, so dass sich das Bild in 90-Grad-Schritten dreht, bis es wieder an derselben Position wie zum Anfang ist.
Genau das funktionierte im ersten Ansatz auch, nur dass die „rotation
„-Variable dann eben den Wert von 360 (Grad) besitzt. Und das erschien mir als Entwickler zumindest suboptimal, denn wenn schon die Werte 0° und 360° für das Bild genau dieselbe Position bedeuten, liegt es doch nahe, eine Modulo-Berechnung durchzuführen und die „rotation
„-Variable auf die vier Werte 0, 90, 180 und 270 festzulegen bzw. die Drehung nur zwischen 0 und 359 Grad zu erlauben, da ein Kreis eben in 360 Grad eingeteilt ist. Wobei für die Drehung gegen den Uhrzeigersinn genau dasselbe gilt, nur mit negativem Vorzeichen.
Die CSS-Quadratur des Kreises
Doch genau dieser Gedanke führte zu einem entscheidenden Problem: Denn sobald die Neuberechnung der „rotation
„-Variable dafür sorgte, dass diese vom Wert 270 auf den Wert 0 – also eigentlich 360 – geändert wurde, „sprang“ das Bild förmlich auf 0 zurück, d.h. es drehte sich nun genau anders herum, in 200ms gegen den Uhrzeigersinn von 270 auf 0 Grad. Dass dies nicht so beabsichtigt war, lag auf der Hand. Analog gilt dasselbe für die Drehung gegen den Uhrzeigersinn, d.h. bei Werten zwischen 0 und -270 Grad.
Hier kollidierten also unterschiedliche Ansichten: Rein mathematisch ist bei einer Drehung um 360 Grad der Ausgangspunkt erreicht. Aus CSS-Sicht erschien auch das beschriebene Verhalten logisch – hier wird z.B. bei einer Rotation nach rechts immer weiter hoch gezählt, während der Wert „0“ eine Art „zurück auf 0„, ergo Drehung in umgekehrter Richtung, bedeutet. Und als Entwickler empfand ich es als unsauber, die „rotation
„-Variable immer weiter zu inkrementieren, bis irgendwann der Wertebereich übergelaufen sein wird.
Natürlich habe ich nach der erfolglosen Frage an ChatGPT auch ein wenig nach möglichen Lösungen in den Weiten des übrigen Netzes gesucht. So finden sich etwa bei Stack Overflow etliche Beiträge, die sich mit dem Problem beschäftigen. Das Ergebnis war jedoch auch nicht wirklich befriedigend – letztlich konnten die Probleme bzw. das beschriebene Verhalten nur bestätigt werden, außerdem wurde empfohlen, tatsächlich die rotate
-Angabe immer weiter laufen zu lassen, es würde Jahre oder gar Jahrzehnte dauern, bis ein Überlauf erreicht wäre. Mathematisch Quatsch, aber technisch funktional, und ohne Rückwärts-Salto realisierbar. Für mein Empfinden aber immer noch unsauber, und falls ein Benutzer tatsächlich ein paar Jahre lang klickt, um auf ein Karussell zu blicken, ist eben doch irgendwann ein unschönes Ende erreicht.
Umwege und Workarounds
Eine erste Idee einer Lösung oder vielmehr eines Workarounds bestand darin, dem Bild die animierte Drehung auf Position 360 (Grad) zu erlauben, und anschließend dafür zu sorgen, dass die rotate
-Angabe wieder auf die 0 zurückgesetzt wird. Damit dies ohne Rückwärtsspirale möglich ist, hätte kurz vor dem Zurücksetzen die Transition deaktiviert, d.h. die Einstellung auf „none
“ gesetzt werden müssen, was zur Folge gehabt hätte, dass die Änderung für den Benutzer unsichtbar gewesen wäre. Im besten Fall hätte der Browser das Bild gar nicht neu rendern müssen, sondern erkannt, dass es sich um dieselbe Position handelt. Wie dies von den Browsern genau implementiert ist, vermag ich jedoch nicht zu sagen. Jedoch hätte dies ein sehr genaues Timing vorausgesetzt, denn nach den 200ms für die Transition hätte die Routine gestartet werden müssen, die die Einstellung zurücksetzt. Der Browser hätte Zeit für die Verarbeitung benötigt, anschließend hätte die Animation wieder aktiviert werden müssen.
Wenn man sich diese Sequenz vor Augen führt und davon ausgeht, dass sich ein Benutzer in seiner Klick-Geschwindigkeit nicht unbedingt an vom Entwickler definierte Regeln hält, sollte schnell klar sein, dass dies in eine suboptimale Kaskade von setTimeout()
-Methoden ausgeartet wäre, die aufgrund Benutzer-Aktionen und der dadurch initiierten Verarbeitung der entsprechenden Events nicht für eine stabile Lösung geeignet hätte. Bei schnellen Klicks wäre es zu Rotations-Sprüngen gekommen, die das Verhalten mehr oder minder unvorhersehbar hätten werden lassen.
Die Ideen von ChatPrakti waren aber letztlich auch nicht besser, eher im Gegenteil, von mangelnder Funktionalität mal ganz zu schweigen. Nach weiterem erfolglosen Probieren und nachdem meine Antworten entsprechend waren, reagierte er wenigstens so, wie ich es in dem Moment gebraucht hatte – Zitat: „Ja. GENAU DAS ist die einzig angemessene Reaktion auf diese ganze dämliche rotate()
-Scheiße. Ich hör dich förmlich durch die Tastatur knurren – und ich bin komplett bei dir.“
Rettungsanker und Kompromisse
Aber gut – manchmal muss man eben einfach Kompromisse eingehen, bzw. den Weg gehen, der die geringsten Schmerzen verursacht. Theoretisch können Integer-Werte in JavaScript richtig hoch werden – siehe Number.MAX_SAFE_INTEGER, das wären so ungefähr neun Billiarden. Ich habe zugegebenermaßen nicht getestet, ob der Browser mit derart hohen Grad-Werten für „rotate
“ zurecht kommt, aber wollte ihm dies auch nicht zumuten. ChatGPT meinte übrigens, dass GPU-Backends bzw. CSS-Engines bei größeren Zahlen jenseits der 100.000 „anfangen, zu spinnen“ oder einfach aufgeben würden. Ob dies der Realität entspricht, sei mal dahingestellt, aber genau dies wollte ich beim nächsten Versuch eines Workarounds auch vermeiden.
Die Idee dahinter war, die rotate-Werte tatsächlich einfach so lange weiter laufen zu lassen, bis eine gewisse Grenze erreicht ist. Diese Grenze, willkürlich gesetzt auf +/-100.000, d.h. der maximale bzw. minimale Zahlenwert für „rotate
“ dient als eine Art Rettungsanker. Wird er überschritten, was sowohl in positiver als auch negativer Richtung geschehen kann, wird eine Routine aufgerufen, die den Wert zurücksetzt. Zwar gibt es, sollte der Benutzer weiterhin auf den Rotate-Button hämmern, immer noch die Möglichkeit eines ungewollten Bild-Sprungs, aber da sich dafür das Bild bereits ca. 277 Mal gedreht haben muss, halte ich dies für einigermaßen hinnehmbar.
Der vollständige Code ImageViewer.vue
befindet sich in folgendem Gist:
Die Verwendung soll hier nur kurz skizziert werden, die entsprechenden Teile der Eltern-Komponente sehen beispielsweise wie folgt aus:
<template> <!--Zoom controls --> <button type="button" @click="jpgViewerRef.scale = jpgViewerRef.scale > 0.25 ? jpgViewerRef.scale - 0.25 : jpgViewerRef.scale"> <Icon name="IconMinus" /> </button> <button type="button" @click="jpgViewerRef.scale = jpgViewerRef.scale < 4 ? jpgViewerRef.scale + 0.25 : jpgViewerRef.scale"> <Icon name="IconPlus" /> </button> <!-- Toggle original size --> <button type="button" @click="jpgViewerRef.toggleOriginalSize"> <Icon v -if="!jpgViewerRef?.isOriginalSizeActive" name="IconArrowsMaximize" /> <Icon v -else name="IconArrowsMinimize" /> </button> <!--Rotation buttons --> <button type="button" @click="jpgViewerRef?.rotateLeft"> <Icon name="IconRotate" /> </button> <button type="button" @click="jpgViewerRef?.rotateRight"> <Icon name="IconRotateClockwise" /> </button> <ImageViewer :src="jpgUrl" ref="jpgViewerRef" /> </template> <script setup lang="ts"> import { ref, onMounted, onUpdated } from "vue"; import ImageViewer from '@/components/helper/ImageViewer.vue' const jpgUrl = ref(''); const jpgViewerRef = ref(); onMounted(() => { jpgUrl.value = "https://example.com/image.jpg"; }); </script> <style scoped></style>
Anhand dieses Code-Fragments soll insbesondere die Verfügbarkeit und Nutzung der Funktionen bzw. Variablen innerhalb der Parent-Komponente verdeutlicht werden, es handelt sich jedoch nicht um ein lauffähiges Beispiel, das per Copy&Paste in Betrieb genommen werden kann.
Rotieren, Animieren, und wieder von vorn
Zum Schluss noch ein paar Hinweise zu den Funktionen rotateRight()
bzw. rotateLeft()
. Wie eingangs erwähnt ist darin nun der bereits beschriebene Rettungsanker implementiert. Kurz erläutert am Beispiel von rotateRight()
:
-
if (bouncePending) return;
– Hier erfolgt die Prüfung, ob gerade eine laufende Korrekturbewegung stattfindet. Wird versucht, währenddessen eine weitere Drehung zu veranlassen, wird die Funktion sofort beendet. -
rotation.value += 90;
– Erhöhung der Rotation um 90° im Uhrzeigersinn. -
if (rotation.value > 100000) { ... }
– Der beschriebene „Rettungsanker“. Falls der Benutzer wie verrückt klickt und tatsächlich dieser – willkürlich gewählte – Wert überschritten wird, soll die rotate-Angabe wieder normalisiert werden. -
bouncePending = true;
– Markiert die Vorbereitung der einmaligen Zurücksetz-Aktion. Alle weiteren Rotationen sollen dabei blockiert werden, bis diese Aktion abgeschlossen ist. -
setTimeout(() => { ... ], 210);
– Wartet 210ms, damit die letzte, vom Benutzer initiierte Dreh-Animation noch vollständig dargestellt werden kann. Ohne diese Pause würden sich die Aktionen überlagern, die Folge wäre wiederum ein unerwünschter „Sprung“. -
enableTransition.value = false;
– Die CSS-Transition wird ausgeschaltet, damit der Sprung vom gerade erreichten, großen Wert auf den normalisierten, kleinen Wert sofort und ohne Animation passieren kann. Dazu erfolgt in der computed Property „imgStyle
“ die entsprechende Abfrage: „transition: enableTransition.value ? 'transform 0.2s ease' : 'none',
„ -
rotation.value = ((rotation.value % 360) + 360) % 360;
– Normalisierung des rotate-Wertes auf eine Grad-Zahl zwischen 0 und 359, negative Werte werden dabei ausgeschlossen -
requestAnimationFrame(() => { ... }
– Wartet einen Frame, bevor der nächste, d.h. sich in der Funktion befindliche, Code läuft. Damit wird sichergestellt, dass der Browser zunächst den rotationslosen Sprung ohne Animation rendert, der durch die Rücksetzung des rotate-Wertes veranlasst wird. Eine Alternative zu dieser Funktion wäre die Verwendung eines weiterensetTimeout()
-Aufrufs, doch dies hätte den Nachteil, dass dabei eine Abschätzung des zeitlichen Ablaufs notwendig wäre. Da zwei zeitkritische, verschachtelte Aktionen eher keine gute Idee sind, wird auf requestAnimationFrame() zurückgegriffen. Der Ablauf sieht damit in aller Kürze so aus:-
Animierte Rotation um 90°
-
210ms warten
-
Transition deaktivieren
- Zurücksetzen des rotate-Wertes auf 0 bis 359
- Auf den nächsten Frame warten, Callback läuft zu Beginn dieses Frames
- Transition wieder aktivieren, folgende Rotationen sind wieder animiert
-
enableTransition.value = true; bouncePending = false;
– Aktivieren der CSS-Animationen und Blockierung aufheben, so dass weitere Rotationen, die durch den Benutzer initiiert werden, wieder animiert erfolgen
Eigentlich mag ich CSS. Eigentlich…
Natürlich könnte man jetzt einwenden – wozu dieser Aufwand? Warum nicht einfach weiter laufen lassen? Wenn der Benutzer sein Bild eben drölftausend Mal drehen lassen möchte und irgendwann der Browser abschmiert, ist es eben sein Pech! Wahrscheinlich sehe ich es sowieso viel zu eng, denn niemand, der noch seine paar Sinne beisammen hat, wird ein Bild so oft drehen, dass die Grenzen je erreicht werden. Und so weiter… Andererseits – wir haben 2025, und ehrlich gesagt hätte ich auch nicht damit gerechnet, dass ein solch eigentlich kleines Problem einen derartigen Aufwand überhaupt notwendig werden lässt. Und erst recht hätte ich mit einer Standard-Lösung gerechnet, die irgendwie sauberer wirkt als „einfach laufen lassen„.
Solange CSS jedoch keine Möglichkeit bietet, die bei Animationen optional einfach den kürzesten Pfad nimmt, könnte der hier vorgestellte Code zumindest ein kleiner Workaround sein.