Skip to content

Report Packs Internals 1 The Designer

Ed Mozley edited this page Oct 2, 2026 · 1 revision

Report Packs internals, part 1: the designer

Series: 1. The designer Β· 2. The layout engine Β· 3. PHP and SQL Β· overview: Report Packs developer guide Β· for users: Report Packs

This part covers the HTML, CSS and JavaScript behind Reporting β†’ Report Packs β†’ a pack: the Office-style editor with a ribbon, a searchable toolbox, real pages with rulers, drag and drop onto a 12-column grid, resize handles, text edited in place, and undo. It explains how the pieces are built, and why it feels fluid rather than like a form that redraws itself.

The one-sentence version: the designer never decides where anything goes. It holds a JSON design, asks the layout engine (part 2) for pages, draws them, and lays a thin layer of HTML on top to catch the mouse. Every interaction is either a cheap change to that JSON followed by one redraw, or a purely visual preview that commits only when the mouse is released.


Contents

  1. The page is a shell
  2. The frame: CSS that makes it an application
  3. Building the DOM without HTML strings
  4. State: one object, one way to change it
  5. Why it feels fluid
  6. The canvas: SVG pages under an HTML overlay
  7. Drag and drop
  8. Resizing on the column grid
  9. The ribbon
  10. Editing text in place
  11. The toolbox and its live previews
  12. The properties pane writes itself
  13. Clipboard, keyboard and the context menu
  14. Saving, conflicts and export
  15. Accessibility
  16. Testing hooks
  17. Traps

1. The page is a shell

reporting/packs/designer.php is 64 lines. It checks module access, prints the standard header, an empty <div id="rpApp" data-pack="N"> with a Loading... line, two globals, and the scripts:

<script>
    window.RP_API  = '../../api/reporting/packs/';
    window.RP_LOGO = <?php echo json_encode(brandingLogoUrl()); ?>;
</script>
<script src="../../assets/js/vendor/jspdf.umd.min.js"></script>
<script src="../../assets/js/chart.min.js"></script>
<script src="../../assets/js/report-packs/engine.js"></script>      <!-- window.RPEngine   -->
<script src="../../assets/js/report-packs/charts.js"></script>      <!-- window.RPCharts   -->
<script src="../../assets/js/report-packs/render-svg.js"></script>  <!-- window.RPRenderSvg -->
<script src="../../assets/js/report-packs/render-pdf.js"></script>  <!-- window.RPRenderPdf -->
<script src="../../assets/js/report-packs/runtime.js"></script>     <!-- window.RPRuntime  -->
<script src="../../assets/js/report-packs/editor.js"></script>      <!-- window.RPEditor   -->
<script src="../../assets/js/report-packs/designer.js"></script>    <!-- builds the app    -->

Each file is an IIFE that publishes one object on window, and the load order is the dependency order. There is no bundler and no framework. designer.js builds the title bar, ribbon, toolbox, canvas, properties pane and status bar itself, from configuration functions (homeGroups(), insertGroups()...) rather than from markup in the PHP.

Whether you may edit is not decided here. The page renders read-only for a viewer as a courtesy. The server refuses a save from anybody below Edit regardless (part 3).

Boot

async function boot() {
    const [p, c] = await Promise.all([api('get.php?id=' + packId), api('catalogue.php')]);
    // ...S.pack, S.role, S.design, S.catalogue, S.presets, S.companies...
    rt.design = S.design; rt.name = S.name;
    rt.onChange = scheduleRender;
    build();               // the whole UI, empty pages
    await rt.loadLogo();   // rasterised once (part 2)
    await rt.loadContext(); // header field values for the period
    scheduleRender();
    loadData();            // every data block, 4 at a time; each arrival re-renders
}

The pack and the catalogue arrive in parallel. The UI is built and drawn before any block data exists: every data block draws as a dashed Loading... placeholder at its real size (part 2), so the page appears at once and fills in as each block lands. Nothing jumps, because a placeholder takes the block's chart height.


2. The frame: CSS that makes it an application

assets/css/report-packs-designer.css turns the page into a fixed application frame:

