Repository navigation
Report Packs Internals 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.
- The page is a shell
- The frame: CSS that makes it an application
- Building the DOM without HTML strings
- State: one object, one way to change it
- Why it feels fluid
- The canvas: SVG pages under an HTML overlay
- Drag and drop
- Resizing on the column grid
- The ribbon
- Editing text in place
- The toolbox and its live previews
- The properties pane writes itself
- Clipboard, keyboard and the context menu
- Saving, conflicts and export
- Accessibility
- Testing hooks
- Traps
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).
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.
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.
bodyis exactly the viewport withoverflow: hidden, and each pane scrolls independently. Themin-height: 0on 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-pageitself 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.
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
innerHTMLanywhere in the designer. A pack's names, titles and data reach the page throughtext: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 : nullreads naturally, andcanEdit() ? button : nullchildren simply disappear (kids.flat()also lets an array of children be passed). -
Icons are path data, not files: a 50-entry
ICONmap of 24Γ24 stroke paths, turned into SVG byicon(name, size). No requests, they inheritcurrentColor, 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.
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.
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.
No single trick does it. These choices together keep every interaction under a frame or two.
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.
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.
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.
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.
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.
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.
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.
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.
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.
-
Selecting a block scrolls it into view smoothly, but only after the render that draws it: two nested
requestAnimationFrames, thenscrollIntoView({block: 'nearest', behavior: 'smooth'}).nearestmeans 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 canpreventDefaultthe browser's own zoom. Fit computes the zoom that fits the page to the canvas width. The zoom is remembered inlocalStorage. - 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.
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.jsfrom the engine's primitives, with aviewBoxin millimetres. Zoom is only the SVG'swidth/heightin px:mm Γ 96/25.4 Γ zoom. -
The overlay is for the mouse. Each block on the page gets a transparent
.rp-frameat the block's exact position (it.x * kpx, wherek = 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-regionhit 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 horizontal ruler is a <canvas>, redrawn on every render because it depends on the page width and zoom:
- drawn at
devicePixelRatioresolution 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
-
paddingRightset 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
scrollLeftfollows the canvas's on everyscrollevent
Each page also carries its own vertical ruler canvas, positioned 26px to the left of the page.
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}).
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.
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:
- Which page? The one whose rectangle (Β±20px) contains the pointer's y.
-
Pointer to mm:
mx = (cx - pageRect.left) / k,my = .... -
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: trueis set.
- Between rows: before the first block of the next row down.
- Below everything on the page: after the last block on it.
- 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.
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.
-
newRowmoves 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'snewRowand 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).
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.
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
leftmoves so the right edge stays put, as in any drawing tool. -
The columns appear while you resize.
.rp-col-guideslays 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.
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.
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 focusDrop-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).
refreshRibbonState() runs on every render and on every selectionchange while editing:
-
RPEditor.state()readsqueryCommandStatefor bold, italic, lists and alignment, plus the heading level at the caret. Buttons getaria-pressed, and the Styles gallery highlights the current style. -
.rp-needs-editcontrols (all the text formatting) are disabled unless text is being edited. -
data-cmd="needsel"controls (cut, copy, move) need a selected block, andpasteneeds 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.
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).
// 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 mmA 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.
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.
panddivgivep;h1-h3keep their level;h4-h6becomeh3; list items becomeliwith a level from nesting depth; loose inline content is gathered into a paragraph. -
Inline styles become run flags.
styleOf()readsb/strong/i/u/s/fonttags and inline CSS (font-weight >= 600,text-decoration, colours asrgb()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.
-
execCommand('styleWithCSS', true)anddefaultParagraphSeparator: 'p'make the browser produce<span style>and<p>, whichhtmlToDocreads 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 indata-pt. On close, every on-screen pixel size is converted back to points (data-ptwhere present, otherwise computed), so zoom never leaks into the stored sizes. -
Paste is plain text (
insertTextwithtext/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
keydownpropagation, 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
pointerdownlistener 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.
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.
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.
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.
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.
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
updatedstamp the designer loaded. If someone else has saved since, the server answers409 conflictwith 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.
- The ribbon is a
toolbar, its tabstab/tabpanelwitharia-selected. Toggle buttons exposearia-pressed, and every icon button has anaria-labelthat 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
listboxofoptions with keyboard navigation. The context menu is amenuofmenuitems. - 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"witharia-multiline, and spellcheck is on. - Reduced motion is respected.
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).
-
replaceChildren()andappend()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, asbuild()andrenderStatus()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. -
selectionchangeis 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 incollectRuns()and on the server (rpStr()). -
rowOf()duplicates the engine's row rule. If you change howpaginate()flows blocks into rows (part 2), changerowOf()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 throughrender()is safe. Code that replacesels.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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96