RVRite · implementation reference

Figure carousel

A carousel is a <fig-group specific-use="carousel"> wrapping one <fig> per slide. It is a first-class element end to end — its own ProseMirror node, JATS/BITS representation, track-changes identity, relabel configuration and outline behaviour. This is what changed, in each area, and why.

Orientation

At a glance

One carousel holds many figures in a single document position: readers page through slides, and each slide keeps its own label, caption and image. Nothing about a slide is special — it is an ordinary displayimage node that happens to live inside a group.

Three ways in

  • Upload files — pick several files at once in Insert → Figure/Video (Block).
  • Choose figures — turn figures already in the document into a carousel.
  • Add images — the context menu on an existing figure or carousel.

Gating

Every entry point requires add_figures_as_carousel or SA. Without it the modal behaves exactly as before — single file, no tabs, no multi-select.

new permission config/permissions.php

<!-- what a carousel is, in exported HTML -->
<fig-group class="fig-group" specific-use="carousel"
           position="float" orientation="portrait" id="fg1">
  <fig class="figure" id="f1">label + caption + img</fig>
  <fig class="figure" id="f2">label + caption + img</fig>
</fig-group>
<anchor ref-type="figure" ref-id="f1" pos="t!"/>
<anchor ref-type="figure" ref-id="f2" pos="t!"/>

Anchors sit after the whole group, never inside it — the group's content model admits figures only.

Extension

Node & schema

FigGroup.tsx (new, ~1080 lines) defines the figgroup node: group: block, content: "displayimage+", isolating, defining. It carries the same attribute surface as any other tracked block — id, position, orientation, specific-use, the full track-changes set, conversion-status, current-flow-id, comment attrs — plus data-element-name="fig-group".

The tag and the class are not the same thing. In the live editor the node renders through a React NodeView whose DOM root is <div class="fig-group-carousel">. The literal <fig-group> tag only exists in serialized or exported HTML. Several call sites — click handling, the context menu, outline navigation — had to match the class, not the tag.

Registered in two schemas

The client registers it in util/tiptapConfig.js. The collaboration server carries its own mirror of the schema, so an equivalent FigGroup node was added to Hocuspocus_api/tiptap-extensions.js and appended to TiptapExtentions. Without the mirror the server cannot parse or re-serialize the node, and the carousel would not survive Yjs persistence or server-side HTML generation.

Behaviour

Creating a carousel

Insert → Figure/Video (Block)
├─ Upload files
│  ├─ one file       → setdisplayImage            ordinary figure
│  └─ several files  → setFigGroup                new carousel
└─ Choose figures   → convertFiguresToFigGroup   reuse existing figures

Context menu → Add images
├─ on a carousel         → appendFigGroupImages      case A · append slides
└─ on a standalone figure → appendFigGroupImages      case B · wrap it

setFigGroup — from newly uploaded files

