Pixxl i granice dobrej architektury

  • architecture
  • frontend
  • canvas
  • rich-text
  • product-engineering

Analiza architektury Pixxl, w której rich text na canvasie staje się testem granic między neutralnym formatem CanvasDocument, silnikami domenowymi, render pipeline, workspace UI i integracją aplikacyjną.

Pixxl architecture boundaries

Większość narzędzi opartych na canvasie zaczyna się podobnie.

Najpierw powstaje jeden edytor. Potem drugi. Na początku wspólne wydają się głównie zoom, minimapa i kilka kontrolek. Szybko okazuje się jednak, że podobne są także zapis pliku, historia operacji, selekcja, panele, viewport, overlaye i integracja z aplikacją.

Wtedy organizacja kodu przestaje być lokalną decyzją implementacyjną. Staje się decyzją produktową.

Pixxl zaczyna właśnie w tym miejscu: od założenia, że aplikacje canvasowe trzeba traktować jak rodzinę produktów, a nie zbiór podobnych, ale osobnych edytorów.

Tutaj patrzę na Pixxl jak na architekturę produktów canvasowych: jeden wspólny substrat dla kilku edytorów, z neutralnym formatem pliku pod spodem i silnikami konkretnych produktów wyżej. Najciekawsze pytanie brzmi, czy taka warstwa wspólna może pozostać użyteczna, nie zmieniając się w uniwersalny edytor pod inną nazwą.

To rozróżnienie brzmi abstrakcyjnie, dopóki nie pojawi się rich text.

Rysowanie ścieżek, prostokątów i prostych obiektów da się jeszcze zmieścić w stosunkowo prostym modelu sceny. Edytor dokumentów jest innym problemem. Potrzebuje komend, transakcji, historii cofania, semantyki tabel, selekcji tekstu, układu stron, importu i eksportu, obsługi schowka, inputu klawiaturowego, IME, dostępności oraz wydajnego renderingu.

W Pixxl @pixxl/cantext pełni więc rolę testu skrajnego dla całej architektury. Jeśli wspólna warstwa canvasowa potrafi udźwignąć rich text, prawdopodobnie poradzi sobie też z prostszymi produktami.

Granica zamiast mega-edytora

Najciekawsza decyzja w Pixxl nie dotyczy samego canvasu ani samego rich textu. Dotyczy granicy odpowiedzialności.

Pixxl nie próbuje zbudować jednego mega-edytora, który jednocześnie udaje edytor dokumentów, narzędzie do rysowania, workspace dla grafów i kreator wykresów. Zamiast tego rozcina system na cztery warstwy:

  • neutralny format zapisu CanvasDocument
  • silniki domenowe dla konkretnych produktów
  • współdzieloną powłokę aplikacji canvasowej
  • osobną warstwę integracji aplikacyjnej

To rozdzielenie jest ważniejsze niż podobieństwo komponentów UI.

@pixxl/canvas pełni rolę współdzielonego substratu. Odpowiada za kształt dokumentu, migracje, walidację, prymitywy komend i runtime'u, historię, rejestr rendererów, matematykę workspace'u oraz wspólną powłokę Reactową.

Obok niego istnieją pakiety produktowe: @pixxl/cantext, @pixxl/candraw, @pixxl/cangraph i @pixxl/canchart. Każdy z nich ma własny silnik domenowy. Jeszcze wyżej znajduje się aplikacja website, gdzie trafiają panele, lokalna persystencja, pobieranie plików, dema i integracja konkretnego workflow.

Dzięki temu Pixxl ma czytelną mapę systemu. Na dole znajduje się neutralny dokument opisujący canvas, strony, zasoby, elementy i viewState. Wyżej działają silniki produktów, które wiedzą, czym naprawdę jest akapit, wykres, węzeł grafu albo ścieżka wektorowa. Obok nich stoi współdzielona powłoka UI: layout aplikacji, minimapa, kontrolki zoomu, sidebar inspektora i panel stron. Na samej górze są lokalne decyzje konkretnego produktu.

W praktyce oznacza to, że różne aplikacje mogą wyglądać jak część jednej rodziny, ale nie muszą dzielić jednego, przeciążonego modelu domenowego.

