Syncing a Format Toolbar Across Four Vite Entry Points
TLDR: Four entry HTMLs share one formatToolbar.js module. Zoom is a CSS custom property. Distribute finds the nearest common ancestor and applies flex. :has([data-page]) handles page-card styling with zero JS.
Repo: tools/pdf-processor
The Problem
The PDF processor has four entry points: the main uploader, the Monaco editor, the visual diff view, and the multi-document compare view. Each one needs the same format toolbar: text alignment, distribute layout, and PDF zoom.
Copy-pasting the toolbar HTML and JS four times means four diverging implementations. There is no SPA router, no shared bundle. Each entry HTML is its own Vite entry point.
Text Alignment
Four buttons: justifyLeft, justifyCenter, justifyRight, justifyFull.
Each calls applyAlignment(mode) on the currently active extraction output element. The mode maps directly to text-align CSS.
function applyAlignment(mode) {
const el = state.activeElement;
if (!el) return;
const value = mode === 'justifyFull' ? 'justify'
: mode.replace('justify', '').toLowerCase();
el.style.textAlign = value;
}
This is the straightforward case. One element, one property, no state machine.
Distribute
Distribute is the interesting control. Select 2+ block-level children, click "distribute row" or "distribute column," and they arrange into a flex container with even spacing.
The tricky part: those children can live inside different parent elements. The toolbar does not know the DOM structure upfront.
The solution is finding the nearest common ancestor of all selected elements, then applying flex to it.
function distribute(direction) {
const selected = Array.from(document.querySelectorAll('[data-selected]'));
if (selected.length < 2) return;
const parent = nearestCommonAncestor(selected);
if (!parent) return;
const isActive = parent.dataset.distributed === direction; if (isActive) { parent.style.display = ''; delete parent.dataset.distributed; return; }
parent.style.display = 'flex'; parent.style.flexDirection = direction === 'row' ? 'row' : 'column'; parent.style.justifyContent = direction === 'row' ? 'space-between' : 'space-evenly'; parent.style.gap = direction === 'column' ? 'var(--distribute-gap, 1rem)' : ''; parent.dataset.distributed = direction; }
Calling distribute again on the same parent removes the flex layout. Toggle on repeat.
PDF Zoom
The PDF canvas panel uses a CSS zoom property applied to its scroll container.
function setZoom(v) {
zoom.value = Math.max(0.5, Math.min(3.0, v));
pdfPanel.style.setProperty('--pdf-zoom', zoom.value);
}
.pdf-panel { zoom: var(--pdf-zoom, 1); }
This is deliberately not transform: scale. CSS zoom affects layout geometry and scroll coordinates. The visual-diff scroll-sync uses IntersectionObserver with root: pane, which tracks element visibility in scroll space. Under zoom, the observer's frame of reference stays correct. Under transform: scale, the scroll geometry is unchanged but element visual bounds shift, breaking the intersection math.
Note: zoom does not work in Firefox. The Firefox path falls back to a transform: scale wrapper with known scroll-sync degradation. This is a documented limitation.
The :has() Page-Card Pattern
After zoom landed, pages needed visible card boundaries. Previously they floated in undifferentiated whitespace.
The card styling needed to be zero-JS: no class toggling, no IntersectionObserver, no scroll listener. CSS :has() handles it.
.pdf-scroll-container:has([data-page]) {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 24px;
}
[data-page] { background: white; border-radius: 4px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); overflow: hidden; }
When the scroll container gains [data-page] children (after a PDF loads), the container automatically switches to centered flex column with gap. The overflow: hidden clips PDF canvas content that slightly overruns the page boundary on some documents.
The :has() selector requires Safari 15.4 and later. In older browsers, the layout degrades gracefully to normal block flow without shadows. Not a regression, just less polish.
Responsive Breakpoints
/ visual-diff: stack panels vertically on tablets /
@media (max-width: 1024px) {
.diff-pane-container { flex-direction: column; }
}
/ header: allow wrapping on phones / @media (max-width: 768px) { .app-header { flex-wrap: wrap; height: auto; } }
/ toolbar: hide non-essential items on small phones / @media (max-width: 480px) { [data-toolbar-optional] { display: none; } }
Items marked data-toolbar-optional (zoom controls, distribute buttons) vanish below 480px. Alignment buttons stay because they affect the export output, not just display.
Wiring Four Entry Points
formatToolbar.js exports a single initFormatToolbar(root) call. Each entry HTML imports it:
<!-- editor/index.html -->
<script type="module">
import '../src/ui/formatToolbar.js';
import '../src/app.js';
</script>
The module attaches toolbar listeners to [data-toolbar-action] buttons within root. It reads state from src/state.js, which is a singleton shared by the toolbar module and the main app.
The four entry points share state by sharing the module singleton. No postMessage, no global window variables. This works because Vite deduplicates module imports within a single entry bundle. Each entry HTML gets its own bundle, but within that bundle state.js is instantiated once.
Tradeoffs
Distribute creates flat flex containers. If selected elements already have nested flex structure, the distribute wrapper may double-flex the layout. There is no undo for distribute in the current implementation.
zoom vs transform: scale is a real browser split. Firefox has never shipped zoom as a layout-affecting property. Any scroll-sync logic must account for both modes if Firefox support matters.
Module singleton state requires care. Two toolbar instances on the same page would share the same state.activeElement. This is fine for the current single-toolbar layout but would need a refactor for multi-panel toolbars.