UploadModal.saveFigGroupImages uploads each file in sequence with progress, then builds one displayimage per image via the shared buildDisplayImageContent helper (extracted from DisplayImage.tsx so both extensions use one definition of a figure's inner content). The group and every slide receive one shared track id. insertAnchorsForFigGroup then places one anchor per slide after the group.

If the group lands at the end of the document a trailing paragraph is added so the cursor has somewhere to go. That paragraph is editor scaffolding, not authored content, so it is dispatched as its own transaction tagged trackManualChanged — otherwise it would appear in the track-changes panel as a spurious "paragraph added".

convertFiguresToFigGroup — from figures already in the document

The Choose figures tab (InternalFigures.jsx) reads standalone figures straight out of the ProseMirror document — skipping fig-groups whole, since their children are already slides, and skipping figures pending deletion. Each card shows the thumbnail, label and caption; a search box filters on label and caption; an Insert carousel at dropdown chooses which figure's position the carousel takes.

The command moves each picked figure whole — its own label, caption and image node untouched — into a group at the target's position, and removes the standalone originals. Slides are ordered by document position, not by the order they were ticked. The dropdown offers only checked figures, because the carousel replaces one of the figures being merged; a position outside the selection would leave a gap. At least two figures and a target are required before Insert is enabled.

appendFigGroupImages — Add images

Case A appends new slides into an existing group. Case B wraps a standalone figure into a new group using findWrapping and tr.wrap — deliberately not replaceWith, which reads to the track-changes engine as "delete the old figure, insert a new subtree" and would flag the reused figure as deleted even though it was only wrapped.

Behaviour

Deleting

removeFigGroup accepts either the group's own id or the imgId of any one slide, so "Remove figure group" can be triggered from a single slide's context menu without the caller resolving the parent. It collects every slide's imgId and the positions of their anchors into extension storage, deletes the group, and sets figDelete meta.

Four ProseMirror plugins

PluginJob
fig-group-deletion-handler On Delete/Backspace over a selected group, records slide ids and anchor positions so the cleanup pass below can run.
fig-group-click-select Clicking the carousel selects the whole node — but ignores clicks on button or .figure, so arrows, thumbnails and slide content keep working. Selecting the node forces a NodeView re-render that would otherwise stomp the view's own active-slide state.
remove-anchor-on-fig-group-delete Removes the deleted slides' anchors. Runs only when track changes is off, and bails out during Yjs undo/redo so an undo never strips anchors.
remove-empty-fig-group Deletes any group left with no slides — the content model requires at least one.

Deleting a single slide

DisplayImage.tsx's remove path now checks whether the figure's parent is a group with only one child left; if so it removes the group rather than leaving an empty shell. Selection restoration is clamped to the document size and wrapped, since the position it wants may no longer exist after the delete.

Context menu & undo

Tiptap.tsx adds Add images (on a figure or a carousel), Remove figure group (on a slide inside one) and Delete carousel (on the group). Each wraps the action in a getUndoStackLength measurement and trims the surplus with removeLastChangeFromUndoStack, so one user action is one undo step rather than several.

Behaviour

Track changes

figgroup was added to blockTrackNodeTypes and to the many node-type lists throughout TrackChangeExtension.tsx that decide what counts as a tracked block, what may own a track id, and what participates in accept/reject. On the server, trackChanges.js maps figgroup → fig-group in BLOCK_TYPE_MAP so change descriptions name it correctly. The panel (TrackChanges.jsx) renders it as Figure group added / Figure group deleted.

One shared track id. The wrapper and every slide carry the same data-track-id and the same insert attributes. A carousel is therefore one accept/reject unit, matching how a multi-image upload is one action. The duplicate track-id sweep had to learn an exception: nodes whose parent is a figgroup are skipped, or the sweep would re-id the slides and split the entry apart.

Carousel transactions set blockMark: true, which tells the engine not to apply inline <insert>/<delete> marks inside the content — the nodes' own attributes carry the change instead, so caption text does not get independently marked.

Converting figures under track changes

This is the one place where tracking changes the mechanics rather than just the styling. The engine re-adds whatever a transaction removes and marks it deleted, so the original figures survive the conversion as delete-marked copies while the carousel is inserted alongside them: deleted here, inserted there, which is how a move should read.

Track changes offTrack changes on
Original figures Removed Kept, marked deleted
Carousel slides No marking Marked inserted, shared track id
Slide imgId Preserved, so existing <xref> citations still resolve after accept
Anchors Moved onto the group, ids preserved Left in place — one anchor per figure
Why anchors are left alone when tracking is on. An <anchor> has no tracking attributes at all — they are commented out in AnchorTag.tsx, so the schema drops them and an anchor can never render as deleted. Every anchor in the document is a live one. Adding a copy for the carousel while the delete-marked original kept its own therefore produced two visible anchor chips for the same figure, both forced visible on the matching slide. Reusing the existing anchor keeps it to exactly one, and it survives accept and reject intact.

Two robustness fixes came out of this work: TrackChangeFilter.tsx now includes figgroup in the anchor management pass for delete-marked blocks, and setNodeAttributeFun gained an upper-bound guard — a stale position past the document size made nodeAt() throw a RangeError rather than return null.

Numbering

Relabel

Slides are ordinary figures, so by default they number independently: a three-slide carousel reads Figure 2, Figure 3, Figure 4. That is deliberate — nothing about being in a carousel implies shared numbering.

Lettered runs are opt-in. A carousel becomes a single lettered unit — Figure 2a, 2b, 2c — only once a slide is pinned through Label settings with Include part label checked. From that moment every sibling slide joins the same unit regardless of the adjacency heuristics, because being in the same carousel is itself the grouping signal: the whole carousel follows the one slide the user edited.

In relabel_processor.js this is implemented by walking each label's ancestors to record the owning figGroupId, collecting the set of group ids that have a lettered pin, and merging consecutive items of the same group into one unit. getOwnerId also reads imgId now, since that is what identifies a figure. A figgroup bucket exists in the active-label map and stays permanently empty — slide labels are owned by their fig ancestor and collected under displayimage — but the processing loop reads the bucket for every enabled element type, so it has to be present.

Admin configuration

fig-group is now a configurable element in its own right: config/relabelsettings.php lists it among reference types, element keys and relabel elements; RelabelSettings.jsx shows it as "Figure group" with the float-anchor fieldset and an anchor-type mapping; and RelabelSettingsSeeder.php seeds a row with renum_enabled: 1, id_prefix: "fg", ref_type: "fig-group" and relabel_enable: 0. The seeder comment is explicit that the row exists to make the container renumberable and admin-configurable, not to add a second visible running label for the group.

Navigation

Outline & navigation

Clicking a figure in the outline used to scroll its element into view. A slide inside a carousel is hidden unless it is the active one, so scrolling to it would land on whatever slide happened to be showing.

Both the outline (OutlineAccordionItem.jsx) and the editor's own scroll-to handler (Tiptap.tsx) now detect a .fig-group-carousel ancestor, compute the target's index among the slides in .carousel-track, dispatch a set-carousel-index CustomEvent on the wrapper, and scroll the wrapper into view. FigGroupView listens for that event and moves to the slide. Citation links to a figure inside a carousel therefore land on the right slide.

UI

Rendering

FigGroupView.jsx renders the wrapper, a .carousel-track NodeViewContent holding the slides, and a footer with an "Image n of m" counter, previous/next arrows, a thumbnail strip and a progress bar. Thumbnails come from each slide's customdispimg src, and carry the slide's own insert/delete state so a pending change is visible in the strip.

Slide visibility is pure CSS. NodeViewContent's children belong to ProseMirror and are periodically re-synced by the Yjs layer, so any attribute or inline style set imperatively on a slide gets silently wiped on the next tick. Visibility is therefore driven entirely from data-active-index on the wrapper — which React owns and re-renders declaratively — through nth-of-type rules generated for indices 0–49 in custom.scss, with figSlideFromRight / figSlideFromLeft supplying direction-aware motion.

The same stylesheet hides every anchor in or following a carousel by default and reveals only the active slide's — both the nested case and the sibling case, the latter via a generated + * selector chain. extensions/css/style.scss extends the existing delete/insert rules for pagination controls and caption selection to cover fig-group, and UploadModal.scss gains the tab strip (shaped to match the document/mini template tabs), the multi-image preview grid and the internal-figure card grid.

XML · XSLT

XML & XSLT round trip

JATS and BITS both have a native <fig-group>, so the carousel maps onto the standard rather than inventing markup. Before this work the wrapper was lost in both directions: no stylesheet had a template for it on export, so XSLT's built-in rule dropped it and left the slides as sibling figures; and on import xml2html.xslt listed fig-group among the elements it deliberately unwraps.

editor HTML with fig-group.fig-group
export html2jats / html2bits
emit fig-group
export *-sec2final
strip attrs, assign id
zip XML with specific-use="carousel"
upload the same zipped XML
import xml2html
rebuild fig-group
import ApiController
anchors after the group
editor carousel restored
StageChange
export
html2jats.xslt ×4,
html2bits.xslt
A fig-group template that copies attributes and recurses, mirroring the existing fig template. Applied to all five variants — jats/default, sphere, aipcp, bjs and bits/pcori — so the folder resolution order cannot miss it.
export
jats-sec2final.xslt ×4,
bits-sec2final.xslt
Strips the editor's class, data-render and track-change attributes down to the legal id, position, orientation, specific-use. JATS generates fg1, fg2 for the same reason fig gets f1: the editor's own id is a bare uuid, invalid as an XML ID when it starts with a digit. BITS/pcori preserves the source id, matching its local convention.
import
xml2html.xslt
A template matching fig-group[@specific-use='carousel'] rebuilds <fig-group class="fig-group" data-element-name="fig-group">, which is what the node's parseHTML matches. Scoped to the carousel case so any other fig-group in third-party XML keeps being unwrapped exactly as before.
import
ApiController.php
add_anchors_for_block_elements placed each figure's anchor as its next sibling, which for a slide meant inside the group — where the node's displayimage+ content model cannot hold it, so it was dropped on parse. Slide anchors now go after the whole group, walking past the anchors already placed for earlier slides to keep them in slide order.
both
XMLService.php
fig-group ⇒ [id, position, orientation, specific-use] added to both attribute whitelists, so the sanitiser keeps them instead of stripping every attribute off an unknown element.
Verified against the real pipelines. Running a two-carousel document (one at body start, one inside a section, two and three slides, plus a standalone figure) through html2xml.sh and xml2html.sh produces valid JATS 1.3d2 — DTD-checked — and restores both carousels with the right slide counts and no figures left loose. xml2docx and bits2html have no catch-all template, so the new element falls through to the built-in rule and DOCX/PDF export is unaffected.

Reference

File manifest

New files

PathContents
RVRite_ui/src/extensions/FigGroup.tsxThe node, four commands, four plugins, NodeView binding
RVRite_ui/src/components/FigGroupView.jsxReact NodeView — track, counter, arrows, thumbnails
RVRite_ui/src/components/FigGroupView.scssCarousel chrome
…/UploadModal/FigUpload/InternalFigures.jsxChoose figures picker and collectDocumentFigures

Modified

AreaFiles
EditorTiptap.tsx · DisplayImage.tsx · BoxedText.tsx · tiptapConfig.js
Track changesTrackChangeExtension.tsx · TrackChangeFilter.tsx · TrackChanges.jsx · RVRite_server/computation/trackChanges.js
CollaborationHocuspocus_api/tiptap-extensions.js
RelabelRVRite_server/relabel_processor.js · RelabelSettings.jsx · config/relabelsettings.php · RelabelSettingsSeeder.php
Upload UIUploadModal.jsx · UploadModal.scss · FigUpload.jsx · en.locale.json
OutlineOutlineAccordionItem.jsx
Stylesstyles/custom.scss · extensions/css/style.scss
BackendApiController.php · XMLService.php · config/permissions.php
XSLThtml2jats.xslt ×4 · html2bits.xslt · jats-sec2final.xslt ×4 · bits-sec2final.xslt · xml2html.xslt
Also on this branch, unrelated to the carousel: supervisord.conf (bringing clamd under supervisor so uploads stop failing virus validation after a container restart), Hocuspocus_api/logger.js, server-mysql.js and RVRite_server/logger.js.

Reference

Edges & open items

  • Transient duplicate ids. While a tracked conversion is pending, a delete-marked original and its carousel slide share an imgId. This is the deliberate trade: fresh ids would break any <xref> citation pointing at the figure, which is worse.
  • Anchor positions under tracking. Because anchors cannot be marked deleted, a tracked conversion leaves them at the original figures' positions rather than moving them onto the carousel, so PDF float placement for the pending change reflects where the figures used to be. Repositioning them cleanly would mean un-commenting the tracking attributes on the anchor node.
  • Panel granularity. The wrapper and slides share one track id, so a carousel insert is a single panel entry while three converted figures produce three delete entries. Giving each slide its own id would split the insert into one entry per slide.
  • The sphere export variant emits an empty body for synthetic test input, both before and after these changes — a pre-existing mismatch in that stylesheet's expected data-id conventions, not a regression.
  • Choose figures is scoped to the block insert, not the Add images flow. Appending existing document figures to an existing carousel is not wired up.
  • Tab naming is unsettled. "Upload files" / "Choose figures" reads oddly, since the second tab uploads nothing.