Plik jako najtrwalszy kontrakt

Najtrwalszym kontraktem nie jest tu komponent Reactowy. Jest nim format zapisu.

CanvasDocument w wersji 2 jest opisany jako przenośny format plików dla produktów canvasowych. Zawiera schema, version, konfigurację canvasu, listę stron, zasoby, elementy oraz stan widoku. Dokumenty są walidowane, normalizowane, serializowane i migrowane przez canvasDocumentCodec. Wejścia w wersji 1 mogą zostać przeniesione do wersji 2 przez dodanie stron i przypisanie elementom pageId.

To nie jest detal infrastrukturalny. To sygnał, że architektura jest projektowana w kategoriach czasu, a nie tylko najbliższego feature'a.

Najciekawsze jest jednak to, czego wspólna warstwa nie robi. Nie próbuje rozumieć pełnej semantyki rich textu, layoutu grafów ani normalizacji wykresów. Waliduje tylko kształt wspólny dla wszystkich produktów: poprawny nagłówek dokumentu, sensowne wymiary, referencje do stron, JSON-serializowalne pola oraz elementy z wymaganymi właściwościami canvasowymi.

Nie waliduje głęboko payloadów produktowych.

To świadomy kompromis. Dzięki niemu plik może zawierać nieznane typy elementów i nadal przejść przez warstwę wspólną. Cena również jest jasna: błędy domenowe są wykrywane później, na granicy konkretnego produktu, przez adaptery jego silnika.

Pierwszy praktyczny wniosek jest prosty: jeśli tworzysz więcej niż jeden produkt na canvasie, zacznij od granicy dokumentu, a nie od współdzielenia toolbarów.

W Pixxl to właśnie granica dokumentu jest właściwą abstrakcją. Komponenty są dopiero konsekwencją wcześniejszego kontraktu: co zapisujesz, co migrujesz, co wolno zachować bez zrozumienia, a co należy interpretować dopiero w silniku domenowym.

cantext jako test architektury

cantext pokazuje, jak wygląda taki silnik, gdy problem staje się naprawdę trudny.

Kanonicznym stanem edytora nie jest DOM, Markdown ani HTML. Jest nim strukturalny JSON określany jako DocumentStructure. Obejmuje ustawienia dokumentu, style, sekcje, bloki, runy inline, adnotacje, tabele, zakotwiczone obiekty, sugestie recenzenckie i inne elementy modelu dokumentu.

HTML i Markdown pojawiają się dopiero na wejściu oraz wyjściu.

Ta decyzja ma duże znaczenie. Gdy HTML staje się źródłem prawdy, logika renderowania i edycji zaczyna krążyć wokół DOM-u. Gdy źródłem prawdy jest struktura, canvas może pozostać powierzchnią renderującą, a nie protezą dla przeglądarki.

Dlatego cantext nie jest po prostu contenteditable na canvasie.

Architektura ma wyraźny pipeline. CantextHost owija HTML canvas i tworzy wokół niego EditorKernel, kontroler renderingu, adapter dostępności, adapter inputu, transfer service, menedżer skrótów i asset store.

EditorKernel staje się centrum runtime'u. Posiada model dokumentu, selekcję, command bus, historię, layout engine, wtyczki, wyszukiwanie, review, writing checks oraz asynchronicznie wyprowadzany stan pochodny.

Edycja odbywa się przez komendy i transakcje, nie przez bezpośrednie mutowanie węzłów. Następnie układ dokumentu generuje snapshoty i RenderScene, a warstwa renderująca maluje je na canvasie, opcjonalnie z użyciem ścieżki offscreen, jeśli jest dostępna.

To ważne rozdzielenie. Model dokumentu nie jest powierzchnią renderującą. Canvas nie jest modelem dokumentu. DOM nie jest źródłem prawdy. Każda warstwa ma własną odpowiedzialność.

RenderScene jako jawny kontrakt

Jedną z najmocniejszych decyzji technicznych jest potraktowanie RenderScene jako jawnego kontraktu.

Układ dokumentu nie renderuje bezpośrednio z modelu. Zamiast tego emituje prosty opis tego, co ma zostać narysowane: strony, runy tekstu, komórki tabel, obrazy, prostokąty selekcji, overlaye i caret.