body.rp-designer-body { height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
.rp-app   { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.rp-main  { flex: 1; min-height: 0; display: grid; grid-template-columns: 270px 1fr 300px; }
.rp-main.is-readonly { grid-template-columns: 1fr 300px; }        /* no toolbox for a viewer */
.rp-toolbox, .rp-props { overflow-y: auto; min-height: 0; }
.rp-canvas { flex: 1; overflow: auto; }
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ header (FreeITSM) ─────────────────────────────┐
β”œβ”€ .rp-titlebar  ← name Β· role Β· Saved/Unsaved Β· undo redo Β· Save Β· Export ───
β”œβ”€ .rp-ribbon    ← tabs + the active tab's groups ──────────────────────────────
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ .rp-toolboxβ”‚ .rp-canvas-wrap                                β”‚ .rp-props     β”‚
β”‚  (scrolls) β”‚  .rp-ruler-h (22px, scrolls with the canvas)   β”‚  (scrolls)    β”‚
β”‚            β”‚  .rp-canvas  (scrolls) β†’ .rp-pages β†’ .rp-page  β”‚               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
└─ .rp-statusbar ← pages Β· blocks Β· period Β· warnings Β· zoom β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Three things make this behave like a desktop application:

  • The page never scrolls; panes do. body is exactly the viewport with overflow: hidden, and each pane scrolls independently. The min-height: 0 on every flex and grid child in the chain is what lets a child shrink below its content and take a scrollbar. Without it, the canvas would push the status bar off the screen.
  • Sticky pane headings. .rp-pane-head { position: sticky; top: 0 } keeps Toolbox and Properties in place while their lists scroll.
  • Theme tokens throughout, except on the paper. The frame uses var(--surface), var(--border) and so on, so dark mode works. The .rp-page itself is hard-coded #fff, because it is paper and the PDF is white.

The accent is Reporting's rust-orange (--accent: var(--rep-accent, #ca5010)), and selection, guides, handles and pressed ribbon buttons all use it.

Below 860px wide, the toolbox and properties panes are hidden and the canvas takes the width. The designer is a desktop tool; a phone can open a pack and export it.


3. Building the DOM without HTML strings

Everything is built with one 14-line helper:

function h(tag, attrs, ...kids) {
    const e = document.createElement(tag);
    for (const k in attrs || {}) {
        const v = attrs[k];
        if (v === undefined || v === null || v === false) continue;
        if (k === 'class') e.className = v;
        else if (k === 'text') e.textContent = v;
        else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
        else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
        else e.setAttribute(k, v === true ? '' : v);
    }
    kids.flat().forEach(c => { if (c !== null && c !== undefined && c !== false) e.append(c.nodeType ? c : document.createTextNode(String(c))); });
    return e;
}
  • No innerHTML anywhere in the designer. A pack's names, titles and data reach the page through text: or text nodes. That is what makes the file safe to feed with user content, not a sanitiser.
  • Falsy attributes are skipped, so disabled: ro ? true : null reads naturally, and canEdit() ? button : null children simply disappear (kids.flat() also lets an array of children be passed).
  • Icons are path data, not files: a 50-entry ICON map of 24Γ—24 stroke paths, turned into SVG by icon(name, size). No requests, they inherit currentColor, and they theme for free.

The only HTML strings in the whole designer are the ones editor.js builds for contentEditable. That is section 10, and every piece of text in them is escaped.


4. State: one object, one way to change it

const S = {
    pack: null, role: 'view', design: null, name: '', desc: '', updated: null,
    catalogue: null, presets: [], companies: [], multiCompany: false,
    sel: null,                 // the selected block's id
    zoom: 1, layout: null, dirty: false, saving: false,
    undo: [], redo: [],
    tab: 'home', query: '',
    editing: null,             // {kind: 'block'|'header'|'footer'|'cover'|'heading', id}
    drag: null,
};

S.design is the single source of truth. It is the same JSON document the server stores (part 3). Nothing else holds layout state: the pages on screen are derived from it every time.

commit() β€” the only way to change the design

function commit(fn, opts) {
    if (!canEdit() && !(opts && opts.viewer)) return;
    opts = opts || {};
    const before = JSON.stringify(S.design);
    const crBefore = JSON.stringify(S.design.criteria);
    fn(S.design);                                          // mutate in place
    if (JSON.stringify(S.design) === before) return;      // no-op: no undo step, no redraw
    if (JSON.stringify(S.design.criteria) !== crBefore) { // new period or company
        opts.data = true;
        rt.loadContext().then(() => { scheduleRender(); refreshRibbonState(); });
    }
    const last = S.undo[S.undo.length - 1];
    if (!(opts.merge && last && last.key === opts.merge && Date.now() - last.at < 1200)) {
        S.undo.push({ snap: before, sel: S.sel, key: opts.merge || null, at: Date.now() });
        if (S.undo.length > 150) S.undo.shift();
    } else last.at = Date.now();
    S.redo = [];
    if (!opts.viewer) setDirty(true);
    rt.design = S.design;
    if (opts.data) loadData();
    scheduleRender();
}

What this buys:

Behaviour How
Undo and redo of anything A whole-document JSON snapshot before each change. Designs are small (kilobytes), so a snapshot is cheaper and simpler than a command log, and it can never get out of step with the document. 150 steps.
Typing is one undo step, not forty opts.merge is a key like 'title-b3k9x2'. Changes with the same key within 1.2 seconds of each other coalesce into the step already on the stack. Theme colour pickers, the title field and heading text all use it.
A click that changes nothing costs nothing The before/after comparison drops no-op changes: no undo step, no Unsaved, no redraw.
Changing the period refetches exactly what it must A change to criteria reloads the header fields and every data block, because the cache key includes the criteria (section 5). Moving a block does not.
A viewer can explore without "unsaved changes" On the Data tab, a view-only user can change the period. {viewer: true} lets the commit through without marking the pack dirty, and the server would refuse the save anyway.
The selection comes back with undo Each step records S.sel. Undo restores it if that block still exists.

setDirty() drives the Saved / Unsaved / Saving... label, and a beforeunload handler asks before leaving with unsaved work.


5. Why it feels fluid

No single trick does it. These choices together keep every interaction under a frame or two.

5.1 Every redraw is coalesced to one per frame

let renderQueued = false;
function scheduleRender() {
    if (renderQueued) return;
    renderQueued = true;
    requestAnimationFrame(() => { renderQueued = false; render(); });
}

Nothing calls render() directly. Things like a commit, a data block arriving, the context loading or a zoom step all schedule a render. However many happen in one frame, the browser paints once, and four data blocks arriving together cost one layout, not four.

5.2 Rendering never touches the network

render() calls rt.layout(), which is RPEngine.paginate(design, dataMap, ctx), a pure function of data already in memory (part 2). Moving, resizing, retyping or rethemeing a block re-runs layout on cached data. The data cache is keyed by what decides the data, not by the block:

function keyFor(b) { return JSON.stringify([b.handler, b.opts || {}, rt.design.criteria]); }

So moving a chart, resizing it, changing its title, or undoing any of those never refetches. Changing Group by refetches only that block. Two blocks with identical settings even share one fetch.

5.3 Text measurement is cached per character

Layout measures every word (part 2), but each character's width in each font and style is looked up in jsPDF once, then cached in a Map. After the first render, measuring is additions.

5.4 Chart pictures are cached, and their resolution is snapped

Charts are the expensive part: Chart.js drawing to a canvas, then toDataURL. rt.chartImage() caches by everything that changes the picture: block id, size in mm, scale, theme, data and legend. The on-screen scale is snapped to half steps and capped at 3:

const chartScale = Math.min(3, Math.round(S.zoom * (window.devicePixelRatio || 1) * 2) / 2 || 1);

Zooming from 100% to 110% to 120% therefore reuses pictures instead of redrawing every chart at every step. The cache is cleared once it passes 200 entries.

5.5 During a drag or resize, nothing is re-rendered

A drag moves a ghost (position: fixed) and repositions one guide element. A resize sets the frame's style.width / style.height directly and shows a size tip. The design is only committed on pointerup, which triggers one real render. Dragging is therefore as smooth as moving a div, because that is all it is.

5.6 Never redraw under the cursor

function render() {
    if (S.editing) { refreshRibbonState(); return; }   // never redraw under the cursor
    ...

While text is being edited in place, a data block arriving or the context loading would otherwise rebuild the page and destroy the contentEditable box mid-word. Renders are skipped while editing; the edit's own close commits and renders.

5.7 The scroll position survives every redraw

render() replaces the whole page stack (replaceChildren), which would reset the scroll position. It reads scrollTop first and writes it back after, so a redraw is invisible.

5.8 Clicks stay clicks

A drag only begins after the pointer has travelled 5px (Math.hypot(...) < 5). A click selects; a slightly shaky click doesn't start a drag and move the block.

5.9 Data loads in the background, four at a time

runtime.js queues block fetches with at most four in flight, so a 30-block pack doesn't open 30 connections at once. Each arrival re-renders (coalesced, see 5.1), so the pages fill in progressively. A block that fails shows its error in place (You do not have access to Contracts); the rest of the pack is unaffected.

5.10 Small things

  • Selecting a block scrolls it into view smoothly, but only after the render that draws it: two nested requestAnimationFrames, then scrollIntoView({block: 'nearest', behavior: 'smooth'}). nearest means it doesn't scroll at all if the block is already visible.
  • Ctrl+wheel zooms, in 10% steps between 40% and 200%. The listener is registered {passive: false} so it can preventDefault the browser's own zoom. Fit computes the zoom that fits the page to the canvas width. The zoom is remembered in localStorage.
  • The toolbox preview waits 280ms before showing, so sweeping the mouse down the list doesn't flash a dozen popovers.
  • Motion is minimal and optional: one 140ms pop-in for previews and a 200ms toast fade, both switched off under prefers-reduced-motion.

6. The canvas: SVG pages under an HTML overlay

Each page on screen is two layers:

<div class="rp-page" data-page="2" style="width: 793px; height: 1122px">
    <svg viewBox="0 0 210 297">...the page, drawn in millimetres...</svg>
    <div class="rp-overlay">
        <div class="rp-margin-guide"></div>
        <div class="rp-region rp-region-header"></div>   <!-- double-click to edit the header -->
        <div class="rp-region rp-region-footer"></div>
        <div class="rp-frame" data-id="b3k9x2" style="left:60px; top:220px; ...">
            <div class="rp-frame-tag">β Ώ Tickets by status</div>
            <div class="rp-handle rp-handle-e"></div> ...
        </div>
    </div>
    <div class="rp-page-tag">Page 3</div>
    <canvas class="rp-ruler-v"></canvas>
</div>
  • The SVG is the page as it will print. It is drawn by render-svg.js from the engine's primitives, with a viewBox in millimetres. Zoom is only the SVG's width/height in px: mm Γ— 96/25.4 Γ— zoom.
  • The overlay is for the mouse. Each block on the page gets a transparent .rp-frame at the block's exact position (it.x * k px, where k = PX_PER_MM * zoom). Hovering tints its border, and selecting it draws the accent border, the name tag and the resize handles. Header, footer and cover get .rp-region hit areas, so double-clicking anywhere in them opens the editor.

Keeping the two apart is what keeps the SVG faithful to the PDF. Nothing interactive is ever drawn into the page itself. Hit-testing, focus and cursors all live on plain HTML elements, which are far easier to style and to make accessible than SVG.

A block that runs onto several pages (a long table) has a frame on each page. The continuation frames carry a continued label and tabindex="-1": only the first part is selectable, has handles and can be dragged.

The rulers

The horizontal ruler is a <canvas>, redrawn on every render because it depends on the page width and zoom:

  • drawn at devicePixelRatio resolution and scaled back with CSS, so it is crisp on a high-DPI screen
  • the margins shaded, centimetres measured from the left margin as a word processor's ruler is, minor ticks dropped below 200% zoom
  • the 12-column grid drawn as a band along the bottom edge, so you can see what a resize will snap to
  • paddingRight set to the canvas's scrollbar width, so the ruler's centre line matches the page's. The canvas has a scrollbar and the ruler doesn't.
  • its scrollLeft follows the canvas's on every scroll event

Each page also carries its own vertical ruler canvas, positioned 26px to the left of the page.


7. Drag and drop

Both kinds of drag (a toolbox item onto a page, and a block to a new place) share one implementation: startDrag(e, {tool}) or startDrag(e, {move: id}).

Pointer events, not HTML5 drag and drop

HTML5 DnD gives a translucent browser screenshot as the drag image, fires dragover too coarsely for a live guide, and behaves differently with touch. Pointer events give full control:

const move = (ev) => {
    if (!started) {
        if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return;   // still a click
        started = true;
        // build a ghost (icon + name), a guide, mark the source frame "is-moving"
    }
    ghost.style.left = (ev.clientX + 12) + 'px';
    ghost.style.top = (ev.clientY + 8) + 'px';
    autoScroll(ev.clientY);
    target = dropTarget(ev.clientX, ev.clientY, what.move);
    showGuide(guide, target);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);

Listeners go on window, not the element, so the drag survives the pointer leaving the toolbox. body.rp-dragging forces the grabbing cursor everywhere and switches off text selection. touch-action: none on tools and frames stops a touch drag from scrolling the page instead.

dropTarget() β€” from a pointer to a place in the design

The design is a flat list of blocks that the engine flows into rows (part 2). A drop therefore has to answer two questions: at which index does the block go, and does it share a row or start a new one? dropTarget(cx, cy, movingId) works it out in millimetres against the current layout:

  1. Which page? The one whose rectangle (Β±20px) contains the pointer's y.
  2. Pointer to mm: mx = (cx - pageRect.left) / k, my = ....
  3. Over a block?
    • In its outer 28% on either side, and the block can share a row (data, text, spacer): a side drop. A vertical guide is drawn at that edge; the index is just before or after the block.
    • Otherwise, a row drop. The top half means before the row's first block; the bottom half means after its last. A horizontal guide spans the content width, and newRow: true is set.
  4. Between rows: before the first block of the next row down.
  5. Below everything on the page: after the last block on it.
  6. An empty page: before whatever comes next in the design.

The block being moved is excluded from hit-testing, so you can't drop a block beside itself.

applyDrop() β€” making room

let at = t.index;
if (from >= 0 && from < at) at--;       // removing the block first shifted everything up
...
if (t.side) {
    const row = rowOf(d.blocks, t.target);
    const used = row.reduce((s, x) => s + E.blockSpan(x), 0);
    if (used + b.span > 12) {
        const free = 12 - used;
        if (free >= 3) b.span = free;                          // take what's left
        else {                                                 // or halve the target
            const half = Math.max(3, Math.floor(target.span / 2));
            b.span = Math.max(3, target.span - half); target.span = half;
        }
    }
    if (t.side === 'before') { b.newRow = !!target.newRow; target.newRow = false; }
    else b.newRow = false;
}
  • The index correction. When a block moves down the list, removing it first shifts every later index up by one. Forgetting this drops the block one place too far.
  • A side drop always fits. Dropping a half-width chart beside a full-width one would overflow the row, so the new block takes the free columns. If fewer than 3 are free, it halves the target and takes the other half. The result is always a valid row, never a block that wraps unexpectedly.
  • newRow moves with the row's start. Dropping before the first block of a row makes the new block the start of the row, so it inherits the target's newRow and the target loses it.

rowOf(blocks, id) reproduces the engine's row-flow rule to find which blocks share a row. It must stay in step with paginate()'s row rule (see Traps).

Auto-scroll

Holding a drag within 50px of the canvas's top or bottom edge scrolls it 18px every 16ms (about 1,100px a second), via an interval that stops the moment the pointer moves back in or is released.


8. Resizing on the column grid

Widths are always a whole number of the 12 columns (span). Resizing a block drags its east or west handle:

const pitch = S.layout.colW + S.layout.gutter;                  // one column plus one gutter, mm
const dmm = (ev.clientX - startX) / k * (edge === 'w' ? -1 : 1);
span = Math.max(1, Math.min(12, Math.round((item.w + dmm + S.layout.gutter) / pitch)));
const w = span * S.layout.colW + (span - 1) * S.layout.gutter;
frameEl.style.width = (w * k) + 'px';
if (edge === 'w') frameEl.style.left = ((item.x + item.w - w) * k) + 'px';
tip.textContent = T('designer.cols_of_12', { n: span });       // "6 of 12 columns"
  • It snaps while you drag. The frame jumps from column to column, so what you see on release is exactly what you get.
  • The west handle grows leftwards. The frame's left moves so the right edge stays put, as in any drawing tool.
  • The columns appear while you resize. .rp-col-guides lays 12 tinted stripes over the page and removes them on release.
  • Height (the south handle) works the same way in whole millimetres: 2-120mm for a spacer, 30-250mm for a chart.

Release commits once, through setSpan() or a height change. Releasing at the starting size just re-renders.


9. The ribbon

Five tabs (Home, Insert, Layout, Design, Data), each a function returning groups of controls:

function homeGroups() {
    return [
        { label: T('designer.grp.clipboard'), items: [ rb('paste', ..., { big: true }), h('div', {class: 'rp-stack'}, cut, copy, dup) ] },
        { label: T('designer.grp.font'),      items: [ size select, bold, italic, underline, strike, colour, highlight, clear ] },
        { label: T('designer.grp.paragraph'), items: [ bullets, numbering, outdent, indent, left, centre, right, justify ] },
        { label: T('designer.grp.styles'),    items: [ Normal, Heading 1-3 previews ] },
    ];
}

buildRibbon() turns the active tab's groups into .rp-group columns, each with a caption underneath, Office-style. Switching tabs rebuilds only the ribbon. A view-only user gets only the Data tab.

Buttons that don't steal the selection

The Home tab's font and paragraph commands act on text selected in the in-place editor. Clicking a button would normally move focus, and with it the selection, so the command would apply to nothing. Two defences:

onmousedown: (e) => { if (opts.keepFocus) e.preventDefault(); },   // a button never takes focus

Drop-downs and colour pickers must take focus to open, so for those, editor.js remembers the last selection inside the editor and puts it back before running the command (section 10).

Live state

refreshRibbonState() runs on every render and on every selectionchange while editing:

  • RPEditor.state() reads queryCommandState for bold, italic, lists and alignment, plus the heading level at the caret. Buttons get aria-pressed, and the Styles gallery highlights the current style.
  • .rp-needs-edit controls (all the text formatting) are disabled unless text is being edited.
  • data-cmd="needsel" controls (cut, copy, move) need a selected block, and paste needs something on the clipboard.
  • Undo and redo are disabled when their stacks are empty.

The Layout tab's width buttons draw a tiny bar glyph (widthGlyph(span)) showing the fraction of the page, and the Design tab's palette buttons show the first five colours of each palette. Each control shows what it does rather than describing it.


10. Editing text in place

Double-clicking a text box, the header, the footer or the cover opens an editor on the page, over the text, at the same size. The ribbon's Home tab drives it. It is editor.js (window.RPEditor).

Positioning: measure what is drawn

// The scale of the page AS DRAWN, not the zoom setting: if a zoom change
// has not been painted yet, the editor must still sit exactly on the text.
const k = pageEl.offsetWidth / S.layout.geometry.pw;

The editor is an absolutely positioned contentEditable div in the page's overlay, at the block's engine coordinates Γ— k. Its font size converts the theme's points to screen pixels at this zoom:

fontSize: (opts.theme.size * 25.4 / 72 * pxPerMm) + 'px'      // 1pt = 25.4/72 mm

A box-shadow: 0 0 0 9999px rgba(255,255,255,.5) dims the rest of the page, and frames and regions on that page get pointer-events: none, so the edit has focus visually and physically.

Model to HTML, HTML to model

Stored text is not HTML. It is a paragraph model (part 3 has the schema):

{ t: 'p', a: 'center', r: [ { x: 'From ', b: true }, { fld: 'date_from' } ] }

docToHtml(doc) builds the editor's HTML from it, escaping every piece of text. It opens and closes nested <ul>/<ol> from the flat list items' levels, and renders fields as non-editable chips:

<span class="rp-field" contenteditable="false" data-fld="date_from">Start date</span>

When editing ends, htmlToDoc(el) walks the DOM back into the model:

  • Block elements become paragraphs. p and div give p; h1-h3 keep their level; h4-h6 become h3; list items become li with a level from nesting depth; loose inline content is gathered into a paragraph.
  • Inline styles become run flags. styleOf() reads b/strong/i/u/s/font tags and inline CSS (font-weight >= 600, text-decoration, colours as rgb() or hex, font sizes as px or pt), inheriting down the tree.
  • Neighbouring runs of identical style merge (pushRun), so the stored document stays small however much the browser fragments it.
  • Default colours are dropped, so they don't become explicit.

The browser's HTML is a temporary view. It is never sent to the server; only the model is.

The details that make it feel like Word

  • execCommand('styleWithCSS', true) and defaultParagraphSeparator: 'p' make the browser produce <span style> and <p>, which htmlToDoc reads reliably.
  • Font size isn't done with execCommand('fontSize'), which only knows sizes 1-7. wrapSelection() wraps the selection in a <span> of the exact pixel size and stores the real point size in data-pt. On close, every on-screen pixel size is converted back to points (data-pt where present, otherwise computed), so zoom never leaks into the stored sizes.
  • Paste is plain text (insertText with text/plain). A report's look comes from the report, not from wherever the text was copied.
  • Fields are inserted as non-editable chips followed by a zero-width space, so the caret has somewhere to go after one. That character is stripped on the way back to the model, because the PDF fonts don't have it (see Traps).
  • Typing doesn't trigger designer shortcuts. The editor stops keydown propagation, so Delete deletes a character, not the block.
  • Escape ends editing and keeps the text. Word has no discard, and neither does this. Clicking anywhere outside the editor (except the ribbon and title bar) also ends it, via a capture-phase pointerdown listener added on the next tick so the opening click doesn't immediately close it.

Headings are simpler: a plain <input> laid over the heading's frame at the heading's size, colour and font. Enter keeps the text, Escape cancels, and blur keeps it.


11. The toolbox and its live previews

The toolbox lists the catalogue's items (part 3) in five areas: Layout, Tickets, Service Status, Software, Assets.

  • Search filters as you type, across title, description, keywords and area name. disponibilidad finds the uptime block because its keywords include it, for Enrique's team.
  • Keyboard: ArrowDown from the search box goes into the list, arrows move between items, and Enter inserts.
  • Inserting: drag onto a page, double-click, or Enter. A double-clicked item goes after the selected block, or at the end. Text and headings open straight into editing.
  • Unavailable items (a module the viewer can't use) are greyed out with a needs X tooltip and can't be dragged. They're shown rather than hidden, so a shared pack that uses them makes sense.

The live preview

Hovering over an item for 280ms shows a popover with the block as it would actually look, with your real data, in this pack's theme:

const entry = b.type === 'data' ? await rt.fetchBlock(b) : null;   // through the same cache
const mini = clone(S.design);
mini.header.on = false; mini.footer.on = false; mini.cover.on = false; mini.toc.on = false;
mini.blocks = [b];
const L = E.paginate(mini, { [b.id]: entry }, rt.ctx());
const svg = window.RPRenderSvg.page(L.pages[0], L.geometry, {...});
svg.setAttribute('viewBox', (it.x - pad) + ' ' + (it.y - pad) + ' ' + (it.w + pad * 2) + ' ' + Math.min(it.h + pad * 2, 140));

It reuses the real engine and renderer on a one-block copy of the design, then crops the SVG to the block with a viewBox. No second rendering path exists to drift out of step. The preview fetch is quiet (it doesn't trigger a page re-render) and goes through the same cache, so a block you preview and then insert appears instantly. The popover is pointer-events: none, so it never gets in the way of the pointer.


12. The properties pane writes itself

With nothing selected, the pane shows the pack: description, cover, contents, header and footer switches, and margins. With a block selected, it shows that block, and for a data block the options come from the handler's option schema in the catalogue:

Object.entries(hd.opts || {}).forEach(([k, def]) => body.push(optionControl(b, k, def, ro, upd)));
Schema type Control
select a drop-down of def.values
int a number field clamped to min/max
bool a checkbox
multi a scrolling checklist of def.choices (e.g. services); none ticked means all

Adding an option to a block in PHP gives it a control here with no JavaScript change. Changing an option commits with {data: true}, which refetches only that block. Charts also get Height, and doughnuts and pies get Legend.


13. Clipboard, keyboard and the context menu

Clipboard: a copied block is kept in memory and in localStorage (rp.clip), so you can copy a block in one pack and paste it into another, in another tab. A pasted block gets a new id. Cut is copy plus delete.

Keyboard (outside text fields):

Keys Does
Ctrl+S / Ctrl+P save / export PDF (also inside text fields)
Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z undo, redo
Ctrl+C / X / V / D copy / cut / paste / duplicate
↑ ↓ select the previous / next block (in design order)
Alt+↑ / Alt+↓ move the block up / down
Enter edit the selected text or heading
Delete / Backspace delete the block, and select its neighbour
Escape clear the selection

Right-click on a block opens a context menu with the same actions, their shortcuts shown as <kbd>, and width presets for blocks that can be resized. It positions itself to stay on screen, focuses its first item, cycles with the arrow keys and closes on Escape or an outside click.


14. Saving, conflicts and export

const r = await api('save.php', { id, name, description, design: S.design, updated: S.updated, force });
S.updated = r.updated;
// Adopt what the server kept (it tidies as it checks), so the screen and
// the stored pack can never drift apart.
if (r.design && JSON.stringify(r.design) !== JSON.stringify(S.design)) { S.design = r.design; ... }
  • Optimistic concurrency. The save sends the updated stamp the designer loaded. If someone else has saved since, the server answers 409 conflict with their name. A dialog offers Reload (take theirs) or Save mine (force: true).
  • The server's design wins. The server cleans every design it is sent (clamping numbers, dropping unknown keys, part 3). The designer adopts the cleaned copy, so what you see after saving is exactly what is stored.
  • Copy (for a viewer) creates a new pack of your own and opens it.
  • Export closes any open editor, shows a busy overlay and calls rt.exportPdf(). That fetches anything still missing, lays out once more and builds the PDF from the same pages (part 2). It warns if some text can't be represented in the PDF fonts.

15. Accessibility

  • The ribbon is a toolbar, its tabs tab/tabpanel with aria-selected. Toggle buttons expose aria-pressed, and every icon button has an aria-label that includes its shortcut.
  • Block frames are role="button", focusable, labelled with the block's name, and selectable with Enter or Space.
  • The toolbox is a listbox of options with keyboard navigation. The context menu is a menu of menuitems.
  • Actions with no visible change (Copied, Deleted, Saved) are announced through a visually hidden aria-live="polite" region.
  • The in-place editor is role="textbox" with aria-multiline, and spellcheck is on.
  • Reduced motion is respected.

16. Testing hooks

window.RPDesigner = { S, rt, commit, undo, redo, select, insertTool, applyDrop, dropTarget,
                      setSpan, moveBlock, deleteBlock, editBlock, editRegion, save, render, setZoom, rowOf };

tests/report-packs-designer-live.html drives the real designer in a browser through these and through real pointer events (30 checks): toolbox search, insert, dragging onto a chart's edge, resize, move, delete, undo/redo, keyboard copy/paste, typing and bold in place, header fields, a period change, save and read-back, the PDF page count, and no stray null text anywhere. tests/ isn't served over HTTP, so copy the file to the web root to run it (instructions are in the file).


17. Traps

  • replaceChildren() and append() write a missing element as the text "null". It appeared under the ribbon, in the status bar and beside every colour picker. Always .filter(Boolean) before spreading children into them, as build() and renderStatus() do.
  • Size an overlay from what is drawn, not from the zoom setting. The text editor once took the requested zoom while the page on screen was still the previous one, and sat a line below the text. Measure pageEl.offsetWidth.
  • selectionchange is asynchronous. A ribbon command that blindly restored the saved selection applied bold to the caret instead of the words just selected, because the saved copy was a step behind. restoreRange() restores only when focus has really taken the selection out of the editor.
  • The editor's zero-width space is a real character. The one placed after a field printed as ? in the PDF. It is stripped in collectRuns() and on the server (rpStr()).
  • rowOf() duplicates the engine's row rule. If you change how paginate() flows blocks into rows (part 2), change rowOf() with it, or side drops will compute room against the wrong row.
  • Don't rebuild the canvas while editing. The guard against it lives inside render(), so every path through render() is safe. Code that replaces els.canvas's children some other way would destroy the editor mid-word.
  • A pointer listener on the element, not window, loses the drag the moment the pointer leaves it.

Next: Part 2: the layout engine. This covers how a design and its data become pages of millimetre-exact primitives, and how the same pages become both the SVG on screen and the PDF.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally