
Zwischenbilanz positioner 8.1.0

Comics für das Internet
Frontend-Editing mit TYPO3 in normalisierten Datenbank-Domänen
Murphy: „In jedem großen Problem steckt ein Kleines, das raus will.“ Bei Comics und Cartoons gilt das umgekehrte. Eigentlich sind Comics und Cartoons gehören zu Klasse der Bild-Text-Kompositionen. Sie sollten sich einfach fürs Internet bauen lassen. Diese Bild-Text-Klasse durchdringt unser Leben; Werbeplakate, Katalogseiten, Möbelbauanleitungen, Straßenkarten oder auch chemischen Reaktionsgleichungen gehören dazu. Weil TYPO3 bei Pixelgraphiken nicht zwischen Karikatur, Bauzeichnung und Werbefoto unterscheidet, sollte ein Comic-Extension sehr vielfältig einsetzbar sein oder als Templategenerator dienen. In meiner Naivität übersah ich ein großes Problem, das dem Problemchen Internet-Comic innewohnt: die Datenflut.
Der Artikel ist in drei Teile aufgeteilt. Das erste Drittel nähert sich der Extension aus konzeptioneller Sicht. Das zweite Drittel beschäftigt sich mit Code-Ausschnitten und der Integration des Frontend-Editings-Pattern. Das letzte Drittel reflektiert den aktuelln Stand, analysiert Vor- und Nachteile und wagt einen Zukunftsblick.
Konzept: Zuviel ist niemals gut
Da TYPO3 7.6 und auch nicht TYPO3 8.6 keine Konzept anbietet, musste ich selbst ein Frontend-Editing-Konzept erdenken. Es sollte schnell, einfach und standardisiert sein. Performance war mir nicht wichtig. Nach mühevollen Wegwerfen alter Ideen wuchs irgendwann die simple Idee, die Prinzipien der Datenbank-Normalisierung systematisch auch für Views und Controller zu fordern. In der Comic-Extension repräsentiert jedes Modell ein Objekt, dass einen View, Tests, ein Inputformular und einen Controller sein eigen nennt. Wie ein normalisierter Datensatz ist ein normalisierte Objekt vollständig einzigartig im Vergleich zum Rest der Welt und gleichzeitig doch nur ein Rädchen in der Komposition 'Comic'. Die Abbildung 1 zeigt, was ich alles für einen Comic für wichtig hielt. Das XML-Konstrukt beschreibt gleichzeitig auch die grundsätzliche HTML-Struktur.
<story> <artinfo /> <comic dimension borderstyle > <artinfo /> <cartoon dimension position borderstyle rotation > <picture dimension image /> <speech position dimension rotation > <bubble color symmetry image overlaysBox /> <bubblepointer dimension position color symmetry rotation image / … > </bubble> <text /> <audio audiofiles /> </speech … > </cartoon … > </artinfo> </comic> </artinfo> </story>
Wie das XML-Schema zeigt, muss neben den Texten und Bildern viel zusätzlich definiert werden. Dazu gehören zum Beispiel Größe, Ausrichtung, Symmetrie, Position und Farbraum für jede Sprechblase. Angesichts der vielen Größendefinitionen wird die Trennung zwischen Backend und Frontend, die man in TYPO3 und vielen anderen Content-Management-Systemen findet, zum Problem. Der Redakteur sieht im Backend nicht mehr, was er im Frontend eigentlich anrichtet. Für Comics brauche ich also WYSIWYG (What You se, is what you get).
Das WYSIWYG muss dem Redakteur schnell Änderungen erlauben. Dies funktioniert nur mit AJAX, dass für die Weiterarbeit nicht auf die Serverrückmeldung wartet. Die Abbildung 1 impliziert auch verschiedene Relationen zwischen Tabellen/Models. Im View können die Relation durch Partialaufrufe übersichtlich nachgebildet. Für die Übersichtlichkeit wird eine hohe Verschachtelungstiefe in Kauf genommen. In der php.ini musste zum Beispiel der Default-Wert xdebug.max_nesting_level=100 auf 1200 hochgesetzt werden. Wegen der Übersichtlichkeit werden auch im View auch Dateneingabe, Datenausgabe und Tests zusammengehalten, wobei hier View meist eine Komposition von Partialaufrufen meint. Damit nun ein Surfer keine Änderungen am comic vornehmen kann, programmierte ich den Burka-Viewhelper. Dieser zeigt seine umschlossenen Formulare und Tests nur dann an, wenn ich gleichzeitig als Admin eingeloggt bin. Die TYPO3-Viewhelper f:security.ifAuthenticated und f:security.hasRole gelten nur für Frontend-User und lassen erahnen, wie stark das Paradigma der Frontend-Backend-Trennung TYPO3 durchdringt und mich bei der Ideenfindung behinderte.
Paradigmen: Hemmschuhe gegen neue Ideen
Die Entwicklung des Frontend-Editing erfolgte sehr kleinschrittig und führte mich nach vielen Irr- und Umwegen zum Frontend-Editing mit Icon-Menüs. Dafür waren verschiedene Arbeiten nötig:
Der Viewhelper f:form ist fehlerhaft und musste durch einen eigenen form-Viewhelper gepatcht werden. (siehe BUG #80341 im TYPO3-forge)
Ich brauchte generische ID's für Menüs, Formulare und Datenfelder. Gegen den Geist vom MVC werden diese aktuell vom View generiert und sind nicht im Model definiert.
Für das einfache Bearbeiten von Position, Größe etc waren jQuery-Plugin-Lösungen einzubauen.
Die Extension Positioner erlauben dem User, für Cartoons und Comic individuell CSS-Rahmenklassen auszuwählen. Diese Klassen selbst werden in der Extension „borderstyle“ definiert. Die Extension war ein Umweg, um zu prüfen, wie man mit TYPO3 temporäre Style-Themes definieren könnte. (Die Extension ermöglicht es zum Beispiel Inhalte zu Ostern oder zu Weihnachten mit besonderen individuell definierbaren Rahmen zu schmücken.)
Jedes Dateneintrag kann erst verändert werden, wenn das dazugehörige Formular per Klick auf einen Icon/Text-Button aktiviert wurde. Jede Deaktivierung führt automatisch beim Wechsel zu anderen Formularen oder beim Submit-Event. Bei Tear-Down wird automatisch der letzte Eintrag gespeichert.
Die Buttons für Create- oder Delete-Action aktivieren immer einen Seitenreaload. Da alle Icons verschiebbar sein sollten, muss für die Auslösung der Aktion beim Anklicken die Taste C(reate) oder D(elete) gedrückt sein.
Leider beißen sich die Syntax von Fluid und Javascript, so dass solide Funktionstest mit Jasmine nur wenig und wenn kompliziert zu realisieren waren.
Leider funktioniert aus irgendeinem Grund die Sprachumschaltung nicht korrekt. Die Ursache ist unklar. (siehe TYPO3-Bug 57272)
Design-Pattern für modulares Frontend-Editing
Ein Kernmerkmal der Extension 'positioner' ist für jedes Modell genutzte Frontend-Editing-Pattern. Das Pattern spiegelt sich als Querschnitt-Aufgabe an vielen Stellen der Extension wider. Die Code-Fragmente für das normalisierte Objekt 'rotation' sind in der Extension über seinen Namen bestimmbar.
Das Objekts 'rotation' braucht natürlich Definitionen für Referenz und Modell in der Datenbank. Entsprechender Code findet sich in der Datei ext_tables.sql
Abbildung. Datenbank-Definitionen in ext_tables.sql
CREATE TABLE tx_positioner_domain_model_cartoon ( ... rotation int(11) unsigned DEFAULT '0', ... ) CREATE TABLE tx_positioner_domain_model_rotation ( ...)
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addLLrefForTCAdescr( 'tx_positioner_domain_model_rotation', 'EXT:positioner/Rotation/Private/Language/locallang_csh_tx_positioner_domain_model_rotation.xlf' ); \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::allowTableOnStandardPages( 'tx_positioner_domain_model_rotation' );
Abbildung.generische Namen für Actions in ext_localconf.php
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( 'Porth.' . $_EXTKEY, 'Cartoon', array( .... 'Rotation' => 'ajax, createrotationcartoon, updaterotationcartoon, deleterotationcartoon,...', .. ), // non-cacheable actions array( ... 'Rotation' => 'ajax, createrotationcartoon, updaterotationcartoon, deleterotationcartoon,...', .. ) );
Abbildung.Create-Methode mit Dummy-Methode aus Repository
/** * Creates a new rotation-entry * * @param \Porth\Positioner\Domain\Model\Cartoon $parent The blog the post belongs to * @param \Porth\Positioner\Domain\Model\Rotation|null $rotation A fresh Post object which has not yet been persisted * @return void */ public function createrotationcartoonAction( \Porth\Positioner\Domain\Model\Cartoon $parent, \Porth\Positioner\Domain\Model\Rotation $rotation = null ) { // see the comment in BubbleController in method createbubblespeechAction /** @var \Porth\Positioner\Domain\Model\Cartoon $myParent */ $myParent = $this->cartoonRepository->findByUid($parent->getUid()); if (is_null($myParent->getRotation())){ $rotation = $this->rotationRepository->buildDummy($this->storagePid,\Porth\Positioner\Domain\Repository\RotationRepository::ROTATIONREPOSITORY_FLAG_DUMMY_CARTOON); $this->rotationRepository->add($rotation); $myParent->setRotation($rotation); } $this->cartoonRepository->update($myParent); $this->persistenceManager->persistAll(); $uri = self::detectUriToCurrentPageWithoutTypeNum(); $this->redirectToUri($uri); }
Abbildung.Dummy-Methode definiert sinnvolle Startwerte
class RotationRepository extends \TYPO3\CMS\Extbase\Persistence\Repository { public function buildDummy($pid = 0, $type = self::ROTATIONREPOSITORY_FLAG_DUMMY_CARTOON) { /** @var \Porth\Positioner\Domain\Model\Rotation $dummy */ $dummy = $this->objectManager->get("Porth\\Positioner\\Domain\\Model\\Rotation"); $dummy->setPid($pid); switch (ucfirst($type)) { case self::ROTATIONREPOSITORY_FLAG_DUMMY_CARTOON: $dummy->setRotation(0); break; .... } self::rebuildDummyElement($dummy, $type); return $dummy; } }
Abbildung.Zusätzliche reset-Methode in Elter-Modell
/** * Cartoon */ class Cartoon extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity { … public function resetRotation() { $this->rotation = null; } } Natürlich finden sich auch TypoScript-Definitionen für das Pattern. Hier Konstanten definiert, die man braucht um dynamisch, flexibel und systematisch das Menü und die Formulare definieren und steuern zu können. Abbildung. TypoScript-Definitionen zur Steuerung von Menu und Formularen plugin.tx_positioner.settings.controller { ... action { type { update = update delete = delete create = create } parent { ... rotation { name { cartoon = cartoon speech = speech bubblepointer = bubblepointer } parts { create = create delete = delete all = all rotation = rotation } } } } menu { action { update = update delete = delete create = create } dataTypeAdditional = -datatype dataType { ... rotation = rotation } dataReaction { ... rotation = rotation create = create delete = delete } modelAdditional = -objecttype model { ... rotation = rotation } parentAdditional = -parenttype parent { ... cartoon = cartoon } flag { redirect = redirect } } }
Da sich das Pattern 'rotation' von vielen anderen Pattern abgrenzen soll, finden sich in der CSS-Datei der Extension abgrenzende (Farb-)Definitionen. Da das JavaScript nur vom Backend genutzt wird, ist es nur wenig optimiert. Das JavaScript für das normalisierte Objekt 'rotation' ist sehr ähnlich mit dem Pattern für 'dimension', 'position' oder andere. Bei Aktivieren wird ein SetUp-Prozess gestartet, der das Formular des Model in das Menü verschiebt, der den Button als Aktiv kennzeichnet und und der gegebenenfalls ein jQuery-Plugin aktiviert. Beim Tear-Down-Prozess wird der Submit-Event gestartet und danach alles wieder zurückgebaut. War die Ajax-Übertragung erfolgreich, wird im Info-Tab des Menü eine Grün-Meldung intwegriert. War sie fehlerhaft, kann man sich gegebenenfalls die TYPO3-Fehlermeldung anzeigen lassen.
Um den Code möglichst allgemeingültig zu halten, startet der SetUp-Prozess durch Anclicken des Buttons. Der Button enthält in data-Attributen alles Wichtige, wie Formular-IDs und Feld-IDs. Da manches DOM-Element mehrere Daten- bzw. Feldeinträge enthalten kann bzw. weil Manche Daten an mehreren Stellen zu setzen sind, werden die DOM-Elemente der Daten enthaltenden Felder über $('div[data-fieldid*=",' + destination + ',"]') eindeutig bestimmt. Damit die CSS-Suche greift, im Template von TYPO3 darauf geachtet werden, dass ein Eintrag für das Attribut data-fieldid immer durch Kommas eingeschlossen ist.
Abbildung. Auszug aus dem JavaScript für Frontend-Editing
/*********************************************************************************************** * * Rotation * the destination is binded to the jQueryUi-resize-Acrtion, which know the event 'resizestop' */ porth.positioner.FrontendEditing.setUpUserTransformJsValueToCssValue = function (javaScriptValue) { return parseInt(((360 + javaScriptValue * 180 / Math.PI) % 360), 10); }; porth.positioner.FrontendEditing.setUpUserTransformCssValueToJsValue = function (inputFormValue) { return parseInt((inputFormValue / 180 * Math.PI), 10); }; porth.positioner.FrontendEditing.setUpUserStopChangeRotationBind = function ($destination, $source, jsSourceArray) { var params = { angle: porth.positioner.FrontendEditing.setUpUserTransformCssValueToJsValue($source.val()), wheelRotate: true, stop: function (event, ui) { var radValue, degreeValue; radValue = porth.positioner.FrontendEditing.utility.extraktValueFromUi(ui, jsSourceArray[porth.positioner.FrontendEditing.params.jQueryResource]); degreeValue = porth.positioner.FrontendEditing.setUpUserTransformJsValueToCssValue(radValue); porth.positioner.FrontendEditing.utility.setForm($source, degreeValue); } }; if ($destination.data('flag-formhelper-rotation') != '1') { // make container rotable $destination.rotatable(params); // flag for unique activation/deactivation of ration-helper $destination.data('flag-formhelper-rotation', '1'); } }; /** * fill the input-form by the jQuery-Plugin * * @param $destination */ porth.positioner.FrontendEditing.setUpUserStopChangeRotationFree = function ($destination) { if ($destination.data('flag-formhelper-rotation') == '1') { $destination.rotatable('destroy'); // flag for unique activation/deactivation of ration-helper $destination.data('flag-formhelper-rotation', '0'); } }; /** * parametrisize the jQuery-Plugin by changing the input-form * * @param $destination */ porth.positioner.FrontendEditing.setUpUserStopChangeRotationReBind = function ($destination, $source, jsSourceArray) { $source.on('focusout', function () { $destination.rotatable( 'angle', porth.positioner.FrontendEditing.setUpUserTransformCssValueToJsValue($(this).val()) ); }); }; porth.positioner.FrontendEditing.setUpBindingRotation = function (source, destination, jsSourceArray) { // init blinking of active object var $destination = $('div[data-fieldid*=",' + destination + ',"]'), $source = $('#' + source); porth.positioner.FrontendEditing.blinkBackgroundDataId($destination, true); porth.positioner.FrontendEditing.setUpUserStopChangeRotationBind($destination, $source, jsSourceArray); porth.positioner.FrontendEditing.setUpUserStopChangeRotationReBind($destination, $source, jsSourceArray); }; porth.positioner.FrontendEditing.tearDownBindingRotation = function (source, destination) { var $destination = $('div[data-fieldid*=",' + destination + ',"]'), $source = $('#' + source); // stop blinking of active object and disallwo resizing porth.positioner.FrontendEditing.blinkBackgroundDataId($destination, false); porth.positioner.FrontendEditing.setUpUserStopChangeRotationFree($destination); $source.off('focusout'); }; porth.positioner.FrontendEditing.setUpTypeRotation = function ($posMarkup) { var pairAryRaw = $posMarkup.data('pairs'), pairAry = (!!pairAryRaw) ? pairAryRaw.split(';') : '', formId = $posMarkup.data('form'), i, pair, source, destination, jsSourceArray; // porth.positioner.FrontendEditing.triggerLastSaveRequestBeforeTearDown(); //move form to selectLayer porth.positioner.FrontendEditing.removeFormAndClassInOldActiveButton((pairAry !== ''), false); $('#' + formId).appendTo('#' + porth.positioner.FrontendEditing.constants.selectLayerEditId); for (i = 0; i < pairAry.length; i++) { // get Names of field and place pair = pairAry[i].split(','); source = pair[porth.positioner.FrontendEditing.constants.positionFormFieldId]; destination = pair[porth.positioner.FrontendEditing.constants.positionViewId]; jsSourceArray = porth.positioner.FrontendEditing.utilityExtraktParameters(porth.positioner.FrontendEditing.constants.positionJavaScriptValue, pair); if (jsSourceArray.length >0) { porth.positioner.FrontendEditing.setUpBindingRotation(source, destination, jsSourceArray); } } $posMarkup.addClass('active'); }; porth.positioner.FrontendEditing.tearDownTypeRotation = function ($posMarkup) { var pairAryRaw = $posMarkup.data('pairs'), pairAry = (!!pairAryRaw) ? pairAryRaw.split(';') : '', i, pair, source, destination, jsSourceArray; porth.positioner.FrontendEditing.triggerLastSaveRequestBeforeTearDown(); //remove form selectLayer to original .positioner-markup-Place porth.positioner.FrontendEditing.removeFormAndClassInOldActiveButton(true, true); for (i = 0; i < pairAry.length; i++) { // get Names of field and place pair = pairAry[i].split(','); source = pair[porth.positioner.FrontendEditing.constants.positionFormFieldId]; destination = pair[porth.positioner.FrontendEditing.constants.positionViewId]; jsSourceArray = porth.positioner.FrontendEditing.utilityExtraktParameters(porth.positioner.FrontendEditing.constants.positionJavaScriptValue, pair); porth.positioner.FrontendEditing.tearDownBindingRotation(source, destination); } };
<f:section name="main"> <f:alias map="{ ridRotation: '{rid}_rotation-{cartoon.rotation.uid}',... }“ > <f:alias map="{ … }"> <f:alias map="{ … }" > <porth:burka status="admin_be"> <f:if condition="{cartoon.rotation}"> <f:then> <f:render partial="Forms/Rotation/Cartoon/Delete" … /> </f:then> <f:else> <f:render partial="Forms/Rotation/Cartoon/Create" … /> </f:else> </f:if> </porth:burka> <div style="position:absolute; width:100%; height: 100%; "> <div class="rotation" style=" transform-origin: 50% 50%; transform: rotate({cartoon.rotation.rotation}deg); width: 100%; height: 100%; position:absolute; overflow: hidden;" {porth:burka(status:'admin_be',data:'data-fieldid=",{ridRotationRotation}," id="{ridRotationRotation}"')} > <f:render partial="Cartoon/Picture" … /> </div> <!-- … --> </div> <porth:burka status="admin_be"> <f:if condition="{cartoon.rotation}"> <f:then> <f:render partial="Menu/MarkerSign" … /> <f:render partial="Forms/Rotation" … /> </f:then> </f:if> </porth:burka> </f:alias> </f:alias> </f:alias> </f:section>
<?xml version="1.0"?> <html xmlns:f="http://typo3.org/ns/TYPO3/Fluid/ViewHelpers" xmlns:porth="http://typo3.org/ns/Porth/Positioner/ViewHelpers" > <head> </head> <body> <f:section name="main"> <f:alias map="{ ridDelete: '{rid}_cartoon-delete', ridDeleteMarkup: '{rid}_cartoon-delete-markup', ridDeleteForm: '{rid}_cartoon-delete-form', actionType: settings.controller.action.type.delete, actionFields: settings.controller.action.parent.cartoon.parts.all, actionParent: settings.controller.action.parent.cartoon.name.comic, menuAction: settings.controller.menu.action.delete, menuModel: settings.controller.menu.model.cartoon, menuParent: settings.controller.menu.parent.comic }" > <f:comment><!-- ########################################################################### tests ########################################################################### --></f:comment> <f:comment><![CDATA[<!-- <porth:burka status="admin_be"> <f:if condition="{settings.tests.general.formAspects}"> <div style="display:none">There is noting to test </div> </f:if> </porth:burka> -->]]></f:comment> <f:comment><!-- ########################################################################### content ########################################################################### there no infos to show --></f:comment> <f:comment><!-- ########################################################################### frontend-editing ########################################################################### --></f:comment> <porth:burka status="admin_be"> <f:render partial="Menu/MarkerSign" section="main" arguments="{ rid: ridDelete, ridMarkup: ridDeleteMarkup, ridForm: ridDeleteForm, pairs: 'nothing', menuAction: menuAction, menuModel: menuModel, menuParent: menuParent, menuDataType: settings.controller.menu.dataType.cartoon, menuDataReaction: settings.controller.menu.dataReaction.delete, sizeClass: sizeClass, markerSignStyle: '', sign: '{menuModel}-{menuAction}#{actionParent}', iconPath: '' menuTitle: '{f:translate(key:\'LLL:EXT:positioner/Resources/Private/Language/locallang_editing.xlf:frontend.menu.title.button.info.{menuAction}\')}' }" /> <f:render partial="Forms/Cartoon" section="main" arguments="{ ridForm: ridDeleteForm, cartoon: cartoon, cartoonFields: cartoonFields, actionType: actionType, actionParent: actionParent, actionFields: actionFields, mediaIn: mediaIn, plugIn: plugIn, loadtype: settings.frontendEditing.redirectLoadType, infoText: { error: '{menuModel}-{cartoonFields.all}', success: '{menuModel}-{cartoonFields.all}' }, parent: parent }" /> </porth:burka> </f:alias> </f:section> </body> </html>
<?xml version="1.0"?> <html xmlns:f="http://typo3.org/ns/TYPO3/Fluid/ViewHelpers" xmlns:porth="http://typo3.org/ns/Porth/Positioner/ViewHelpers" > <head> </head> <body> <f:section name="main"> <f:render partial="Forms/Cartoon/Make" section="main" arguments="{ ridForm: ridForm, cartoon: cartoon, cartoonFields: cartoonFields, actionType: actionType, actionParent: actionParent, actionFields: actionFields, menuDataReaction: menuDataReaction, loadtype: loadtype, infoText: infoText, mediaIn: mediaIn, plugIn: plugIn, parent: parent }" /> </f:section> </body> </html>das eigentliche Formular<?xml version="1.0"?> <html xmlns:f="http://typo3.org/ns/TYPO3/Fluid/ViewHelpers" xmlns:porth="http://typo3.org/ns/Porth/Positioner/ViewHelpers" > <head> </head> <f:section name="main"> <div class="hidden" id="{ridForm}-container"> <f:render partial="Forms/General/AjaxError" section="main" arguments="{ ridForm: ridForm, alternativHeadline: '', alternativMessage: '', errorInfo: '.{menuModel}', errorInfoPart: '.{actionFields}'}" /> <porth:form method="post" controller="Cartoon" action="{actionType}{actionFields}{actionParent}" pageType="{f:if(condition:'{loadtype}=={settings.frontendEditing.ajaxLoadType}',then:'{settings.frontendEditing.ajaxTypeNum}',else:'{settings.frontendEditing.redirectTypeNum}')}" arguments="{parent:parent}" additionalParams="{switcher:plugIn}" pluginName="{plugIn}" name="cartoon" object="{cartoon}" id="{ridForm}" class="[ js-form-fe ]" additionalAttributes="{ data-error: '{f:translate(key:\'LLL:EXT:positioner/Resources/Private/Language/locallang_backend.xlf:tx_positioner_domain_model_cartoon.update.error\')} {infoText.error}', data-success: '{f:translate(key:\'LLL:EXT:positioner/Resources/Private/Language/locallang_backend.xlf:tx_positioner_domain_model_cartoon.update.success\')} {infoText.success}', data-action-reload: '{f:translate(key:\'LLL:EXT:positioner/Resources/Private/Language/locallang_backend.xlf:tx_positioner_domain_model_cartoon.question.{actionType}\')}', data-actiontype: '{actionType}', data-type: menuDataReaction }" > <f:render partial="Forms/Cartoon/Fields" section="main" arguments="{ ridForm: ridForm, cartoon: cartoon, cartoonFields: cartoonFields, actionFields: actionFields, mediaIn: mediaIn }" /> <table> <tr> <td> <f:form.submit value="{f:translate(key:'LLL:EXT:positioner/Resources/Private/Language/locallang_editing.xlf:frontend.edit.button.general.{actionType}')}" class="submit" data="{loadtype:'{loadtype}'}" /> </td> <td> <f:form.button type="reset" name="cancelForm" value="cancelForm" class="cancel-form" > <f:translate key="LLL:EXT:positioner/Resources/Private/Language/locallang_editing.xlf:frontend.edit.button.general.cancelForm" /> </f:form.button> </td> </tr> </table> </porth:form> <f:render partial="Forms/Cartoon/JavaScript" section="main" arguments="{ ridForm: ridForm, cartoonFields: cartoonFields, actionFields: actionFields }" /> </div> </f:section> </html>
Bilder sagen mehr als 1000 Worte
Abbildung - Informations-Overkill: Darstellung, wenn ich als Admin eingeloggt bin und alle aktuell verfügbaren 'Icons' „freigezogen“ wurde und wenn die Testumgebung sowie ein Formular aktiv sind.

Abbildung - Zeige an, was du brauchst. Nutze das Frontend-Menü. Nur die 'Rotation' ist gerade ausgewählt.
Abbildung - Und so sieht es am Ende der Surfer.
Abbildung - HTML plus SVG plus 0px bie top, bottom, left & right = scharfe dynamische Sprechblase
<div class=".." style=" position:relative; width:200px;; height:200px;;"> <div style="position:absolute; width:100%; height: 100%; "> <div class=".." style=" transform-origin: 50% 50%; transform: rotate(70deg); width: 100%; height: 100%; position:absolute; overflow: hidden;"> <div class=".." style=" "> <img id=".." style="width:100%; height: auto;" src="/fileadmin/_processed_/8/2/csm_img_0641_f030594dd4.jpg" alt="" height="150" width="200"> </div> </div> <div class=".." style="width: initial; height:0px; overflow: visible; position:absolute; z-index:7; left:111%; top:12%; "> <div class="ui-widget-content [ rotation ]" style="position: relative; width:auto; height:auto; transform-origin: 50% 50%; transform: rotate(30deg);"> <div class=".. " data-svg-container="1" style="position: absolute; display: block; top: 0px; bottom: 0px; left: 0px; right: 0px; filter: alpha(100); opacity:1; ;width: 100%; height: 100%;" title="Alles Cool"> <div class="nix" style="position:absolute; top:0; left:0; transform-origin: 0% 0%; width:100%; height: 100%; z-index: 7;"> <svg id=".." class=".." ... viewBox="0 0 640 480" data-url=".." style="width: 100%; height: 100%;transform-origin: 50% 50%;" preserveAspectRatio="none"> <defs id="defs3350"> <metadata id="metadata3353"> </metadata> <g id="layer1"> <path id="path3357" style="fill-rule: evenodd; stroke-width: 1px; stroke-linecap: butt; stroke-linejoin: miter; stroke-opacity: 1;" d="..."> </g> </svg> </div> </div> <div class="..." style="position: relative; z-index: 14; height:auto; width:auto; padding:10% 8% 10% 5% ; " data-text-over-svg="1"> <div style=" height:117%; color: #0014F6;"><p> <span style="font-family: Normalschrift,serif;">Die Lösung war wohl zu stark.</span>. </p></div> </div> <div class="js-resource"> </div> </div> </div> </div> </div>
Zwischenbilanz
Projekt: Reflektion zum aktuellen Stand (April 2017)
Das Konzept des normalisierten MVCs macht die Programmierung übersichtlich und erlaubt ein flexibles Anpassen des Frontend-Editings an beliebige Modelle. Dabei ist TYPO3, abgesehen von Viewhelper-Bug, leicht darauf anzupassen. Eine Portierung des Pattern in den Extensionbuilder ist sicher denkbar.
Um während der Entwicklung die JavaScript-Event-Hölle für mich in Schach halten zu können, brauchte ich im Frontend Unit-Tests mit Jasmine. Grundsätzlich könnte zukünftig das Einbinden von Frontend-Tests helfen, das Updaten von TYPO3-Versionen sicher zu machen und das Testen von TYPO3-Websites zu systematisieren. Ich werde die Funktionstest-Option aus der Extension heraus ziehen und in eine externe Extension auslagern, wenn ich mal irgendwann Zeit dafür finde.
Gleiches gilt für den Ausbau der schon im TER verfügbaren Extension borderstyle, mit welcher Redakteure im Backend Rahmenklassen definieren können, die sie in das Frontwend einbauen könnten – vorausgesetzt ihr Template lässt dies zu. Ich werde in Zukunft das Zeit-Konzept anpassen, um so zum Beispiel tageszeitlich sich ändernde Layouts zu ermöglichen. Inhaltlich müsste die Extension um CSS für Farbgebungen, Schriftstyles, Hintergründe und Animationen erweitert werden. Insbesondere sollte die Extension das einfache Austauschen von Bild-Hintergründen erleichtern. Um TYPO3 echt Themes-fähig zu machen, muss zukünftig eine Zuordnung von mehreren Klassen in einem Klassenfeld möglich sein. Wenn TYPO3 Themes-fähig wäre, könnten Designer und Website-Betreiber darüber nachdenken, an welchen Stellen Redakteuren das Website-Schmücken erlaubt ist/sein muss.
Die Comic-Extension 'positoner' ist für mich eine Lern- & Spiel-Extension. Für die Hintergründe von den Sprechblasen verwende ich SVGs und habe viel über SVGs gelernt. Ich denke, dass das direkte Rendern von SVG's mit TYPO3 viele Seiten aufpeppen könnten, da man mit SVG zum Beispiel die Laufrichtung von Texten frei verändern kann. Vielleicht überkommt es mich und ich baue die Sprechblase noch vor einer Veröffentlichung im TER um? Der nächste Schritt besteht in jedem Fall in eine Test-Installation auf meiner aktuell verwaisten Website www.buergerstimmen.de / www.düddelei.de, um beim Pilot-Betrieb mit politischen Karikaturen die Nutzbarkeit der Extension zu optimieren. An Interessenten verkaufe ich gern die Nutzungslizenzen einer Vorabversion; denn die rund 700 Stunden meiner Freizeit, die ich in das Projekt bislang investiert habe, sollen sich ja zumindest ein wenig lohnen. In zirka ein bis zwei Jahren, wenn ich die Kinderkrankheiten ausgemerzt habe, werde ich die Extension dann im TER veröffentlichen. Mit dem hier veröffentlichten Angaben sollte ein guter TYPO3-Entwickler in 100-200 Stunden eine gleichwertige Extension nachbauen können bzw. auf andere Frontend-Szenarien übertragen. Die grundsätzliche Prinzip ist einfach und lässt asich auch auf andere Frameworks übertragen.
Aktuelle Probleme (Juni 2017) - TYPO3 und die kastrierten SVG#s
Ursprünglich wollte ich mal eben die Extension, die ursprünglich auf TYPO3 7.6 entwickelt wurde, nach TYPO3 8.7 migrieren. Dies stieß auf verschiedene kleiner Probleme. Ein Problem beschäftigt mich aktuell intensiver: die dynamische Manipulation von SVG-Graphiken mit einem Viewhelper.
Alte SVG-Lösung unter TYPO3 7.6
In der ursprünglichen Version wählte ich die Guttenberg-Programmier-Technik und kopierte mir alles zusammen. Die SVG-Graphik wurde damals mit dem f:uri-Viewhelper eingebunden, per JavaScript nachgeladen und in den HTML-Code geschrieben. Das JavaScript übernahm auch die notwendigen Anpassungen, damit über modifizierte SVG-Attribute die Sprechblasen die zugewiesenen Farben annahmen.
SVG-Kastration durch TYPO3 8.7
Unter Typo3 8.78 funktioniert dies nicht mehr, weil der f:uri-Viewhelper die SVG-Graphik über ImageMagick in eine Pixelgraphik umwandelt wurde. Der Code der Pixelgraphik wurde zeichencodiert über das Image-Tag ins SVG zurückgeschmuggelt und alle anderen SVG-Tags wurden entfernt. Diese kastrierte Version des SVG legte TYPO3 dann im Cache ab. Der gesamte SVG-Code fehlt also jetzt und ein nachträgliches Einfärben war mit dem alten (schlechten) Verfahren nicht mehr möglich.
(Eine Zeitlang habe ich versucht, mit Hilfe vom GIF-Builder und TypoScript Pixelgraphiken ähnlich wie beim Positioner 1.0 umzufärben. Aber die mehrfachen Anforderungen [Schutz der transparenten Bereichen, Spiegelung der Bilddateien & Mehrfarbiges Umfärben] habe ich nicht hingefrickelt bekommen. Außerdem haben SVG-Graphiken mehr Charme, weshalb ich mir beim Frickeln nicht allzu viel Mühe gab.)
Angestrebte SVG-Lösung unter TYPO3 8.7
Mit Hilfe eines Viewhelpers soll der SVG-Text direkt in das HTML eingebunden werden. Dabei soll der Viewhelper potentielle Gefahrenstellen für Cross-Site-Scripting per Default entfernen können und dynamisch steuerbare Ersetzungen von Attributen, Texten und Tags auf einfache Weise erlauben. Dabei wird eine grundsätzliche Lösung angestrebt, um zum Beispiel auch für Shop mal eben popige variantenreiche Teaser zu erlauben. Da ich bislang wenig Erfahrungen mit PHP-Unit-Tests habe, nutze ich dies schön abgegrenzte Problem gleich zum Üben, was die Entwicklung zusätzlich verlangsamt. Deshalb sind auf der Pilotseite www.düddelei.de bislang die Sprechblasen schwärzlich.
2017-Jun-16 - Aktuell nicht sicher gegen CSRF-Angriffe :-(
Wie mir letztens aufgefallen ist, sind die Actions nicht sicher gegen CSRF-Angriffe. Die Formulare und Actions müssen entsprechend erweitert werden.