daffie/canvas_revisions

Revision handling for Drupal Canvas content on the PostgreSQL with JSON entity storage.

Maintainers

Package info

gitlab.com/daffie/canvas_revisions

Issues

Type:drupal-module

pkg:composer/daffie/canvas_revisions

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

1.0.0-beta1 2026-07-22 10:15 UTC

This package is auto-updated.

Last update: 2026-07-22 08:32:34 UTC


README

Adds a Revisions side panel to the Drupal Canvas editor: browse an entity's revision history, see which one the editor is currently showing, and load an older revision back into the editor to restore it.

It is built for the daffie/pgsql JSONB entity storage driver, and depends on both canvas and pgsql. On any other database driver the module installs but does nothing — the storage overrides that make revision listing cheap only exist on pgsql (see Entity types and storage).

  • Requires: Drupal core ^11.3, drupal/canvas ^1.3.2, daffie/pgsql.
  • Configure: Administration › Configuration › Content authoring › Canvas revisions (/admin/config/content/canvas-revisions), permission administer canvas revisions.

Why it is not just a React panel

Canvas's editor is a single-page React app that ships as a ~4 MB prebuilt bundle, and its side rail and panel switch are two hardcoded lists in its source (SideMenu.tsx / PrimaryPanel.tsx) with no registration API. Adding a real React panel would mean patching contrib and rebuilding that bundle on every update.

So the panel is a plain vanilla-JS overlay instead:

  • The rail button is declared as a page extension (canvas_revisions.canvas_extension.yml), which Canvas renders for us.
  • js/panel.js intercepts that button's click and mounts its own panel as a flex sibling of Canvas's primary panel — same slot, same dimensions, same open/close transition — so the two behave as mutually exclusive views of one slot.
  • The panel library is made a dependency of canvas-ui (LibraryHooks::libraryInfoAlter()), which is what gets the script onto every page where the editor boots.

extension/index.html is a fallback page, reached only by deep-linking to /canvas/app/canvas_revisions directly; normally the click never gets that far.

How loading a revision works

Canvas has no concept of "open an arbitrary revision": the editor always renders the default revision overlaid with the draft its auto-save layer holds. So loading a revision does not try to make the editor show something it cannot — it writes the revision into that draft (RevisionListController::load()) and reloads the editor, which then displays it through its normal path. Publishing that draft persists it, which is what restoring an old revision means.

Two problems fall out of that, and most of the module exists to solve them.

1. Telling a "loaded revision" apart from real unsaved work

A draft that is just a revision someone loaded looks identical to a draft that is work someone did — both are the same object in the auto-save store. Left unaddressed, browsing the history would make every page look like it had unpublished changes waiting, and offer them up for publishing.

LoadedRevisionMarker records, per entity, the revision a draft was loaded from and the draft's hash at that moment. A draft that still hashes to what the module wrote is a revision being viewed; anything else — including the moment the user edits it — is their work. That one distinction drives three things:

  • which row the panel highlights as Editing (RevisionListBuilder),
  • whether replacing the draft needs a "you'll lose your changes" warning (js/panel.js),
  • whether Canvas's "Review changes" list counts the draft as a pending change (PendingChangesController, which filters Canvas's own auto-save endpoint).

A marker is only meaningful while its draft exists. Rather than trying to observe every way a draft can end, markers are pruned self-correctingly: LoadedRevisionMarkerPruner drops stale ones on any auto-save write (it listens to Canvas's auto-save cache tag), and RevisionListBuilder clears one the moment it notices the draft is gone. hook_uninstall deletes the rest, since they live in the key-value store which nothing else reaps.

2. The changed timestamp

An old revision carries an old changed time, and unlike the entity keys that is an ordinary field Canvas copies onto the stored entity when the draft is published — at which point core sees an older timestamp than storage and refuses the save as a concurrent edit. RevisionListController::alignChangedTime() gives the drafted revision the stored entity's changed time (per translation, the granularity core validates at), because the draft is based on the entity as it stands now; restoring old content does not move the entity back in time.

Entity types and storage

Listing a revision's summary without materializing a full entity per row is what makes a long history cheap to display. That reads directly out of the JSONB full-history document the pgsql driver already holds, so it only exists on that driver.

  • EntityHooks::entityTypeAlter() points the Canvas-editable entity types — canvas_page and node — at storage handlers whose loadAll() returns a revision-listing history handle. It is a no-op unless the pgsql driver is active, and touches no other entity type.
  • CanvasPageStorage / CanvasNodeStorage are thin subclasses (via RevisionListStorageTrait) of the storage each type would otherwise use — the node one extends pgsql's own NodeStorage so node's extra API is not lost.
  • CanvasRevisionEntity::getRevisionList() builds the summary rows from the history document, falling back to materializing a revision only when it cannot (e.g. an entity class that overrides label()). Usernames are resolved in one batched load.

An entity type without this storage answers the listing endpoint with a 501, which the panel turns into a plain "revisions are not available for this kind of content" message.

Route swaps

Routing\RouteSubscriber wraps — never replaces — two of Canvas's own endpoints; each wrapper takes Canvas's controller as a dependency and is tolerant of it going missing, so an upstream rename degrades to Canvas's own behaviour rather than a broken editor:

  • canvas.boot.empty / canvas.boot.entityEditorBootController, which attaches the open entity's revision history to the editor page that is loading anyway, so the panel's first open needs no request of its own. Both boot routes are swapped because Canvas rewrites every /canvas/* path to /canvas before routing, so the editor is actually served by canvas.boot.empty with no parameters — the open entity survives only in the browser URL, which is why both the controller and the panel read it from the path. The preloaded rows carry their cacheability with them, and the response is varied by url.path so Dynamic Page Cache cannot serve one entity's history for another's.
  • canvas.api.auto-save.getPendingChangesController, the change-list filter described above.

Configuration

SettingDefaultEffect
revision_limit50How many of the newest revisions the panel lists. Listing a revision can cost materializing it, so this bounds the editor-page cost on content with a long history. 0 means no limit.

Layout

canvas_revisions.canvas_extension.yml   Rail button, as a Canvas page extension
js/panel.js                             The panel: mount, list, load, freshness
css/panel.css                           Panel styling
src/Controller/
  EditorBootController.php              Preloads history into the editor page
  RevisionListController.php            The list + load endpoints
  PendingChangesController.php          Hides loaded revisions from the change list
src/Entity/CanvasRevisionEntity.php     Revision-listing history handle (pgsql)
src/EntityStorage/                      Storage handlers that return that handle
src/LoadedRevisionMarker.php            "This draft is a loaded revision" tracking
src/Cache/LoadedRevisionMarkerPruner.php   Prunes stale markers on auto-save writes
src/RevisionListBuilder.php             Builds the rows both paths render
src/Hook/                               entity_type_alter, library_info_alter
src/Routing/RouteSubscriber.php         The two route swaps
src/Form/SettingsForm.php               The settings form

Tests

PHP kernel tests live in tests/src/Kernel/ and cover the storage swap, the listing and its cap, revision-load ownership, the changed-time alignment, the pending-changes filter, marker pruning, and the editor preload (including its cacheability). They run only against the pgsql driver and skip otherwise.

js/panel.js has no test that ships with the module yet — see the review notes in the repository root for the current state of that gap.