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".
<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
| Plugin | Job |
|---|---|
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.
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 off | Track 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 |
<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.
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.
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.
fig-group.fig-group
emit
fig-group
strip attrs, assign id
specific-use="carousel"
rebuild
fig-group
anchors after the group
| Stage | Change |
|---|---|
exporthtml2jats.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. |
exportjats-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. |
importxml2html.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. |
importApiController.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. |
bothXMLService.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. |
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
| Path | Contents |
|---|---|
| RVRite_ui/src/extensions/FigGroup.tsx | The node, four commands, four plugins, NodeView binding |
| RVRite_ui/src/components/FigGroupView.jsx | React NodeView — track, counter, arrows, thumbnails |
| RVRite_ui/src/components/FigGroupView.scss | Carousel chrome |
| …/UploadModal/FigUpload/InternalFigures.jsx | Choose figures picker and collectDocumentFigures |
Modified
| Area | Files |
|---|---|
| Editor | Tiptap.tsx · DisplayImage.tsx · BoxedText.tsx · tiptapConfig.js |
| Track changes | TrackChangeExtension.tsx · TrackChangeFilter.tsx · TrackChanges.jsx · RVRite_server/computation/trackChanges.js |
| Collaboration | Hocuspocus_api/tiptap-extensions.js |
| Relabel | RVRite_server/relabel_processor.js · RelabelSettings.jsx · config/relabelsettings.php · RelabelSettingsSeeder.php |
| Upload UI | UploadModal.jsx · UploadModal.scss · FigUpload.jsx · en.locale.json |
| Outline | OutlineAccordionItem.jsx |
| Styles | styles/custom.scss · extensions/css/style.scss |
| Backend | ApiController.php · XMLService.php · config/permissions.php |
| XSLT | html2jats.xslt ×4 · html2bits.xslt · jats-sec2final.xslt ×4 · bits-sec2final.xslt · xml2html.xslt |
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
anchornode. - 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
sphereexport variant emits an empty body for synthetic test input, both before and after these changes — a pre-existing mismatch in that stylesheet's expecteddata-idconventions, 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.