Takiej decyzji często nie widać w demach, ale to ona decyduje o przyszłości systemu.

Jawny kontrakt renderowania ułatwia testowanie, pozwala przenosić część pracy między głównym wątkiem a workerem i zmusza zespół do precyzyjnego opisania każdego feature'a wizualnego. Ma też koszt: nic nie pojawia się samo. Każda funkcja musi zostać wyrażona w scenie renderującej.

To dobry koszt, jeśli system ma rosnąć.

Bez takiej granicy renderer łatwo zaczyna czytać model domenowy bezpośrednio. Potem layout, rendering, selekcja, eksport i optymalizacje zaczynają zależeć od tych samych struktur. Na początku jest szybciej. Później każda zmiana wymaga negocjacji z całym systemem.

RenderScene wymusza odwrotną dynamikę. Najpierw trzeba powiedzieć, co naprawdę ma zostać narysowane. Dopiero potem można zdecydować, gdzie i jak to narysować.

Optymalizacja nie może decydować o poprawności

Wokół kontraktu renderowania Pixxl buduje drugi ważny motyw: asynchroniczne wyprowadzanie stanu ma być optymalizacją, nie warunkiem poprawności.

DerivedRuntimeCoordinator może planować układ, eksport i część pracy renderującej w workerze, ale potrafi też wrócić na main thread. Koordynator anuluje przeterminowane żądania, ignoruje spóźnione odpowiedzi i degraduje się do pracy w głównym wątku, jeśli worker nie uruchomi się albo zawiedzie podczas renderingu czy eksportu.

To brzmi zwyczajnie, ale jest oznaką dojrzałości.

System wydajnościowy nie powinien stać się systemem, od którego zależy poprawność. Worker, offscreen rendering i asynchroniczne pipeline'y są użyteczne tylko wtedy, gdy porażka optymalizacji nie oznacza porażki produktu.

Warto też zauważyć ograniczenie: obecna implementacja nadal renderuje sceny zawierające obrazy na main thread. To dobre przypomnienie, że architektura renderingu jest tu ewolucyjna, a nie domknięta. Projekt zostawia miejsce na dalsze przesuwanie pracy poza główny wątek, ale nie udaje, że wszystko zostało już rozwiązane.

Wspólne UI bez przejmowania produktu

Trzecia kluczowa decyzja dotyczy UI i viewportu.

Współdzielone chrome w @pixxl/canvas/react jest celowo nudne: stabilne sloty, kontrolowany stan, etykiety dostępności i wspólne CSS. Najważniejsze słowo to „kontrolowany”.

CanvasWorkspace nie ukrywa pan/zoom w globalnym singletonie. Przyjmuje transform, emituje onTransformChange, obsługuje boundsy, targety dopasowania, callbacki viewportu i sloty overlayów.

Właśnie dlatego może być naprawdę współdzielony.

Komponent wielokrotnego użytku staje się użyteczny dopiero wtedy, gdy nie próbuje przejąć kontroli nad produktem, który ma hostować. Wspólna powłoka powinna dawać strukturę, a nie narzucać semantykę.

Ta architektura nie myli podobieństwa wizualnego z podobieństwem domenowym. Panel stron może być wspólny dla edytora dokumentów i narzędzia do diagramów. Nie znaczy to, że oba systemy powinny używać jednego modelu dokumentu. Minimapa może być współdzielona. Nie znaczy to, że warstwa canvasowa powinna rozumieć semantykę tabel albo routing krawędzi grafu.

Pixxl konsekwentnie odmawia budowy zbyt sprytnej warstwy wspólnej. To dobra odmowa.

Co ta architektura umożliwia

Dzięki temu po stronie pliku staje się możliwe coś ważnego.

Produktowo neutralny CanvasDocument może zachowywać nieznane albo nieaktywne elementy, a dokument rich text może współistnieć z wykresami, obrazami, konektorami i innymi elementami canvasowymi.

Rozwiązanie jest eleganckie, ale nie magiczne.

Zachowanie obcych elementów działa na granicy pełnego CanvasDocument. Jeśli ktoś wykona konwersję tylko z DocumentStructure z powrotem do nowego dokumentu canvasowego, otrzyma świeży dokument zawierający wyłącznie warstwę rich textową.

Innymi słowy: przenośność i zachowywanie obcych bytów nie wynikają z samego adaptera. Wynikają z wyboru właściwej granicy persystencji.

Największą siłą tej propozycji nie jest więc sam cantext, lecz to, że rich text nie został potraktowany jako wyjątek.

W wielu codebase'ach dokumenty tekstowe kończą jako osobny świat: z własną persystencją, własnym shellem, własnym sposobem mierzenia i renderowania. Tutaj rich text zostaje włączony do rodziny aplikacji canvasowych bez udawania, że różnice domenowe nie istnieją.

Plik pozostaje wspólny. Shell pozostaje wspólny. Runtime wspólny kończy się tam, gdzie zaczyna się semantyka produktu.

To jest teza architektoniczna, nie detal implementacyjny.

Granice obecnego opisu

Nie oznacza to, że materiały opisują system kompletny w każdym sensie.

Są mocne tam, gdzie chodzi o granice modułów, model dokumentu, pipeline renderowania i integrację hosta. Są słabsze tam, gdzie zwykle zaczynają się realne problemy operacyjne: współpraca wieloosobowa, synchronizacja z backendem, kontrola dostępu, konflikty edycji, cykl życia assetów i telemetria produkcyjna.

Nawet w obrębie samego rich textu widać istotną korektę. writing issues mogą wyglądać jak część stanu kanonicznego, ale bezpieczniejszy model jest inny: sugestie review należą do struktury dokumentu, natomiast writing issues funkcjonują jako stan runtime'u.

To nie jest drobiazg. To różnica między tym, co trzeba zapisać i migrować, a tym, co można przeliczyć od nowa.

Mniejszy znak zapytania dotyczy generowanych recept aplikacyjnych: jeśli takie snippety rozjadą się z bieżącymi kontraktami propsów CanvasWorkspace, powinny być traktowane jako przykłady, a nie fundament głównej tezy artykułu.

Te braki nie podważają głównej tezy. Po prostu zawężają ją do warstwy klienta, lokalnej persystencji i integracji runtime'owej.

Lekcja dla builderów

Najlepsza lekcja z Pixxl jest prosta i mało efektowna.

Jeśli produkt ma mieć więcej niż jeden tryb pracy na canvasie, nie zaczynaj od współdzielonych komponentów. Zacznij od czterech pytań:

  • co jest neutralnym formatem pliku
  • co należy do silnika domenowego
  • co może być współdzieloną powłoką bez przejmowania semantyki produktu
  • gdzie kończy się platforma, a zaczyna workflow konkretnej aplikacji

Dopiero po ustaleniu tych granic warto budować generator recept, preview gridy, tool raile i resztę widocznych elementów wielokrotnego użytku.

To podejście jest mniej efektowne niż wizja uniwersalnego edytora wszystkiego. Jest też trwalsze.

Wspólna warstwa powinna utrzymywać kontrakty, które naprawdę muszą być wspólne: plik, migracje, walidację, viewport, historię, rejestry, runtime i powłokę. Silnik produktu powinien zachować odpowiedzialność za semantykę swojej domeny. A aplikacja powinna mieć miejsce na własny workflow bez przepychania go w dół stosu.

Zakończenie

Pixxl jest ciekawy nie dlatego, że obiecuje uniwersalny edytor wszystkiego.

Przeciwnie: jest ciekawy dlatego, że odrzuca ten pomysł i proponuje bardziej trzeźwą architekturę. Wspólny substrat. Osobne silniki. Kontrolowane chrome. Lokalne workflow.

cantext sprawia, że ta decyzja staje się konkretna. Rich text wymusza transakcje, historię, layout, input, dostępność, eksport, rendering i asynchroniczne wyprowadzanie stanu. Jeśli architektura przechodzi taki test, to nie dlatego, że jest efektowna. Przechodzi go dlatego, że pilnuje granic.

W świecie software'u pełnym „platform”, które po dwóch kwartałach stają się monolitami pod nowymi nazwami, taka powściągliwość nie wygląda jak brak ambicji.

Wygląda jak jej dojrzalsza forma.