Add the A4 report designer and fix band layout
Brings in the A4 (millimetre) report designer alongside the existing matrix editor, migrates the frontend from react-scripts to Vite/Vitest, and fixes four defects found while using the A4 band tools. Layout — layout.js now implements the standard banded-report model: everything prints where it was drawn, and a band growing per data row is the only thing that moves anything. Previously `paginate` re-flowed every band from the top margin, so a band placed halfway down the page previewed at the top, and `header` was treated as a page header and hoisted above the statics drawn before it. A header is a COLUMN header: it prints in place and repeats only on a continuation page. Statics now take part too — one drawn below a band keeps its drawn gap as that band grows, and follows onto the next page rather than vanishing when pushed past the bottom margin. The displacement is keyed on the band's design bottom, not on an instance's laid-out y: every row of a band shares one design position, so keying on the instance measured the gap from row 1 and let the static overlap rows 2..n. Band children — a child's x is relative to its band, as its y already was. layout() emitted it raw, so a field printed measured from the paper edge instead of the band's left margin. Instances now carry bandX. Band placement — a band's x comes from the page margins, so creation takes only the click's Y (the factory's x was being overwritten by the spread order) and dragging is locked to the vertical axis. Bands render as a strip, with top/bottom rules only, matching the matrix editor. fromApiReport reflows on load so reports saved with a stray x straighten themselves. Matrix bands are constrained to vertical too. Drag lifecycle — useElementDrag re-subscribed its document listeners on every render during a drag, because the effect depended on an unmemoized onDrag. Each re-subscribe removed the mouseup listener before adding the next, measured as add/remove/add/remove/add across two mousemoves, and a release landing in that gap was lost: the element kept following the cursor until the next click. Snapping made it routine, since a held guide stops x/y changing and leaves the guide overlay's render sitting between the move and the release. onDrag and dragData now go through refs so the effect depends on [isDragging] alone. Note: the matrix editor has no test coverage, so its band constraint is verified by reading only. The A4 suite is 201 passing tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,22 @@
|
||||
- Inspector CSS: `ObjectInspector.css`
|
||||
- Data resolver utils: `utils/dataResolver.js` — `resolveDBTextValue`, `getAvailableObjects`, `getAvailableFields`
|
||||
- Expression evaluator: `utils/expressionEvaluator.js` — recursive-descent, no eval
|
||||
- A4 designer (sibling tree, mm-based): `frontend/src/components/ReportEditor/a4/`
|
||||
|
||||
## Element Type Pattern
|
||||
## Two editors, deliberately duplicated
|
||||
|
||||
The matrix editor (`EditorCanvas.jsx`, `models/Element.jsx`, `ResizeHandles.jsx`,
|
||||
`ObjectInspector.jsx`, `ReportEditor.jsx`) is treated as frozen. The A4 designer under
|
||||
`a4/` copies similar logic rather than extracting shared abstractions — per the plan at
|
||||
`.claude/plans/a4-report-designer.md`. Do not "DRY up" the two; the duplication is the point.
|
||||
|
||||
Key unit difference: matrix bounds are **inclusive** (`x + width - 1`); A4 bounds in
|
||||
`a4/geometry.js` are **exclusive** (a 40mm box at x=10 has right edge 50).
|
||||
|
||||
Safely reusable by both: `hooks/useElementDrag.jsx` (emits raw pixel deltas, snapping left
|
||||
to the consumer), `hooks/useElementSelection.jsx`, `DataContext`, `BandContext`.
|
||||
|
||||
## Element Type Pattern (matrix editor)
|
||||
|
||||
Adding a new element type requires touching 8 files:
|
||||
1. `models/Element.jsx` — new class + register in `elementClasses` map in `fromJSON`
|
||||
@@ -50,3 +64,20 @@ resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, { currentBand
|
||||
|
||||
`[objectKey.fieldPath]` for top-level fields, `[fieldPath]` for band-relative fields.
|
||||
Strip brackets, split on first `.` to get objectKey and fieldPath.
|
||||
|
||||
## Build tooling
|
||||
|
||||
**Vite + Vitest** (no CRA/Jest). `npm run dev` / `npm start` → port 5172;
|
||||
`npm test` → `vitest run`; `npm run build` → `frontend/build/`.
|
||||
Config: `frontend/vite.config.mjs`. Env vars are `import.meta.env.VITE_*`.
|
||||
**Any new production file containing JSX must be named `.jsx`** — a `.js` file
|
||||
with JSX passes tests and the dev server but fails `vite build`.
|
||||
See [vite-vitest-migration](vite-vitest-migration.md).
|
||||
|
||||
## Detailed notes
|
||||
|
||||
- [Vite + Vitest build tooling](vite-vitest-migration.md) — the JSX-in-.js build trap, axios mocks, jsdom `border: none`
|
||||
- [A4 entry points](a4-entry-points.md) — report `kind` decides the editor/print route; the two contexts A4ReportEditor now needs in tests
|
||||
- [A4 smart guides](a4-smart-guides.md) — px-not-mm snap threshold; why useElementDrag must stay untouched; the drag-test gotcha snapping introduces
|
||||
- [Testing React in this repo](testing-react.md) — no testing-library installed; jsdom/React event gotchas that cost real debugging time
|
||||
- [Decimal number inputs](decimal-inputs.md) — why `type="number"` breaks mm/float entry
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: a4-designer-conventions
|
||||
description: Non-obvious invariants of the A4 report designer — line element shape, mm-vs-px flooring, and why matrix inspector panels must not be imported
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Conventions in `frontend/src/components/ReportEditor/a4/` that are easy to get
|
||||
wrong because they contradict the matrix editor sitting next to them.
|
||||
|
||||
**Lines carry only one dimension.** An `a4-hline` has `width` + `thickness`
|
||||
and NO `height`; an `a4-vline` has `height` + `thickness` and NO `width`.
|
||||
Anything computing a box from an element must substitute `thickness` for the
|
||||
missing axis — `getA4Bounds` in `geometry.js` does this, and it is the
|
||||
function to reach for rather than reading `el.width`/`el.height` directly.
|
||||
Reading the raw props for a line compares against `undefined` and passes
|
||||
vacuously in tests while being wrong in the product.
|
||||
|
||||
**Screen floors hairlines, print must never.** Screen renderers clamp a line's
|
||||
painted thickness and a rect's border to `Math.max(1, …)` px, because 0.3mm at
|
||||
50% zoom is 0.57px and browsers can round it away to nothing. `PrintDocument`
|
||||
deliberately does no such thing — the printed thickness is what a steel rule
|
||||
measures. If a test asserts a floored value on the print path, the test is
|
||||
wrong, not the code.
|
||||
|
||||
**Do not import the matrix inspector panels.** `ExpressionEditorPanel.jsx`
|
||||
looks tempting for the A4 expression section but is coupled two ways: it finds
|
||||
a parent band with `el.type === 'band'` (A4 bands are `a4-band`) and its
|
||||
alignment select writes `element.alignment`, whereas every A4 element and
|
||||
renderer reads `element.align`. Reusing it silently creates a dead second
|
||||
alignment property. The duplication between the two inspectors is accepted
|
||||
deliberately — see the plan's "matrix editor is not modified" section.
|
||||
|
||||
**`fillColor: null` means no fill,** distinct from white, which would hide
|
||||
whatever a rect is framing. A colour input cannot express null, so a checkbox
|
||||
owns that state. It round-trips through Django's JSONField as SQL null.
|
||||
|
||||
**Float inputs use `MmInput`,** never `<input type="number">`: a number input
|
||||
returns `""` for the intermediate value `"12."`, so the decimal point appears
|
||||
not to type. `MmInput` (in `A4Inspector.jsx`) holds a draft string while
|
||||
focused. Font size in pt is the one exception — its step is 0.5 and users
|
||||
never type a long decimal tail there.
|
||||
|
||||
Related: [[vite-vitest-migration]]
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: a4-entry-points
|
||||
description: How the two report editors are reached and dispatched between — kind decides the route, and why the A4 editor now needs Router + NomenclatureData context in tests
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Phase 7 wired the A4 designer to real operator entry points. Three things here
|
||||
are easy to get wrong later.
|
||||
|
||||
**Report `kind` decides the route, never a default.** `Main.jsx` fetches
|
||||
`/api/reports/?name=notice_report` and branches: `kind === 'a4'` → dynamic
|
||||
`import()` of `a4/print/printReport` and `printA4Report(report, data)`;
|
||||
otherwise the existing `ReportPreviewModal`. "Design Notice" routes to
|
||||
`/report-editor-a4` or `/report-editor` by the same field.
|
||||
|
||||
**Why it matters:** a matrix report opened in the mm editor reads its character
|
||||
x/y as millimetres and looks like a corrupt layout rather than a routing bug —
|
||||
a slow, confusing failure. The A4 editor's auto-load therefore filters on
|
||||
`?kind=a4&name=...`, not name alone; verified against the real DB that
|
||||
`?kind=a4&name=<a matrix report>` returns 0 rows.
|
||||
|
||||
**How to apply:** any new entry point that opens a saved report must resolve
|
||||
`kind` before choosing an editor or a render path. Never assume a name implies
|
||||
a kind.
|
||||
|
||||
**`A4ReportEditor` now requires two contexts.** It calls `useLocation`/
|
||||
`useNavigate` (router-state auto-load) and `useNomenclatureData` (sample-data
|
||||
fallback). Both throw outside their providers, so any jsdom test mounting the
|
||||
whole editor needs `<MemoryRouter>` plus
|
||||
`vi.mock('../../../../contexts/NomenclatureDataContext', ...)`. Mocking the
|
||||
nomenclature hook rather than standing up the provider keeps the SSE client and
|
||||
auth context out of tests that are really about drag geometry.
|
||||
|
||||
**Why:** adding these deps silently broke four existing `a4Guides` tests with
|
||||
"useLocation() may be used only in the context of a <Router>" — the component
|
||||
was fine, the harness was stale.
|
||||
|
||||
**`window.print()` is not gated by transient user activation** the way
|
||||
`window.open` is, so an `await` before it is safe. The A4 print chunk is still
|
||||
prefetched on hover/focus of the Print Notice button, purely to shorten the gap
|
||||
on a slow station. This was reasoned, not verified in a real browser — if Print
|
||||
Notice is ever reported as doing nothing on click, re-test that assumption
|
||||
first.
|
||||
|
||||
Related: [[a4-print-path]], [[a4-designer-conventions]], [[testing-react]]
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: a4-print-path
|
||||
description: Two non-obvious constraints in the A4 report print path — the print-root must be a direct body child, and flushSync (not rAF) must precede window.print()
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Two constraints in `frontend/src/components/ReportEditor/a4/print/` that are easy to
|
||||
silently break and whose failure mode is a blank or wrong printed page.
|
||||
|
||||
**1. `.print-root` must sit on the element appended directly to `<body>`.**
|
||||
`print.css` hides the app with `body > *:not(.print-root)` — a CHILD combinator. The app
|
||||
mounts into `<div id="root">`, itself a direct body child, so the print container is its
|
||||
sibling. If the class lived only on `PrintDocument`'s inner div, the appended container
|
||||
would match the hiding rule and print a blank page.
|
||||
|
||||
**Why:** the selector is the entire mechanism for hiding the editor during print; there is
|
||||
no other switch.
|
||||
**How to apply:** when changing how the print container mounts, keep the class on the
|
||||
container itself. `printReport.js` sets it; `PrintDocument` repeats it for the standalone
|
||||
case.
|
||||
|
||||
**2. `flushSync` — never `requestAnimationFrame` — before `window.print()`.**
|
||||
`window.print()` reads the DOM synchronously while React 18 commits asynchronously, so an
|
||||
unflushed render prints an empty container. rAF was the first fix tried and is wrong: it
|
||||
does not fire in a background or hidden tab, so a print click would hang instead of
|
||||
printing.
|
||||
|
||||
**Why:** discovered when tests showed `window.print` never being reached behind a
|
||||
double-rAF await; the background-tab case is the production version of the same bug.
|
||||
**How to apply:** any new print entry point wraps its `root.render` in `flushSync` from
|
||||
`react-dom`.
|
||||
|
||||
Related: `.a4-print-page` is deliberately NOT `.a4-page` — `A4Canvas.css` already owns
|
||||
`.a4-page` for the on-screen editor canvas, and both stylesheets load together.
|
||||
@@ -0,0 +1,35 @@
|
||||
# A4 smart guides (`a4/guides.js`)
|
||||
|
||||
Pure functions, no React/DOM. `buildGuideCandidates(elements, excludeIds, pageSetup)`
|
||||
→ `{V, H}`; `resolveSnap(draftRect, candidates, zoom)` → `{x, y, guides}`;
|
||||
plus `unionRect(boxes)` and `snapToleranceMm(zoom)`.
|
||||
|
||||
## The threshold is in SCREEN PIXELS, not mm
|
||||
|
||||
`SNAP_PX = 6`, converted per-call via `SNAP_PX / (PX_PER_MM * zoom)`. This is
|
||||
load-bearing, not a detail: a fixed mm threshold feels sticky zoomed in and
|
||||
useless zoomed out. Do not "simplify" it to a constant mm value. The property
|
||||
worth asserting is `tolMm * PX_PER_MM * zoom === SNAP_PX` at every zoom.
|
||||
|
||||
## Integration seams in `A4Canvas.jsx`
|
||||
|
||||
- Candidates are built **once** in `handleElementDragStart` into a `useRef`.
|
||||
Rebuilding per mousemove is the one thing that makes dragging laggy.
|
||||
- Multi-select snaps the **union rect**, then applies one delta to every member.
|
||||
Snapping members independently tears the group apart.
|
||||
- `hooks/useElementDrag.jsx` is shared with the matrix editor and forwards only
|
||||
pixel deltas — **never widen it**. Alt-to-suppress is tracked with a
|
||||
window-level `keydown`/`keyup` listener in A4Canvas, plus a `blur` reset (an
|
||||
alt-tab otherwise leaves snapping permanently off, because the keyup lands in
|
||||
another window).
|
||||
- Bands emit no candidates (their edges *are* the margins, already candidates),
|
||||
but band children do.
|
||||
|
||||
## Gotcha: snapping breaks drag tests that assume raw deltas
|
||||
|
||||
Any pre-existing test that drags an element and asserts the exact px→mm result
|
||||
can fail once a candidate happens to sit within ~1.6mm of where it lands. This
|
||||
bit `a4Elements.test.js`'s rect case: it drifted 0.05mm from the 20mm left
|
||||
margin and legitimately snapped. The fix is to hold Alt around the drag
|
||||
(`withAltHeld` helper in that file), not to weaken the assertion — the test is
|
||||
about unit conversion, so switching the magnet off states its intent.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Decimal / float number inputs
|
||||
|
||||
Rule: for a field where the user types a **decimal** value (millimetres, rates, any float),
|
||||
use `type="text"` + `inputMode="decimal"` with local draft-string state — **not**
|
||||
`type="number"`.
|
||||
|
||||
## Why
|
||||
|
||||
Two independent failure modes combine:
|
||||
|
||||
1. **`type="number"` sanitises partial input.** While the user is typing `12.7`, the
|
||||
intermediate value is `"12."`, which is not a valid number. The browser (and jsdom)
|
||||
report `input.value === ""`. The decimal point appears not to type at all.
|
||||
|
||||
2. **A naive `parseFloat` + `isNaN` guard drops the same intermediate.** `parseFloat("12.")`
|
||||
is `12`, so a commit-on-every-change handler rewrites the field to `"12"` and the caret
|
||||
jumps — same visible symptom, different cause.
|
||||
|
||||
## The working pattern
|
||||
|
||||
Keep the raw string in local state while focused; commit only when it parses cleanly;
|
||||
re-sync from the prop only when not focused (so drag/resize updates still flow in).
|
||||
|
||||
```jsx
|
||||
const [draft, setDraft] = useState(String(value ?? ''));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) setDraft(String(value ?? ''));
|
||||
}, [value, isFocused]);
|
||||
|
||||
// onChange: setDraft(next) always; commit only if next is a complete number
|
||||
// onBlur: if the draft is empty/unparseable, restore String(value); else commit rounded
|
||||
```
|
||||
|
||||
Reference implementation: `MmInput` in
|
||||
`frontend/src/components/ReportEditor/a4/A4Inspector.jsx`.
|
||||
|
||||
Note the matrix editor's `ObjectInspector.jsx` uses `parseInt` and integer cells, so it
|
||||
does not need this — only float-valued fields do.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Testing React components in ScalesApp
|
||||
|
||||
## What's available
|
||||
|
||||
- **Vitest + jsdom** (`npm test` → `vitest run`). Run a single suite with:
|
||||
`npx vitest run <path-to-test-file>`. Use `vi.*` (not `jest.*`); `globals: true`
|
||||
is set, so `describe`/`test`/`expect`/`vi` need no import. See
|
||||
[[vite-vitest-migration]].
|
||||
- **`@testing-library/react` is NOT installed.** Don't assume it; mount with
|
||||
`createRoot` from `react-dom/client` and `act` from `react` instead.
|
||||
- There is no `src/setupTests.js`. Set `global.IS_REACT_ACT_ENVIRONMENT = true` in the
|
||||
test file itself, or React logs "testing environment is not configured to support act(...)".
|
||||
- Import `act` from `react`, not `react-dom/test-utils` (the latter is deprecated in React 18
|
||||
and prints a warning on every call).
|
||||
|
||||
## Three gotchas that produce fake failures
|
||||
|
||||
These each look like a component bug and are not. Verify with a tiny probe test before
|
||||
"fixing" component code.
|
||||
|
||||
1. **React's `onBlur`/`onFocus` map to native `focusout`/`focusin`.**
|
||||
Dispatching `new FocusEvent('blur')` does nothing — the handler never runs, so the
|
||||
component looks broken. Dispatch `focusout`/`focusin` instead.
|
||||
|
||||
2. **`MouseEvent` coerces `clientX`/`clientY` to integers.**
|
||||
Passing a fractional pixel coordinate silently truncates. In a mm-based editor that
|
||||
shows up as a ~0.3mm discrepancy that looks like a rounding bug in the conversion code.
|
||||
Use whole pixels in tests and derive expected values from those same integers.
|
||||
|
||||
3. **`<input type="number">` in jsdom returns `""` for a partial value like `"12."`.**
|
||||
Real browsers sanitise similarly. See [[decimal-inputs]].
|
||||
|
||||
## jsdom has no layout
|
||||
|
||||
`getBoundingClientRect()` returns all zeros. For canvas/positioning components, stub it
|
||||
per test to pin a known origin:
|
||||
|
||||
```js
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
if (this.classList.contains('a4-page')) {
|
||||
return { left: 100, top: 50, /* ...*/ toJSON() {} };
|
||||
}
|
||||
return { left: 0, top: 0, /* ... */ toJSON() {} };
|
||||
};
|
||||
```
|
||||
|
||||
## Driving controlled inputs
|
||||
|
||||
React overrides the `value` setter, so `input.value = x` alone won't trigger `onChange`.
|
||||
Use the native setter, then dispatch `input`:
|
||||
|
||||
```js
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
).set;
|
||||
setter.call(input, '12.');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
```
|
||||
|
||||
## Backend round-trips without a live server
|
||||
|
||||
Outbound HTTP from the agent sandbox may be blocked even when the dev server is running.
|
||||
To exercise real DRF serializers/filters/DB, use Django's test client directly against the
|
||||
dev database — pass `APIClient(SERVER_NAME='localhost')`, since the default `testserver`
|
||||
host is rejected by `ALLOWED_HOSTS`.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: vite-vitest-migration
|
||||
description: Frontend build is Vite + Vitest (not CRA/Jest) — the JSX-in-.js trap, why axios no longer needs stubbing, and the jsdom border assertion gotcha
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
The frontend migrated from Create React App / Jest to **Vite + Vitest**. Config
|
||||
lives in `frontend/vite.config.mjs` (`.mjs`, not `.js` — Vite's native config
|
||||
loader warns about ESM in a `.js` config).
|
||||
|
||||
Replaces the old `jest-axios-esm` note, which is now obsolete.
|
||||
|
||||
**Vitest transforms axios fine, so the old blanket `jest.mock('axios')` hack is
|
||||
gone.** Three of the six a4 suites dropped it entirely. `a4EntryPoints` still
|
||||
mocks axios, but for a *behavioural* reason: it drives the report auto-load via
|
||||
`api.get.mockResolvedValue(...)`, so the instance `services/api.js` builds at
|
||||
import time must be a mock. Distinguish the two reasons before deleting a mock.
|
||||
|
||||
**The JSX-in-`.js` trap — this one fails at BUILD time only.** CRA's Babel
|
||||
parsed JSX inside `.js` files; Vite does not by default. Setting
|
||||
`oxc: { include: /\.(m?ts|[jt]sx|js)$/, lang: 'jsx' }` fixes the dev server and
|
||||
Vitest, but **not** `vite build`: the bundled path (Rolldown,
|
||||
`builtin:vite-transform`) derives the language from the file extension and
|
||||
ignores `lang`. So a `.js` file containing JSX will pass tests and the dev
|
||||
server, then fail the production build.
|
||||
|
||||
**How to apply:** any new production file containing JSX must be named `.jsx`.
|
||||
The `oxc` override exists only so the existing a4 `__tests__/*.test.js` files
|
||||
keep working. Importers use extensionless specifiers, so renaming a file
|
||||
`.js` → `.jsx` needs no import changes (this is how `print/printReport.jsx` was
|
||||
renamed without touching the dynamic `import()` in `Main.jsx`).
|
||||
|
||||
**Env vars are `import.meta.env.VITE_*`**, not `process.env.REACT_APP_*`;
|
||||
`process.env` does not exist in the browser. The `|| 'http://localhost:…'`
|
||||
fallbacks are the offline-deployment safety net — keep them. The dev-server
|
||||
port (5172, in `vite.config.mjs`) must match Django's `CORS_ALLOWED_ORIGINS`.
|
||||
Renaming these vars means touching `Dockerfile`, `docker-compose.yml`,
|
||||
`.env.docker` and the gitignored root `.env` too, or the deployed bundle
|
||||
silently falls back to localhost.
|
||||
|
||||
**Newer jsdom round-trips `border: none`.** It now reports
|
||||
`borderStyle === 'none'` with `borderWidth === 'medium'` (the CSS initial
|
||||
keyword). Assert on **`borderStyle`**, not `borderWidth === ''` — the latter was
|
||||
written around an old-jsdom quirk and passed vacuously. Two `a4Elements` tests
|
||||
had to be corrected for this during the migration; the components were correct.
|
||||
|
||||
**`global.IS_REACT_ACT_ENVIRONMENT = true` is no longer required** (Vitest's
|
||||
jsdom environment sets it), but it is harmless and still present in all six
|
||||
suites.
|
||||
|
||||
Related: [[testing-react]], [[a4-print-path]], [[a4-entry-points]]
|
||||
@@ -0,0 +1,540 @@
|
||||
# A4 Free-Positioning Report Designer + Print / Save-as-PDF
|
||||
|
||||
## Context
|
||||
|
||||
Some customers print on A4 laser printers rather than dot-matrix printers. The existing
|
||||
report designer is built entirely around an 80×66 **character grid** — elements snap to
|
||||
cells, frames and lines are drawn with Unicode box-drawing characters, coordinates are
|
||||
integer columns/rows. None of that maps onto an A4 page where a field must sit at an
|
||||
arbitrary millimetre position.
|
||||
|
||||
A second discovery shapes this as much as the first: **the app cannot print at all today.**
|
||||
"Print Notice" in `Main.jsx` opens `ReportPreviewModal`, which renders the report as HTML in
|
||||
an on-screen modal. There is no `window.print()`, no print CSS, no PDF library anywhere.
|
||||
Confirmed by grep: zero matches for `@page`, `@media print`, `window.print` in
|
||||
`frontend/src`. So the output path is new code regardless.
|
||||
|
||||
**Outcome:** a second designer where elements are positioned freely in millimetres, with
|
||||
alignment guides, that can be printed on A4 — and saved as a PDF when the operator wants a
|
||||
file.
|
||||
|
||||
### Decisions taken
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Positioning | Millimetres (floats), free | No grid; the mm typed is the mm printed |
|
||||
| Alignment aids | Smart guides on drag + page centre/margin snapping | Not ruler guides, not a background grid |
|
||||
| Bands | Kept, incl. multi-page overflow | Needed for list reports, not just one-page tickets |
|
||||
| Fonts | **Any font installed on the station** | See below |
|
||||
| Print | `window.print()` + `@page` CSS | Chrome renders with the machine's real fonts |
|
||||
| PDF | **Operator picks "Save as PDF"** in the same print dialog | A file, real fonts, zero dependencies |
|
||||
| Puppeteer | **Deferred**, socket left open | Only needed if code must make a PDF with no human |
|
||||
|
||||
### Why browser printing, not a PDF library
|
||||
|
||||
The requirement is *"the operator gets a PDF when they want one."* Chrome's print dialog
|
||||
already has **Save as PDF** built in (as does Windows' "Microsoft Print to PDF"). Same
|
||||
`window.print()` call — the operator picks a different destination and gets a file.
|
||||
|
||||
This matters because it settles the font question the right way round. A client-side PDF
|
||||
library (pdfmake, pdf-lib) can only use fonts whose **bytes it embeds** — JavaScript cannot
|
||||
read `C:\Windows\Fonts`. That would force Calibri and Segoe UI to substitute with metric
|
||||
clones. But **Chrome prints with the fonts installed on the station**. The customer paid for
|
||||
those fonts; they should be usable.
|
||||
|
||||
So: no PDF library, no embedded fonts, no substitution, no per-font upload step. Design in
|
||||
Calibri → prints in Calibri → saves to PDF in Calibri.
|
||||
|
||||
**Millimetre accuracy comes from CSS**, which has real physical units:
|
||||
|
||||
```css
|
||||
@page { size: A4 portrait; margin: 0; }
|
||||
.a4-page { width: 210mm; height: 297mm; position: relative; }
|
||||
.a4-el { position: absolute; } /* left: 20mm; top: 50mm */
|
||||
```
|
||||
|
||||
Chrome maps `mm` to physical millimetres at 100% scale.
|
||||
|
||||
### Why Puppeteer is deferred (not rejected)
|
||||
|
||||
Puppeteer is real Chrome driven by code — best fidelity, and it uses installed fonts too.
|
||||
It is the right tool **the day code must generate a PDF with no human in the loop**
|
||||
(auto-archiving to the vehicle record, emailed tickets, nightly batches). Nothing on the
|
||||
table needs that now.
|
||||
|
||||
Deferred because: ~150–300 MB of Chromium plus a Node runtime inside an **offline
|
||||
`Setup.exe`**, and a third service to install and supervise at every site — the most
|
||||
expensive thing in the box, for a one-page ticket. And server-side Puppeteer uses the
|
||||
**server's** fonts; at the reference site (1 server, 10 stations) that is the wrong machine,
|
||||
so fonts would have to be installed server-side to match — a problem browser printing simply
|
||||
does not have.
|
||||
|
||||
**The socket:** `layout()` is a pure function and `PrintDocument` emits plain HTML+CSS with
|
||||
no React-only tricks. Adding Puppeteer later means POSTing that same HTML to a small Node
|
||||
service (~60 lines) and returning bytes. An afternoon, not a redesign.
|
||||
|
||||
### Known rough edges (one-time per station, belong in the install checklist)
|
||||
|
||||
- Chrome adds its own header/footer (URL, date) unless **"Headers and footers"** is unticked
|
||||
in the print dialog. Sticky per machine once set.
|
||||
- Scale must stay at **100% / "Actual size"**, not "Fit to page", or millimetres stop being
|
||||
millimetres.
|
||||
|
||||
Neither is a code problem. If they become a field support burden, *that* is the trigger to
|
||||
revisit Puppeteer — and the argument will be about silent printing, not fonts.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
One layout engine, renderers downstream of it are dumb.
|
||||
|
||||
```
|
||||
Report JSON (elements in mm) + vehicle data
|
||||
│
|
||||
▼
|
||||
layout() pure function — no React, no DOM
|
||||
│ resolves bindings, iterates bands,
|
||||
│ paginates → pages[] of {element, x, y} in mm
|
||||
▼
|
||||
┌────────┴────────┐
|
||||
▼ ▼
|
||||
PrintDocument (future) Puppeteer
|
||||
mm → CSS mm same HTML → page.pdf()
|
||||
```
|
||||
|
||||
`layout()` returning `pages[]` is the load-bearing decision: **the same output drives the
|
||||
on-screen preview and the printed page.** That is what makes "preview page count equals
|
||||
printed page count" a real test rather than a coincidence, and it is what keeps a future PDF
|
||||
renderer cheap.
|
||||
|
||||
### The matrix editor is not modified
|
||||
|
||||
The A4 designer is a **sibling tree** under `frontend/src/components/ReportEditor/a4/`.
|
||||
`EditorCanvas.jsx`, `models/Element.jsx`, `ResizeHandles.jsx`, `ObjectInspector.jsx` are
|
||||
**not refactored** — they are the code path the one working report feature depends on, and
|
||||
CLAUDE.md §3 asks for surgical changes over DRY.
|
||||
|
||||
Three things genuinely cannot be shared, which settles it:
|
||||
|
||||
- [`models/Element.jsx:141-147`](frontend/src/components/ReportEditor/models/Element.jsx#L141-L147) — `getBounds()` is **inclusive** (`x + width - 1`). Right for cells, wrong for mm. Everything funnels through it via `elementUtils.js`, so changing it changes matrix behaviour.
|
||||
- [`ResizeHandles.jsx:120-121`](frontend/src/components/ReportEditor/ResizeHandles.jsx#L120-L121) — rounds pixel deltas to integer cells; line 55 hardcodes `80 * charWidth` for bands.
|
||||
- [`ObjectInspector.jsx:24-29`](frontend/src/components/ReportEditor/ObjectInspector.jsx#L24-L29) — `parseInt` only; A4 needs `parseFloat`.
|
||||
|
||||
Cost: ~200 duplicated lines of inspector data-binding/band sections. Benefit: zero
|
||||
regression risk on the working editor. Only **5 existing files** are touched, all additively.
|
||||
|
||||
---
|
||||
|
||||
## 1. Units and geometry
|
||||
|
||||
**Storage is mm floats, always. Never px, never zoom-affected values.**
|
||||
|
||||
```js
|
||||
// a4/units.js
|
||||
export const PX_PER_MM = 96 / 25.4; // 3.7795 — CSS px at 100% zoom
|
||||
export const mmToPx = (mm, zoom = 1) => mm * PX_PER_MM * zoom;
|
||||
export const pxToMm = (px, zoom = 1) => px / (PX_PER_MM * zoom);
|
||||
export const roundMm = (mm) => Math.round(mm * 10) / 10; // 0.1 mm precision
|
||||
export const PAGE = { A4: { portrait: { w: 210, h: 297 }, landscape: { w: 297, h: 210 } } };
|
||||
```
|
||||
|
||||
- A4 portrait at zoom 1 → **793.7 × 1122.5 px**; fits a 1080p station screen.
|
||||
- Zoom 50/75/100/150 %, one `zoom` prop threaded down. Every render path already goes through
|
||||
`mmToPx(v, zoom)`, so zoom stays a one-line concern.
|
||||
- **Font size in pt** (what users expect). On screen `pt * (96/72) * zoom` px; in print CSS,
|
||||
`pt` directly — CSS has real `pt` units, so no conversion at all on the print path.
|
||||
- `roundMm` on every commit prevents `12.700000000000001` in the JSON.
|
||||
|
||||
**Bounds are exclusive**, in a new `a4/geometry.js`. A 40 mm box at x=10 occupies [10, 50)
|
||||
and its right edge — what guides align to — is **50**, not 49. `getA4Bounds(el)` handles the
|
||||
`hline`/`vline` cases.
|
||||
|
||||
**Float input:** `A4Inspector` uses `parseFloat` + `step="0.1"` in a small `<MmInput>` with
|
||||
local draft-string state — otherwise typing `"12."` yields `NaN`, the guard drops it, and the
|
||||
decimal point appears not to type.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend
|
||||
|
||||
Reuse `Report`; add a discriminator. This copies the in-repo pattern from `ScaleProtocol`
|
||||
([`models.py:66-100`](backend/api/models.py#L66-L100)), which already pairs a `kind`
|
||||
CharField with a `config` JSONField.
|
||||
|
||||
**Do not repurpose `page_width`/`page_height`** — they are `IntegerField` meaning character
|
||||
columns/rows. Overloading them with mm makes `80` ambiguous and risks a matrix report
|
||||
rendering as an 80 mm page.
|
||||
|
||||
### [`backend/api/models.py:45-63`](backend/api/models.py#L45-L63)
|
||||
|
||||
```python
|
||||
class Report(models.Model):
|
||||
MATRIX = 'matrix'
|
||||
A4 = 'a4'
|
||||
KIND_CHOICES = [(MATRIX, 'Character matrix'), (A4, 'A4 free positioning')]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX)
|
||||
page_width = models.IntegerField(default=80) # chars — matrix only
|
||||
page_height = models.IntegerField(default=66) # chars — matrix only
|
||||
# mm page setup for kind='a4': page, orientation, margins{top,right,bottom,left},
|
||||
# defaultFont, defaultFontSize
|
||||
page_setup = models.JSONField(default=dict, blank=True)
|
||||
... rest unchanged ...
|
||||
```
|
||||
|
||||
`default=MATRIX` is the point: **every existing row keeps working untouched.**
|
||||
|
||||
- **New** `backend/api/migrations/0009_report_kind_page_setup.py` — two additive `AddField`s
|
||||
with defaults. No data migration, no backfill. (Latest is `0008_license_stationslot.py`.)
|
||||
- [`serializers.py:487-504`](backend/api/serializers.py#L487-L504) — add `'kind'` and
|
||||
`'page_setup'` to `fields`; add `validate_page_setup` (must be a dict).
|
||||
- [`views.py:621-644`](backend/api/views.py#L621-L644) — `filterset_fields = ['name', 'kind']`,
|
||||
enabling `?kind=a4` for the load dialog and `?name=notice_report` for Main.jsx.
|
||||
|
||||
---
|
||||
|
||||
## 3. Files
|
||||
|
||||
### New
|
||||
|
||||
```
|
||||
frontend/src/components/ReportEditor/a4/
|
||||
A4ReportEditor.jsx Orchestrator: state, save/load (sends kind + page_setup),
|
||||
load dialog (?kind=a4), shortcuts, zoom, Print button.
|
||||
Wraps the reused DataProvider + BandProvider.
|
||||
A4Canvas.jsx Core: page canvas, click-to-create, drag via useElementDrag,
|
||||
smart-guide integration, rubber-band select, margin/centre
|
||||
overlay, preview driven by layout().
|
||||
A4Toolbar.jsx Tools, zoom, orientation, Preview, Save, Load, Print
|
||||
A4Inspector.jsx Float-mm geometry (MmInput) + font controls
|
||||
+ verbatim copies of the dbtext/expr/band sections
|
||||
A4PageSetup.jsx Orientation, 4× margin mm, default font + size
|
||||
units.js Conversions, roundMm, PAGE dims
|
||||
geometry.js getA4Bounds (exclusive), a4IntersectsRect
|
||||
fonts.js Font list + CSS fallback stacks
|
||||
guides.js Smart-guide engine — pure, no React (§4)
|
||||
layout.js Band instancing + pagination + data resolution (§5)
|
||||
A4Canvas.css Page/shadow/guide/overlay styles (screen only)
|
||||
a4Elements.css Per-type styles — CSS borders, not box characters
|
||||
|
||||
elements/ A4TextField, A4DBTextField, A4ExprField, A4Rect,
|
||||
A4HLine, A4VLine, A4Band, A4ResizeHandles
|
||||
print/
|
||||
PrintDocument.jsx pages[] → absolutely-positioned mm HTML. No React-only
|
||||
tricks — this is the exact markup a future Puppeteer eats.
|
||||
print.css @page rules + .print-only visibility (§6)
|
||||
printReport.js Non-React entry for Main.jsx: report + data → layout()
|
||||
→ mount PrintDocument → window.print() → unmount
|
||||
|
||||
backend/api/migrations/0009_report_kind_page_setup.py
|
||||
```
|
||||
|
||||
### Modified — 5 files, all additive
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| [`backend/api/models.py`](backend/api/models.py) | `kind` + `page_setup` on `Report` |
|
||||
| [`backend/api/serializers.py`](backend/api/serializers.py) | both fields + `validate_page_setup` |
|
||||
| [`backend/api/views.py`](backend/api/views.py) | `filterset_fields = ['name', 'kind']` |
|
||||
| [`frontend/src/App.jsx:69`](frontend/src/App.jsx#L69) | add `/report-editor-a4` route, same `can_edit_documents` gate |
|
||||
| [`frontend/src/components/Main.jsx:585-601`](frontend/src/components/Main.jsx#L585-L601) | kind-aware Print Notice dispatch (§7) |
|
||||
|
||||
No new npm dependencies.
|
||||
|
||||
### Reused untouched
|
||||
|
||||
`DataContext.jsx`, `BandContext.jsx`,
|
||||
[`hooks/useElementDrag.jsx`](frontend/src/components/ReportEditor/hooks/useElementDrag.jsx)
|
||||
(emits **raw pixel deltas** at lines 35-37, leaving snapping to the consumer — exactly the
|
||||
seam smart guides need), `hooks/useElementSelection.jsx`, `utils/dataResolver.js`,
|
||||
`utils/expressionEvaluator.js`, `utils/bindingParser.js`, `ReportEditor.css` (dialog shell),
|
||||
`services/api.js`.
|
||||
|
||||
**One low-risk extraction:** move the sample-data block
|
||||
([`ReportEditor.jsx:71-116`](frontend/src/components/ReportEditor/ReportEditor.jsx#L71-L116))
|
||||
into `utils/sampleData.js` as a pure `buildSampleVehicleData(definitions, entries)`. Move,
|
||||
don't change — both editors call it so sample data can't drift. If it feels invasive
|
||||
mid-build, duplicate instead.
|
||||
|
||||
### A4 element JSON
|
||||
|
||||
Types prefixed (`a4-text`, …) so a mis-routed renderer fails loudly rather than silently
|
||||
mis-rendering. Positions in **mm floats**, `fontSize` in **pt**.
|
||||
|
||||
```js
|
||||
{ id, type:'a4-text', x, y, width, height, content,
|
||||
fontFamily:'Arial', fontSize:10, bold, italic, align:'left', color:'#000000' }
|
||||
{ id, type:'a4-dbtext', x, y, width, height, objectKey, fieldPath, dataType, ...font }
|
||||
{ id, type:'a4-expr', x, y, width, height, expression, dataType, ...font }
|
||||
{ id, type:'a4-rect', x, y, width, height, borderWidth:0.3, borderColor, fillColor }
|
||||
{ id, type:'a4-hline', x, y, width, thickness:0.3, color }
|
||||
{ id, type:'a4-vline', x, y, height, thickness:0.3, color }
|
||||
{ id, type:'a4-band', x:0, y, width, height, bandType, dataSource, caption,
|
||||
children:[], parentBandId }
|
||||
```
|
||||
|
||||
Lines use `width`/`height` rather than the matrix `length`, keeping bounds and resize
|
||||
uniform. Band children store `y` **relative to the band**, same as matrix
|
||||
([`ReportEditor.jsx:130-134`](frontend/src/components/ReportEditor/ReportEditor.jsx#L130-L134)),
|
||||
so the binding mental model carries over.
|
||||
|
||||
`page_setup`: `{ page:'A4', orientation:'portrait', margins:{top:15,right:15,bottom:15,left:20}, defaultFont:'Arial', defaultFontSize:10 }`
|
||||
|
||||
### Fonts
|
||||
|
||||
Since Chrome prints with installed fonts, the dropdown offers the standard Windows set —
|
||||
Arial, Times New Roman, Courier New, **Calibri, Segoe UI, Tahoma, Verdana** — all of which
|
||||
ship with Windows and cover Cyrillic. Each is emitted with a fallback stack
|
||||
(`"Calibri, Arial, sans-serif"`). Free-text entry for a customer's own corporate font is a
|
||||
one-line addition if asked for; not built speculatively.
|
||||
|
||||
Same `font-family` string is used on screen and in print, so **WYSIWYG holds by
|
||||
construction** — one font stack, one renderer engine.
|
||||
|
||||
---
|
||||
|
||||
## 4. Smart guides
|
||||
|
||||
Pure functions in `a4/guides.js` — no React, no DOM; testable, cheap per mousemove.
|
||||
|
||||
**Candidates**, built **once per drag** (held in a `useRef`, not recomputed per mousemove):
|
||||
for every non-dragged element, 3 vertical (left / centreX / right) and 3 horizontal
|
||||
(top / middleY / bottom) from its exclusive bounds. Plus page-level: `0`, `pageW/2`, `pageW`
|
||||
and each margin boundary; same for the vertical axis.
|
||||
|
||||
**Snap resolution:** the dragged element offers 3 of its own edges per axis; for each
|
||||
(myEdge × candidate) pair take `|myEdgePos − candidate.pos|`, keep the per-axis minimum,
|
||||
snap if under threshold.
|
||||
|
||||
```js
|
||||
export const SNAP_PX = 6; // threshold in SCREEN px
|
||||
const tolMm = SNAP_PX / (PX_PER_MM * zoom); // → identical feel at every zoom
|
||||
```
|
||||
|
||||
Converting the threshold **through the current zoom** is what makes the magnet feel the same
|
||||
at 50 % and 150 % — what users actually expect. At 100 % it is ≈1.6 mm.
|
||||
|
||||
**Rendering & performance:**
|
||||
- Guides are 2 absolutely-positioned 1 px divs in a `pointer-events:none` overlay, driven by
|
||||
local `A4Canvas` state — a drag re-renders the overlay, not the element tree.
|
||||
- 100 elements ≈ 600 candidates; inner loop is 3×600 float comparisons per axis per move.
|
||||
Sub-millisecond; no spatial index needed at report scale.
|
||||
- **Hold `Alt` to suppress snapping** — standard convention, essential escape hatch.
|
||||
- Multi-select drag: snap the union bounding rect, apply the delta to all selected.
|
||||
|
||||
**Arrow-key nudge** in the A4 keyboard handler: arrow = 1 mm, Shift+arrow = 0.1 mm. Highest
|
||||
-value precision affordance on a free-positioning designer, ~10 lines.
|
||||
|
||||
---
|
||||
|
||||
## 5. `layout()` — bands, data, pagination
|
||||
|
||||
Ports `calculateBandInstances` from
|
||||
[`utils/bandRenderer.js`](frontend/src/components/ReportEditor/utils/bandRenderer.js) with
|
||||
three changes:
|
||||
|
||||
1. All arithmetic in mm floats.
|
||||
2. **Drop the inclusive `-1`/`+1`.** The matrix version uses
|
||||
`band.y - previousBandDesignEnd - 1` ([line 89](frontend/src/components/ReportEditor/utils/bandRenderer.js#L89))
|
||||
and `previousBandPreviewEnd + 1 + designGap` ([line 91](frontend/src/components/ReportEditor/utils/bandRenderer.js#L91)),
|
||||
tracking ends as `band.y + band.height - 1` and `currentY - 1`
|
||||
([lines 170-171](frontend/src/components/ReportEditor/utils/bandRenderer.js#L170-L171)).
|
||||
In mm: `designGap = band.y - previousBandDesignEnd`, with
|
||||
`previousBandDesignEnd = band.y + band.height`.
|
||||
3. `calculateTotalContentHeight`'s hardcoded `minHeight = 66`
|
||||
([line 185](frontend/src/components/ReportEditor/utils/bandRenderer.js#L185)) becomes the
|
||||
page content height in mm.
|
||||
|
||||
Nested-band support (`parentBandId`) ports structurally unchanged — already recursive and
|
||||
unit-agnostic apart from those offsets.
|
||||
|
||||
**Data resolution happens inside `layout()`**, where the band's current row is known — so
|
||||
`PrintDocument` receives finished strings and stays dumb. Call `resolveDBTextValue` and
|
||||
`evaluateExpression` as plain functions; no React needed. Copy `formatValue` from
|
||||
[`DBTextField.jsx:11-33`](frontend/src/components/ReportEditor/DBTextField.jsx#L11-L33) into
|
||||
`a4/formatValue.js` — it is already duplicated verbatim in `ExpressionField.jsx`, so this
|
||||
consolidates an existing duplicate rather than creating one.
|
||||
|
||||
**Pagination** — single pass, deliberately simple:
|
||||
|
||||
1. Walk instances in flow order tracking `cursorY` and `pageIndex`.
|
||||
2. **Band instances are atomic** — never split. If `cursorY + height > contentBottom`, start
|
||||
a new page and **re-emit every `bandType === 'header'` band at the top**.
|
||||
3. `footer` bands emit at `contentBottom - height` on **every** page.
|
||||
4. `summary` emits once, after all detail instances.
|
||||
5. **Static (non-band) elements emit on page 0 only.** Anything wanted on every page belongs
|
||||
in a header/footer band — say so in the band-type dropdown's help text.
|
||||
6. Guard: an instance taller than the content area emits on a fresh page and clips rather
|
||||
than looping forever.
|
||||
|
||||
Returns `{ pages: [[{ el, x, y, text }]] }` — pure, and consumed by both the preview and
|
||||
`PrintDocument`.
|
||||
|
||||
---
|
||||
|
||||
## 6. The print path
|
||||
|
||||
`PrintDocument.jsx` maps `pages[]` to absolutely-positioned mm HTML:
|
||||
|
||||
```jsx
|
||||
<div className="print-root">
|
||||
{pages.map((els, i) => (
|
||||
<div className="a4-page" key={i}>
|
||||
{els.map(({ el, x, y, text }) => (
|
||||
<div className="a4-el" key={el.id} style={{
|
||||
left: `${x}mm`, top: `${y}mm`,
|
||||
width: `${el.width}mm`,
|
||||
fontFamily: FONT_STACKS[el.fontFamily],
|
||||
fontSize: `${el.fontSize}pt`,
|
||||
fontWeight: el.bold ? 700 : 400,
|
||||
textAlign: el.align,
|
||||
}}>{text}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
`print.css`:
|
||||
|
||||
```css
|
||||
@page { size: A4 portrait; margin: 0; } /* landscape variant when set */
|
||||
.a4-page { width: 210mm; height: 297mm; position: relative;
|
||||
overflow: hidden; page-break-after: always; }
|
||||
.a4-page:last-child { page-break-after: auto; }
|
||||
.a4-el { position: absolute; }
|
||||
|
||||
@media print {
|
||||
body > *:not(.print-root) { display: none !important; }
|
||||
.print-root { display: block !important; }
|
||||
}
|
||||
.print-root { display: none; } /* hidden on screen */
|
||||
```
|
||||
|
||||
`printReport.js` is the non-React entry (so `Main.jsx` can call it directly): take report +
|
||||
data → `layout()` → render `PrintDocument` into a detached root → `window.print()` → unmount
|
||||
on `afterprint`. It must work **outside React context** — which is exactly why `layout()` is
|
||||
pure.
|
||||
|
||||
**Save as PDF is the same call.** The operator picks "Save as PDF" (Chrome) or "Microsoft
|
||||
Print to PDF" as the destination. No second code path.
|
||||
|
||||
---
|
||||
|
||||
## 7. UI entry points
|
||||
|
||||
**Routing: a separate `/report-editor-a4` route, not a `:kind` param.** A param would force
|
||||
`ReportEditor.jsx` to become a dispatcher — modifying the one file we most want to leave
|
||||
alone. Two routes, two trees, zero coupling. `React.lazy` the A4 tree so `Main.jsx` doesn't
|
||||
pay for it.
|
||||
|
||||
**Creating:** the load dialog's "+ New Report" opens a two-button choice — *Character matrix
|
||||
(dot-matrix)* / *A4 page (laser & PDF)* — navigating to the right editor.
|
||||
|
||||
**Load dialog:** each editor fetches its own kind (`?kind=a4`), but shows cross-kind reports
|
||||
in a dimmed "Other reports" group with a badge, clicking which navigates across. Users will
|
||||
save an A4 report and wonder where it went; ~15 lines prevents that support call.
|
||||
|
||||
**Main.jsx Print Notice** becomes kind-aware:
|
||||
|
||||
```jsx
|
||||
const res = await api.get('/api/reports/?name=notice_report'); // uses the new filter
|
||||
const report = (res.data.results || res.data)[0];
|
||||
if (report?.kind === 'a4') {
|
||||
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
|
||||
await printA4Report(report, vehicleReportData); // layout → PrintDocument → print()
|
||||
} else {
|
||||
setShowNoticeReport(true); // existing modal, unchanged
|
||||
}
|
||||
```
|
||||
|
||||
This also drops the current fetch-all-then-`find` pattern in favour of a server-side filter.
|
||||
|
||||
**Inside the A4 editor:** a 🖨 Print button operating on the current in-memory report — no
|
||||
save required first, which matters for iteration speed while designing.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification
|
||||
|
||||
### The ruler test — acceptance test for mm correctness
|
||||
|
||||
Do this in Phase 4, before building anything on top of the coordinate system:
|
||||
|
||||
1. Place a 1 mm hline at **x=20, y=50, width=170** and a text field at **x=20, y=100**
|
||||
containing `АА1234ВВ` (Cyrillic).
|
||||
2. Corner crosshairs: 10 mm hlines at (10,10), (190,10), (10,287), (190,287).
|
||||
3. **Print at 100% / "Actual size"** (not "Fit to page") on real A4. Measure with a steel
|
||||
rule from the paper edge → 20 mm ± 0.5 mm (printer margin tolerance); top → 50 mm.
|
||||
4. **Save as PDF** from the same dialog; measure in Acrobat → **20.0 / 50.0 / 170.0 mm**.
|
||||
5. A consistent offset points at the `@page margin: 0` interaction with the printer's
|
||||
unprintable area — a page-setup issue, not architecture.
|
||||
|
||||
### Fonts — the point of the whole exercise
|
||||
- Design in **Calibri** and **Segoe UI**; print → the output is really Calibri and Segoe UI,
|
||||
not a substitute. Compare against the same text in Word if in doubt.
|
||||
- `АА1234ВВ` renders as Cyrillic in every offered font, **including bold**.
|
||||
- Save as PDF → **select and copy the text** in the viewer; clipboard must contain
|
||||
`АА1234ВВ` (proves real text, not raster). Ctrl+F for `1234` must hit.
|
||||
- Screen and print use the same `font-family` stack → visually identical.
|
||||
|
||||
### Print / Save-as-PDF parity
|
||||
- Same report → Print, then Save as PDF: identical page count and layout.
|
||||
- With "Headers and footers" **on**, confirm the Chrome header appears (so we know what the
|
||||
install checklist is preventing); untick it and confirm it is gone.
|
||||
- Test at "Fit to page" to see the mm error appear — confirms the 100 % requirement is real
|
||||
and worth documenting.
|
||||
|
||||
### Smart guides
|
||||
Edge-to-edge, centre-to-centre, page-centre and margin snapping each show a guide and snap.
|
||||
**Zoom to 50 % and 150 % — the grab distance must feel identical** (proves the
|
||||
px-threshold-through-zoom conversion). Alt suppresses. 100+ elements drag with no dropped
|
||||
frames in a Performance trace.
|
||||
|
||||
### Bands and pagination
|
||||
- 60 rows × 8 mm = 480 mm → **2 pages**; header band on **both**; footer on both.
|
||||
- No instance split across a page boundary.
|
||||
- **On-screen preview page count must equal printed page count** — the one check that proves
|
||||
the shared `layout()` pipeline really is shared.
|
||||
- Nested detail + subdetail renders and paginates.
|
||||
|
||||
### Matrix regression — mandatory; this is the risk being managed
|
||||
- Load `notice_report` at `/report-editor`; screenshot-diff against a pre-change capture.
|
||||
- Add/move/resize/delete each matrix element type; save → reload → unchanged.
|
||||
- Confirm the payload now carries `kind:'matrix'` and the server stores it.
|
||||
- Main.jsx → Print Notice with a **matrix** report → old modal, unchanged.
|
||||
- `python manage.py migrate` against a DB with existing rows → all get `kind='matrix'`,
|
||||
`page_setup={}`, and still load.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Scope | Testable at the end |
|
||||
|---|---|---|
|
||||
| **1** | Backend `kind`/`page_setup` + migration + filter; `/report-editor-a4` stub | `?kind=a4` filters; existing reports unaffected; route loads |
|
||||
| **2** | `units.js`, `geometry.js`, `A4Canvas` + CSS, `A4Toolbar`, `A4ReportEditor` save/load, `A4TextField`, `A4ResizeHandles`, `A4Inspector` + `MmInput`, zoom, static margin/centre overlay | Place a field, type exact mm, see it move; drag/resize in mm; zoom preserves positions; save/reload persists floats |
|
||||
| **3** | `guides.js` + drag integration + overlay + Alt-suppress + arrow nudge | Full guides checklist |
|
||||
| **4** | `layout.js` (single-page only), `PrintDocument`, `print.css`, `printReport.js`, Print button | **The ruler test.** Calibri prints as Calibri. Save as PDF works |
|
||||
| **5** | Remaining element types + inspector sections + font controls | A complete single-page notice, on screen and on paper, with live data |
|
||||
| **6** | `layout()` bands + pagination; `A4Band`, band inspector; preview driven by `layout()` | Multi-page checklist; preview pages == printed pages |
|
||||
| **7** | Main.jsx dispatch, kind chooser, cross-kind nav, `A4PageSetup`, `React.lazy`, regression sweep | Operator designs → saves → Print Notice → paper; matrix regression passes |
|
||||
|
||||
**Phase 4 deliberately precedes the remaining element types.** If exact mm printing can't be
|
||||
achieved, everything built on top is wasted — so it is de-risked early, on the simplest
|
||||
document, with a physical ruler.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Chrome header/footer, or non-100% scale, breaks mm fidelity | One-time per-station settings; documented in the install checklist and verified in Phase 4 |
|
||||
| `@page margin: 0` vs the printer's unprintable area clips edge content | Default `page_setup` margins of 15–20 mm keep content well inside; Phase 4 ruler test catches it |
|
||||
| Customer station lacks a font used in the design | Offered list is Windows-bundled fonts only; every family emitted with a fallback stack |
|
||||
| Inspector sections drift between matrix and A4 | Accepted deliberately — the price of not touching working code. Note it in the A4 file header |
|
||||
| Matrix editor regression | It is not modified; the Phase 7 sweep is belt-and-braces |
|
||||
| Later need for unattended PDF generation | `layout()` is pure and `PrintDocument` emits plain HTML+CSS — POST it to a small Puppeteer service; ~60 lines, no redesign |
|
||||
+2
-2
@@ -21,10 +21,10 @@ CORS_ALLOWED_ORIGINS=http://192.168.24.45:8100,http://localhost:8100
|
||||
|
||||
# --- Frontend (baked into the React bundle at build time) ---
|
||||
# Address the browser uses to reach the Django API
|
||||
REACT_APP_API_URL=http://192.168.24.45:8101
|
||||
VITE_API_URL=http://192.168.24.45:8101
|
||||
|
||||
# Address the browser uses to reach the serial bridge SSE stream
|
||||
REACT_APP_SERIAL_URL=http://192.168.24.45:8102
|
||||
VITE_SERIAL_URL=http://192.168.24.45:8102
|
||||
|
||||
# --- Serial bridge / test writer ---
|
||||
BAUD_RATE=9600
|
||||
|
||||
@@ -38,11 +38,16 @@ python manage.py test api.tests.test_models # Single test file
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start # Development server (port from frontend/.env, currently 5172)
|
||||
npm run build # Production build
|
||||
npm test # Run tests
|
||||
npm run dev # Vite dev server on port 5172 (set in vite.config.mjs)
|
||||
npm start # Alias for `npm run dev`
|
||||
npm run build # Production build (outputs to frontend/build/)
|
||||
npm run preview # Serve the production build locally
|
||||
npm test # Run tests (Vitest)
|
||||
```
|
||||
|
||||
Build tooling is **Vite** (dev server, build) and **Vitest** (tests); there is
|
||||
no `react-scripts`. The dev-server port lives in `vite.config.mjs`, not `.env`.
|
||||
|
||||
### Serial Bridge
|
||||
|
||||
```bash
|
||||
@@ -317,9 +322,10 @@ DB_PORT=5432
|
||||
|
||||
**Frontend:** `frontend/.env`
|
||||
```
|
||||
PORT=5172
|
||||
REACT_APP_API_URL=http://localhost:8002
|
||||
VITE_API_URL=http://localhost:8002
|
||||
```
|
||||
(Vite exposes only `VITE_*` vars, as `import.meta.env.VITE_*`. The dev-server
|
||||
port is set in `vite.config.mjs`, not here.)
|
||||
|
||||
**Serial Bridge:** `serial_bridge/.env`
|
||||
```
|
||||
|
||||
@@ -90,7 +90,7 @@ npm install
|
||||
#### b. Setup environment variables
|
||||
```bash
|
||||
copy .env.example .env
|
||||
# Ensure REACT_APP_API_URL=http://localhost:8000
|
||||
# Ensure VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
#### c. Start development server
|
||||
@@ -232,7 +232,7 @@ DATABASE_URL=sqlite:///db.sqlite3
|
||||
|
||||
### Frontend (frontend/.env)
|
||||
```env
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### Serial Bridge (serial_bridge/.env)
|
||||
@@ -252,7 +252,7 @@ DEBUG=False
|
||||
- **Database errors**: Run migrations: `python manage.py migrate`
|
||||
|
||||
### Frontend Issues
|
||||
- **Cannot connect to backend**: Ensure Django is running and `REACT_APP_API_URL` is correct
|
||||
- **Cannot connect to backend**: Ensure Django is running and `VITE_API_URL` is correct
|
||||
- **Port 3000 in use**: Kill process: `netstat -ano | findstr :3000`
|
||||
|
||||
### Serial Bridge Issues
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 6.0.1 on 2026-09-19 12:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("api", "0008_license_stationslot"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="report",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[("matrix", "Character matrix"), ("a4", "A4 free positioning")],
|
||||
default="matrix",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="report",
|
||||
name="page_setup",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -44,11 +44,22 @@ class ComPortReading(models.Model):
|
||||
|
||||
class Report(models.Model):
|
||||
"""Model to store report templates for the report editor"""
|
||||
MATRIX = 'matrix'
|
||||
A4 = 'a4'
|
||||
KIND_CHOICES = [
|
||||
(MATRIX, 'Character matrix'),
|
||||
(A4, 'A4 free positioning'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX)
|
||||
page_width = models.IntegerField(default=80)
|
||||
page_height = models.IntegerField(default=66)
|
||||
api_endpoint = models.CharField(max_length=500, blank=True, default='')
|
||||
elements = models.JSONField(default=list)
|
||||
# mm page setup for kind='a4': page, orientation, margins{top,right,bottom,left},
|
||||
# defaultFont, defaultFontSize
|
||||
page_setup = models.JSONField(default=dict, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='reports'
|
||||
|
||||
@@ -489,8 +489,8 @@ class ReportSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Report
|
||||
fields = ['id', 'name', 'page_width', 'page_height', 'api_endpoint',
|
||||
'elements', 'created_by', 'created_by_name', 'created_at', 'updated_at']
|
||||
fields = ['id', 'name', 'kind', 'page_width', 'page_height', 'api_endpoint',
|
||||
'elements', 'page_setup', 'created_by', 'created_by_name', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_by', 'created_by_name', 'created_at', 'updated_at']
|
||||
|
||||
def validate_name(self, value):
|
||||
@@ -502,3 +502,8 @@ class ReportSerializer(serializers.ModelSerializer):
|
||||
if not isinstance(value, list):
|
||||
raise serializers.ValidationError("Elements must be a list")
|
||||
return value
|
||||
|
||||
def validate_page_setup(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("page_setup must be an object")
|
||||
return value
|
||||
|
||||
@@ -631,7 +631,7 @@ class ReportViewSet(viewsets.ModelViewSet):
|
||||
queryset = Report.objects.select_related('created_by').all()
|
||||
serializer_class = ReportSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['name']
|
||||
filterset_fields = ['name', 'kind']
|
||||
search_fields = ['name']
|
||||
ordering = ['-updated_at']
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ Django==6.0.1
|
||||
djangorestframework==3.15.2
|
||||
djangorestframework-simplejwt==5.5.1
|
||||
django-cors-headers==4.6.0
|
||||
django-filter==26.1
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
sqlparse==0.5.3
|
||||
|
||||
@@ -32,6 +32,7 @@ INSTALLED_APPS = [
|
||||
'rest_framework.authtoken',
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
] + PROJECT_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -139,6 +140,7 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 100,
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
REACT_APP_API_URL: ${REACT_APP_API_URL}
|
||||
REACT_APP_SERIAL_URL: ${REACT_APP_SERIAL_URL}
|
||||
VITE_API_URL: ${VITE_API_URL}
|
||||
VITE_SERIAL_URL: ${VITE_SERIAL_URL}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8100:80"
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
PORT=5172
|
||||
REACT_APP_API_URL=http://localhost:8002
|
||||
VITE_API_URL=http://localhost:8002
|
||||
|
||||
+4
-4
@@ -9,10 +9,10 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
# These are baked into the JS bundle at build time
|
||||
ARG REACT_APP_API_URL
|
||||
ARG REACT_APP_SERIAL_URL
|
||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||
ENV REACT_APP_SERIAL_URL=$REACT_APP_SERIAL_URL
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_SERIAL_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_SERIAL_URL=$VITE_SERIAL_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1390
-15243
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -3,24 +3,18 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"dev": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -34,5 +28,11 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:8002"
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^8.57.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, Suspense, lazy } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
// import ProtectedRoute from './components/ProtectedRoute';
|
||||
@@ -14,6 +14,8 @@ import ScaleMonitor from './components/ScaleMonitor/ScaleMonitor';
|
||||
import { LicenseProvider } from './contexts/LicenseContext';
|
||||
import LicenseManager from './components/License/LicenseManager';
|
||||
|
||||
const A4ReportEditor = lazy(() => import('./components/ReportEditor/a4/A4ReportEditor'));
|
||||
|
||||
// function MainApp() {
|
||||
// const [selectedPort, setSelectedPort] = useState(null);
|
||||
// const { readings, isConnected, error } = useSerialData();
|
||||
@@ -64,13 +66,16 @@ function AppContent() {
|
||||
<NomenclatureDataProvider>
|
||||
<ScaleProtocolProvider>
|
||||
<LicenseProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
|
||||
<Route path="/scale-monitor" element={currentUser?.is_admin ? <ScaleMonitor /> : <Navigate to="/" />} />
|
||||
<Route path="/license" element={currentUser?.is_admin ? <LicenseManager /> : <Navigate to="/" />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/report-editor-a4" element={currentUser?.can_edit_documents ? <A4ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
|
||||
<Route path="/scale-monitor" element={currentUser?.is_admin ? <ScaleMonitor /> : <Navigate to="/" />} />
|
||||
<Route path="/license" element={currentUser?.is_admin ? <LicenseManager /> : <Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</LicenseProvider>
|
||||
</ScaleProtocolProvider>
|
||||
</NomenclatureDataProvider>
|
||||
|
||||
@@ -50,6 +50,65 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Header dropdown — the report editor button opens a choice of editors rather
|
||||
than navigating straight to one, since the two are not interchangeable. */
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Over the page, under the menu: any outside click closes it. */
|
||||
.nav-dropdown-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 101;
|
||||
min-width: 220px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu button + button {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu button:hover {
|
||||
background: #f2f3fa;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -10,6 +10,12 @@ function Header() {
|
||||
const navigate = useNavigate();
|
||||
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
|
||||
const [showEditorMenu, setShowEditorMenu] = useState(false);
|
||||
|
||||
const openEditor = (route) => {
|
||||
setShowEditorMenu(false);
|
||||
navigate(route);
|
||||
};
|
||||
|
||||
const getInitials = (user) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
@@ -28,13 +34,36 @@ function Header() {
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
{currentUser?.can_edit_documents && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/report-editor')}
|
||||
title="Report Editor"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
<div className="nav-dropdown">
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => setShowEditorMenu(v => !v)}
|
||||
title="Report Editor"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showEditorMenu}
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
{showEditorMenu && (
|
||||
<>
|
||||
{/* Click-away close, without a document-level listener. */}
|
||||
<div
|
||||
className="nav-dropdown-backdrop"
|
||||
onClick={() => setShowEditorMenu(false)}
|
||||
/>
|
||||
<div className="nav-dropdown-menu" role="menu">
|
||||
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{currentUser?.can_manage_entities && (
|
||||
<button
|
||||
|
||||
@@ -240,6 +240,87 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Design Notice split button — main action plus a caret that opens an explicit
|
||||
editor choice. The two halves read as one control, so they share the gradient
|
||||
and only a hairline divides them. */
|
||||
.design-notice-split {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* .weight-set-btn is width:100%; inside the split the halves are sized by flex. */
|
||||
.design-notice-split .weight-set-btn {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.design-notice-main {
|
||||
flex: 1;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.design-notice-caret {
|
||||
flex: 0 0 28px;
|
||||
padding: 8px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
/* Sits under the menu and over everything else, so a click anywhere outside
|
||||
closes it without needing a document-level listener. */
|
||||
.design-notice-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.design-notice-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 21;
|
||||
background: #fff;
|
||||
border: 1px solid #d8d8e0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.design-notice-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.design-notice-menu button + button {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.design-notice-menu button:hover {
|
||||
background: #f2f3fa;
|
||||
}
|
||||
|
||||
.design-notice-menu strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.design-notice-menu span {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Extra fields section */
|
||||
.extra-section {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
@@ -32,6 +32,8 @@ export default function Main() {
|
||||
const [error, setError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showNoticeReport, setShowNoticeReport] = useState(false);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [showDesignMenu, setShowDesignMenu] = useState(false);
|
||||
const [showManualForm, setShowManualForm] = useState(false);
|
||||
const [manualForm, setManualForm] = useState({ value: '', note: '' });
|
||||
|
||||
@@ -103,7 +105,7 @@ export default function Main() {
|
||||
// closed tab would stop the scale). The UI only supplies the string.
|
||||
useEffect(() => {
|
||||
if (!selectedProtocol) return;
|
||||
const serialUrl = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
|
||||
const serialUrl = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
|
||||
fetch(serialUrl + '/poll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -331,6 +333,103 @@ export default function Main() {
|
||||
};
|
||||
}, [selectedVehicle, netWeight, definitions, entries]);
|
||||
|
||||
// Look up the saved notice report by name. Server-side filter — ?name= is a
|
||||
// real filter now that django-filter is configured, replacing the old
|
||||
// fetch-every-report-then-.find() pattern.
|
||||
const fetchNoticeReport = async () => {
|
||||
const res = await api.get('/api/reports/?name=notice_report');
|
||||
return (res.data.results || res.data || [])[0] || null;
|
||||
};
|
||||
|
||||
// Warm the A4 print chunk on hover/focus, so the click handler's dynamic
|
||||
// import resolves from cache instead of blocking on a network round-trip
|
||||
// between the operator's click and window.print(). Cheap insurance: the
|
||||
// print dialog is not gated behind transient user activation the way
|
||||
// window.open is, but the shorter the gap the less there is to go wrong on
|
||||
// a slow station. Failure is ignored — the click path imports it anyway.
|
||||
const prefetchA4Print = () => {
|
||||
import('./ReportEditor/a4/print/printReport').catch(() => {});
|
||||
};
|
||||
|
||||
// Print Notice is kind-aware: an A4 report goes straight to the browser
|
||||
// print dialog (where "Save as PDF" is a destination), a matrix report opens
|
||||
// the existing on-screen modal exactly as before.
|
||||
const handlePrintNotice = async () => {
|
||||
if (isPrinting) return;
|
||||
setIsPrinting(true);
|
||||
try {
|
||||
const report = await fetchNoticeReport();
|
||||
if (!report) {
|
||||
setError('Report "notice_report" not found. Create it in the Report Editor first.');
|
||||
return;
|
||||
}
|
||||
if (report.kind === 'a4') {
|
||||
// Lazy so Main.jsx does not pull the A4 print tree into the
|
||||
// main bundle for operators who never print an A4 notice.
|
||||
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
|
||||
await printA4Report(report, vehicleReportData);
|
||||
} else {
|
||||
setShowNoticeReport(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to print notice:', err);
|
||||
setError('Failed to load the notice report');
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Design Notice routes to the editor that can actually edit the saved
|
||||
// report. A matrix report opened in the mm editor would read its character
|
||||
// coordinates as millimetres, so the kind decides the route, not a default.
|
||||
const handleDesignNotice = async () => {
|
||||
const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
|
||||
try {
|
||||
const report = await fetchNoticeReport();
|
||||
navigate(report?.kind === 'a4' ? '/report-editor-a4' : '/report-editor', { state });
|
||||
} catch (err) {
|
||||
// Nothing saved yet, or the lookup failed — the matrix editor is the
|
||||
// existing default and pre-fills the name so Save creates it.
|
||||
console.error('Failed to resolve notice report kind:', err);
|
||||
navigate('/report-editor', { state });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a chosen editor regardless of what is saved.
|
||||
*
|
||||
* The automatic route above is right for editing an existing notice; this is
|
||||
* for deliberately starting the OTHER kind. Opening a report in the wrong
|
||||
* editor reinterprets its coordinates — character cells as millimetres, or
|
||||
* the reverse — so a mismatch is confirmed rather than done silently.
|
||||
*/
|
||||
const handleDesignAs = async (kind) => {
|
||||
setShowDesignMenu(false);
|
||||
const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
|
||||
const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor';
|
||||
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await fetchNoticeReport();
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve notice report kind:', err);
|
||||
}
|
||||
|
||||
if (saved && saved.kind !== kind) {
|
||||
const savedLabel = saved.kind === 'a4' ? 'A4' : 'matrix';
|
||||
const targetLabel = kind === 'a4' ? 'A4' : 'matrix';
|
||||
const ok = window.confirm(
|
||||
`"notice_report" is saved as a ${savedLabel} report.\n\n` +
|
||||
`Opening it in the ${targetLabel} editor will read its positions in the ` +
|
||||
`wrong units, and saving would overwrite the ${savedLabel} layout.\n\n` +
|
||||
`Open the ${targetLabel} editor anyway?`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
navigate(route, { state });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
@@ -584,21 +683,59 @@ export default function Main() {
|
||||
<div className="weight-card-value">{netWeight} kg</div>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={() => setShowNoticeReport(true)}
|
||||
onClick={handlePrintNotice}
|
||||
onMouseEnter={prefetchA4Print}
|
||||
onFocus={prefetchA4Print}
|
||||
disabled={isPrinting}
|
||||
>
|
||||
Print Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={() => navigate('/report-editor', {
|
||||
state: {
|
||||
reportName: 'notice_report',
|
||||
vehicleData: vehicleReportData,
|
||||
}
|
||||
})}
|
||||
>
|
||||
Design Notice
|
||||
{isPrinting ? 'Preparing...' : 'Print Notice'}
|
||||
</button>
|
||||
{/* Split button: the main action routes by
|
||||
the saved report's kind; the caret opens
|
||||
an explicit choice for starting the other
|
||||
kind. */}
|
||||
<div className="design-notice-split">
|
||||
<button
|
||||
className="weight-set-btn design-notice-main"
|
||||
onClick={handleDesignNotice}
|
||||
>
|
||||
Design Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn design-notice-caret"
|
||||
onClick={() => setShowDesignMenu(v => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showDesignMenu}
|
||||
title="Choose which editor to open"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{showDesignMenu && (
|
||||
<>
|
||||
{/* Click-away closes the menu. */}
|
||||
<div
|
||||
className="design-notice-backdrop"
|
||||
onClick={() => setShowDesignMenu(false)}
|
||||
/>
|
||||
<div className="design-notice-menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('matrix')}
|
||||
>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('a4')}
|
||||
>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -350,6 +350,12 @@ function EditorCanvas({
|
||||
|
||||
// Only update if position actually changed
|
||||
if (col !== element.x || row !== element.y) {
|
||||
// A band always spans the full page width from x=0, so it moves only up
|
||||
// and down; dragging one sideways would leave a gap no child can print in.
|
||||
const move = (el, x, y) => onElementUpdate(el.id, el.type === 'band'
|
||||
? { y }
|
||||
: { x, y });
|
||||
|
||||
// If this element is selected with others, move all selected elements
|
||||
if (selectedElementIds.includes(dragData.elementId) && selectedElementIds.length > 1) {
|
||||
const deltaCol = col - element.x;
|
||||
@@ -357,11 +363,11 @@ function EditorCanvas({
|
||||
selectedElementIds.forEach(id => {
|
||||
const el = elements.find(e => e.id === id);
|
||||
if (el) {
|
||||
onElementUpdate(id, { x: el.x + deltaCol, y: el.y + deltaRow });
|
||||
move(el, el.x + deltaCol, el.y + deltaRow);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onElementUpdate(dragData.elementId, { x: col, y: row });
|
||||
move(element, col, row);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/* A4 designer canvas — screen only. Print styles arrive in Phase 4. */
|
||||
|
||||
.a4-canvas-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #8a8a8a;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.a4-page {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.a4-page.crosshair {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Margin and page-centre overlay — purely visual, never intercepts the mouse */
|
||||
.a4-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.a4-margin-line {
|
||||
position: absolute;
|
||||
border: 0 dashed #59a2e0;
|
||||
}
|
||||
|
||||
.a4-margin-line.vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.a4-margin-line.horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.a4-center-line {
|
||||
position: absolute;
|
||||
border: 0 dashed #e08db5;
|
||||
}
|
||||
|
||||
.a4-center-line.vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.a4-center-line.horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
/* Smart alignment guides — shown only while dragging.
|
||||
A magenta accent, as Figma and PowerPoint use: it reads as UI chrome rather
|
||||
than as page content, which matters on a page that is otherwise black on
|
||||
white. Stays 1px at every zoom for the same reason — a guide marks a
|
||||
position, it does not have a width on the paper. */
|
||||
.a4-guide-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.a4-guide {
|
||||
position: absolute;
|
||||
background: #ff00c8;
|
||||
}
|
||||
|
||||
.a4-guide--v {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.a4-guide--h {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Centre alignments get a distinct cyan so "centres match" is readable at a
|
||||
glance from "edges match" — the two look identical otherwise. */
|
||||
.a4-guide--element-center,
|
||||
.a4-guide--page-center {
|
||||
background: #00b8d4;
|
||||
}
|
||||
|
||||
/* Rubber-band selection rectangle */
|
||||
.a4-selection-rectangle {
|
||||
position: absolute;
|
||||
border: 1px dashed #667eea;
|
||||
background: rgba(102, 126, 234, 0.12);
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* Preview mode — the pages layout() produced, stacked with a visible break.
|
||||
Same array the print path consumes, which is what makes "preview page count
|
||||
equals printed page count" structural rather than coincidental. */
|
||||
.a4-preview-pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.a4-preview-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.a4-preview-page-break {
|
||||
width: 100%;
|
||||
padding: 6px 0 14px;
|
||||
text-align: center;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #e8e8e8;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.4);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.a4-preview-el {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import A4TextField from './elements/A4TextField';
|
||||
import A4DBTextField from './elements/A4DBTextField';
|
||||
import A4ExprField from './elements/A4ExprField';
|
||||
import A4Rect from './elements/A4Rect';
|
||||
import A4HLine from './elements/A4HLine';
|
||||
import A4VLine from './elements/A4VLine';
|
||||
import A4Band from './elements/A4Band';
|
||||
import { mmToPx, pxToMm, roundMm, getPageSize } from './units';
|
||||
import { a4IntersectsRect, clampToPage } from './geometry';
|
||||
import { buildGuideCandidates, resolveSnap, unionRect } from './guides';
|
||||
import { layout } from './layout';
|
||||
import { styleFor } from './print/PrintDocument';
|
||||
import './A4Canvas.css';
|
||||
|
||||
/**
|
||||
* One placed element in preview mode.
|
||||
*
|
||||
* Reuses PrintDocument's `styleFor` rather than restating it, then rescales the
|
||||
* millimetre values to on-screen pixels. Preview and print therefore cannot
|
||||
* drift apart in how an element LOOKS, just as sharing layout() stops them
|
||||
* drifting in where it SITS.
|
||||
*
|
||||
* mm -> px is a pure multiply by (PX_PER_MM * zoom), so every mm length in the
|
||||
* style object is converted the same way; pt font sizes convert by 96/72.
|
||||
*/
|
||||
function PreviewElement({ el, x, y, text, zoom }) {
|
||||
const mmStyle = styleFor(el, x, y);
|
||||
const scaled = {};
|
||||
|
||||
for (const [prop, value] of Object.entries(mmStyle)) {
|
||||
if (typeof value !== 'string') {
|
||||
scaled[prop] = value;
|
||||
} else if (value.endsWith('mm')) {
|
||||
scaled[prop] = `${mmToPx(parseFloat(value), zoom)}px`;
|
||||
} else if (value.endsWith('pt')) {
|
||||
scaled[prop] = `${parseFloat(value) * (96 / 72) * zoom}px`;
|
||||
} else if (value.includes('mm')) {
|
||||
// Compound values such as "0.3mm solid #000000": scale the mm token only.
|
||||
// Checked AFTER endsWith('mm') because that is the plain-length case.
|
||||
scaled[prop] = value.replace(/([\d.]+)mm/g, (_, mm) => `${mmToPx(parseFloat(mm), zoom)}px`);
|
||||
} else {
|
||||
scaled[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`a4-preview-el a4-preview-el--${el.type}`}
|
||||
style={scaled}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_WIDTH_MM = 40;
|
||||
const DEFAULT_TEXT_HEIGHT_MM = 6;
|
||||
const DEFAULT_RECT_WIDTH_MM = 50;
|
||||
const DEFAULT_RECT_HEIGHT_MM = 20;
|
||||
const DEFAULT_LINE_LENGTH_MM = 50;
|
||||
const DEFAULT_VLINE_LENGTH_MM = 30;
|
||||
const DEFAULT_THICKNESS_MM = 0.3;
|
||||
const DEFAULT_BAND_HEIGHT_MM = 20;
|
||||
|
||||
/**
|
||||
* New-element factories, one per tool.
|
||||
*
|
||||
* Each returns the element's JSON exactly as it will be stored and saved, so
|
||||
* this table is the single readable statement of every A4 element's shape.
|
||||
* `footprint` is what the page clamp uses: for a line that is its thickness on
|
||||
* the axis it does not run along, which is why it is stated separately rather
|
||||
* than read off width/height.
|
||||
*/
|
||||
const ELEMENT_FACTORIES = {
|
||||
addText: (pageSetup) => ({
|
||||
type: 'a4-text',
|
||||
width: DEFAULT_TEXT_WIDTH_MM,
|
||||
height: DEFAULT_TEXT_HEIGHT_MM,
|
||||
content: 'Text',
|
||||
...fontDefaults(pageSetup)
|
||||
}),
|
||||
addDBText: (pageSetup) => ({
|
||||
type: 'a4-dbtext',
|
||||
width: DEFAULT_TEXT_WIDTH_MM,
|
||||
height: DEFAULT_TEXT_HEIGHT_MM,
|
||||
objectKey: '',
|
||||
fieldPath: '',
|
||||
dataType: 'general',
|
||||
...fontDefaults(pageSetup)
|
||||
}),
|
||||
addExpr: (pageSetup) => ({
|
||||
type: 'a4-expr',
|
||||
width: DEFAULT_TEXT_WIDTH_MM,
|
||||
height: DEFAULT_TEXT_HEIGHT_MM,
|
||||
expression: '',
|
||||
dataType: 'general',
|
||||
...fontDefaults(pageSetup)
|
||||
}),
|
||||
addRect: () => ({
|
||||
type: 'a4-rect',
|
||||
width: DEFAULT_RECT_WIDTH_MM,
|
||||
height: DEFAULT_RECT_HEIGHT_MM,
|
||||
borderWidth: DEFAULT_THICKNESS_MM,
|
||||
borderColor: '#000000',
|
||||
fillColor: null
|
||||
}),
|
||||
addHLine: () => ({
|
||||
type: 'a4-hline',
|
||||
width: DEFAULT_LINE_LENGTH_MM,
|
||||
thickness: DEFAULT_THICKNESS_MM,
|
||||
color: '#000000'
|
||||
}),
|
||||
addVLine: () => ({
|
||||
type: 'a4-vline',
|
||||
height: DEFAULT_VLINE_LENGTH_MM,
|
||||
thickness: DEFAULT_THICKNESS_MM,
|
||||
color: '#000000'
|
||||
}),
|
||||
addBand: (pageSetup) => ({
|
||||
type: 'a4-band',
|
||||
// x and width are derived from the page's content area, never user-set —
|
||||
// see A4Band.jsx for why offering a width control would be a trap.
|
||||
x: pageSetup.margins?.left || 0,
|
||||
width: contentWidthOf(pageSetup),
|
||||
height: DEFAULT_BAND_HEIGHT_MM,
|
||||
bandType: 'detail',
|
||||
dataSource: '',
|
||||
caption: '',
|
||||
children: [],
|
||||
parentBandId: null
|
||||
})
|
||||
};
|
||||
|
||||
/** A band spans the page's content width: page width less both side margins. */
|
||||
const contentWidthOf = (pageSetup) => {
|
||||
const size = getPageSize(pageSetup);
|
||||
const m = pageSetup?.margins || {};
|
||||
return roundMm(size.w - (m.left || 0) - (m.right || 0));
|
||||
};
|
||||
|
||||
const fontDefaults = (pageSetup) => ({
|
||||
fontFamily: pageSetup.defaultFont || 'Arial',
|
||||
fontSize: pageSetup.defaultFontSize || 10,
|
||||
bold: false,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
/** Box a type occupies for clamping: lines substitute thickness for the axis
|
||||
* they do not run along, matching getA4Bounds. */
|
||||
const footprintOf = (draft) => ({
|
||||
w: (draft.type === 'a4-vline' ? draft.thickness : draft.width) || 0,
|
||||
h: (draft.type === 'a4-hline' ? draft.thickness : draft.height) || 0
|
||||
});
|
||||
|
||||
/**
|
||||
* A4 page canvas — everything positioned in millimetre floats.
|
||||
*
|
||||
* Structural reference: the matrix `EditorCanvas.jsx` (click/drag/rubber-band
|
||||
* patterns). Its grid snapping is deliberately NOT carried over: mouse pixels
|
||||
* convert straight to mm via pxToMm(px, zoom) and are rounded only to 0.1mm.
|
||||
* Snapping to margins and neighbouring elements is Phase 3.
|
||||
*/
|
||||
function A4Canvas({
|
||||
elements,
|
||||
selectedElementIds,
|
||||
onElementSelect,
|
||||
onSelectMultiple,
|
||||
onDeselectAll,
|
||||
onElementUpdate,
|
||||
onAddElement,
|
||||
onElementDelete,
|
||||
toolMode,
|
||||
pageSetup,
|
||||
zoom,
|
||||
previewMode = false,
|
||||
reportData = null
|
||||
}) {
|
||||
const pageRef = useRef(null);
|
||||
const [selectionRect, setSelectionRect] = useState(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [didDragSelect, setDidDragSelect] = useState(false);
|
||||
|
||||
// Smart-guide state. The candidate set is a ref, not state: it is built once
|
||||
// per drag and read on every mousemove, and rebuilding it per move is the one
|
||||
// thing that would make dragging laggy (100 elements is ~600 candidates).
|
||||
const guideCandidatesRef = useRef({ V: [], H: [] });
|
||||
// Alt suppresses snapping. useElementDrag forwards only pixel deltas, and it
|
||||
// is shared with the matrix editor, so the modifier is tracked here from the
|
||||
// window rather than by widening that hook's contract.
|
||||
const altPressedRef = useRef(false);
|
||||
const [activeGuides, setActiveGuides] = useState([]);
|
||||
|
||||
const pageSize = getPageSize(pageSetup);
|
||||
const pageWidthPx = mmToPx(pageSize.w, zoom);
|
||||
const pageHeightPx = mmToPx(pageSize.h, zoom);
|
||||
|
||||
/** Mouse event → page-relative millimetres. */
|
||||
const eventToMm = (e) => {
|
||||
const rect = pageRef.current.getBoundingClientRect();
|
||||
return {
|
||||
xMm: pxToMm(e.clientX - rect.left, zoom),
|
||||
yMm: pxToMm(e.clientY - rect.top, zoom)
|
||||
};
|
||||
};
|
||||
|
||||
const handlePageMouseDown = (e) => {
|
||||
if (toolMode !== 'select') return;
|
||||
const { xMm, yMm } = eventToMm(e);
|
||||
setIsSelecting(true);
|
||||
setDidDragSelect(false);
|
||||
setSelectionRect({ startX: xMm, startY: yMm, endX: xMm, endY: yMm });
|
||||
};
|
||||
|
||||
const handlePageMouseMove = (e) => {
|
||||
if (!isSelecting || toolMode !== 'select') return;
|
||||
const { xMm, yMm } = eventToMm(e);
|
||||
setSelectionRect(prev => {
|
||||
if (!prev) return prev;
|
||||
if (Math.abs(xMm - prev.startX) > 0.5 || Math.abs(yMm - prev.startY) > 0.5) {
|
||||
setDidDragSelect(true);
|
||||
}
|
||||
return { ...prev, endX: xMm, endY: yMm };
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageClick = (e) => {
|
||||
const { xMm, yMm } = eventToMm(e);
|
||||
|
||||
const factory = ELEMENT_FACTORIES[toolMode];
|
||||
|
||||
if (factory) {
|
||||
const draft = factory(pageSetup);
|
||||
const { w, h } = footprintOf(draft);
|
||||
const { x, y } = clampToPage(xMm, yMm, w, h, pageSize.w, pageSize.h);
|
||||
|
||||
onAddElement({
|
||||
// The id prefix mirrors the type so a stray element is identifiable in
|
||||
// saved JSON without cross-referencing its `type`.
|
||||
id: `${draft.type}-${Date.now()}`,
|
||||
x: roundMm(x),
|
||||
y: roundMm(y),
|
||||
// AFTER the click position, so a factory that fixes its own x wins.
|
||||
// A band's x comes from the page margins, so only the click's Y means
|
||||
// anything — taking its X would drop the band wherever the pointer
|
||||
// happened to be, and the drag lock would then preserve that mistake.
|
||||
...draft
|
||||
});
|
||||
} else if (toolMode === 'select') {
|
||||
if (!didDragSelect) {
|
||||
onDeselectAll();
|
||||
}
|
||||
setDidDragSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDragStart = useCallback((elementId) => {
|
||||
// Snapshot every selected element so a multi-drag stays relative to where it
|
||||
// began rather than accumulating rounding error per mousemove.
|
||||
const ids = selectedElementIds.includes(elementId) && selectedElementIds.length > 1
|
||||
? selectedElementIds
|
||||
: [elementId];
|
||||
|
||||
const initial = ids.map(id => {
|
||||
const el = elements.find(e => e.id === id);
|
||||
const { w, h } = footprintOf(el);
|
||||
// A band's x is derived from the page margins, never dragged — see the
|
||||
// constraint note in A4Band.jsx. Recorded per element so a band caught in
|
||||
// a mixed multi-selection still holds its x while the rest move freely.
|
||||
return { id, x: el.x, y: el.y, width: w, height: h, lockX: el.type === 'a4-band' };
|
||||
});
|
||||
|
||||
// Build the guide candidates ONCE, here. Everything being dragged is
|
||||
// excluded — an element cannot meaningfully align to itself, and including
|
||||
// it would snap it to its own starting position and never let go.
|
||||
guideCandidatesRef.current = buildGuideCandidates(elements, ids, pageSetup);
|
||||
|
||||
return { elementId, initial };
|
||||
}, [elements, selectedElementIds, pageSetup]);
|
||||
|
||||
const handleElementDrag = useCallback((dragData, deltaX, deltaY) => {
|
||||
const deltaMmX = pxToMm(deltaX, zoom);
|
||||
const deltaMmY = pxToMm(deltaY, zoom);
|
||||
|
||||
// Snap the UNION of the selection, then apply one delta to every member.
|
||||
// Snapping each element separately would let them find different candidates
|
||||
// and tear the group apart mid-drag.
|
||||
const start = unionRect(dragData.initial);
|
||||
const draft = { ...start, x: start.x + deltaMmX, y: start.y + deltaMmY };
|
||||
|
||||
let snapped = { x: draft.x, y: draft.y, guides: [] };
|
||||
if (!altPressedRef.current) {
|
||||
snapped = resolveSnap(draft, guideCandidatesRef.current, zoom);
|
||||
}
|
||||
|
||||
// The correction the snap applied to the union, re-expressed as the total
|
||||
// delta each member takes from ITS OWN start — so relative offsets hold.
|
||||
const snapDeltaX = deltaMmX + (snapped.x - draft.x);
|
||||
const snapDeltaY = deltaMmY + (snapped.y - draft.y);
|
||||
|
||||
dragData.initial.forEach(({ id, x, y, width, height, lockX }) => {
|
||||
const clamped = clampToPage(
|
||||
x + (lockX ? 0 : snapDeltaX), y + snapDeltaY, width, height, pageSize.w, pageSize.h
|
||||
);
|
||||
// A locked element reports only y, so no x write can creep in via rounding.
|
||||
onElementUpdate(id, lockX
|
||||
? { y: roundMm(clamped.y) }
|
||||
: { x: roundMm(clamped.x), y: roundMm(clamped.y) });
|
||||
});
|
||||
|
||||
setActiveGuides(snapped.guides);
|
||||
// pageSize is rebuilt every render, so depend on its two scalars instead.
|
||||
}, [zoom, onElementUpdate, pageSize.w, pageSize.h]);
|
||||
|
||||
// Guides belong to the drag, so they are cleared on any mouseup — including
|
||||
// one released outside the page, which useElementDrag also listens for.
|
||||
useEffect(() => {
|
||||
const clear = () => setActiveGuides(prev => (prev.length ? [] : prev));
|
||||
document.addEventListener('mouseup', clear);
|
||||
return () => document.removeEventListener('mouseup', clear);
|
||||
}, []);
|
||||
|
||||
// Alt is read from the window because useElementDrag (shared with the matrix
|
||||
// editor) forwards no modifier state. `blur` resets it: alt-tabbing away
|
||||
// otherwise leaves snapping permanently suppressed, since the keyup lands in
|
||||
// another window.
|
||||
useEffect(() => {
|
||||
const sync = (e) => { altPressedRef.current = e.altKey; };
|
||||
const reset = () => { altPressedRef.current = false; };
|
||||
window.addEventListener('keydown', sync);
|
||||
window.addEventListener('keyup', sync);
|
||||
window.addEventListener('blur', reset);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', sync);
|
||||
window.removeEventListener('keyup', sync);
|
||||
window.removeEventListener('blur', reset);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Rubber-band selection completes on mouseup anywhere, so a release outside
|
||||
// the page still resolves the selection instead of leaving it stuck.
|
||||
useEffect(() => {
|
||||
if (!isSelecting) return;
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
if (selectionRect) {
|
||||
const rect = {
|
||||
minX: Math.min(selectionRect.startX, selectionRect.endX),
|
||||
maxX: Math.max(selectionRect.startX, selectionRect.endX),
|
||||
minY: Math.min(selectionRect.startY, selectionRect.endY),
|
||||
maxY: Math.max(selectionRect.startY, selectionRect.endY)
|
||||
};
|
||||
// A click (no drag) must not select everything it happens to touch
|
||||
if (rect.maxX - rect.minX > 0.5 || rect.maxY - rect.minY > 0.5) {
|
||||
onSelectMultiple(
|
||||
elements.filter(el => a4IntersectsRect(el, rect)).map(el => el.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsSelecting(false);
|
||||
setSelectionRect(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
}, [isSelecting, selectionRect, elements, onSelectMultiple]);
|
||||
|
||||
const margins = pageSetup.margins || {};
|
||||
|
||||
const renderOverlay = () => (
|
||||
<div className="a4-overlay">
|
||||
<div className="a4-margin-line vertical" style={{ left: `${mmToPx(margins.left || 0, zoom)}px` }} />
|
||||
<div className="a4-margin-line vertical" style={{ left: `${mmToPx(pageSize.w - (margins.right || 0), zoom)}px` }} />
|
||||
<div className="a4-margin-line horizontal" style={{ top: `${mmToPx(margins.top || 0, zoom)}px` }} />
|
||||
<div className="a4-margin-line horizontal" style={{ top: `${mmToPx(pageSize.h - (margins.bottom || 0), zoom)}px` }} />
|
||||
<div className="a4-center-line vertical" style={{ left: `${mmToPx(pageSize.w / 2, zoom)}px` }} />
|
||||
<div className="a4-center-line horizontal" style={{ top: `${mmToPx(pageSize.h / 2, zoom)}px` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Alignment guides — 1px lines in their own pointer-events:none layer.
|
||||
*
|
||||
* Driven by local A4Canvas state so a drag re-renders this overlay rather
|
||||
* than the element tree. Width/height stay 1px at every zoom: a guide is a
|
||||
* piece of UI chrome, not page content, so it must not scale with the page.
|
||||
*/
|
||||
const renderGuides = () => {
|
||||
if (!activeGuides.length) return null;
|
||||
return (
|
||||
<div className="a4-guide-overlay">
|
||||
{activeGuides.map(({ axis, pos, kind }) => (
|
||||
<div
|
||||
key={`${axis}-${pos}`}
|
||||
className={`a4-guide a4-guide--${axis} a4-guide--${kind}`}
|
||||
style={axis === 'v'
|
||||
? { left: `${mmToPx(pos, zoom)}px` }
|
||||
: { top: `${mmToPx(pos, zoom)}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectionRect = () => {
|
||||
if (!selectionRect || !isSelecting) return null;
|
||||
const minX = Math.min(selectionRect.startX, selectionRect.endX);
|
||||
const minY = Math.min(selectionRect.startY, selectionRect.endY);
|
||||
return (
|
||||
<div
|
||||
className="a4-selection-rectangle"
|
||||
style={{
|
||||
left: `${mmToPx(minX, zoom)}px`,
|
||||
top: `${mmToPx(minY, zoom)}px`,
|
||||
width: `${mmToPx(Math.abs(selectionRect.endX - selectionRect.startX), zoom)}px`,
|
||||
height: `${mmToPx(Math.abs(selectionRect.endY - selectionRect.startY), zoom)}px`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderElement = (element) => {
|
||||
const commonProps = {
|
||||
element,
|
||||
isSelected: selectedElementIds.includes(element.id),
|
||||
isSingleSelection: selectedElementIds.length === 1 && selectedElementIds[0] === element.id,
|
||||
onSelect: (e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey),
|
||||
onUpdate: (updates) => onElementUpdate(element.id, updates),
|
||||
onDelete: () => onElementDelete(element.id),
|
||||
onDragStart: handleElementDragStart,
|
||||
onDrag: handleElementDrag,
|
||||
zoom,
|
||||
toolMode
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case 'a4-text':
|
||||
return <A4TextField key={element.id} {...commonProps} />;
|
||||
case 'a4-dbtext':
|
||||
return <A4DBTextField key={element.id} {...commonProps} />;
|
||||
case 'a4-expr':
|
||||
return <A4ExprField key={element.id} {...commonProps} />;
|
||||
case 'a4-rect':
|
||||
return <A4Rect key={element.id} {...commonProps} />;
|
||||
case 'a4-hline':
|
||||
return <A4HLine key={element.id} {...commonProps} />;
|
||||
case 'a4-vline':
|
||||
return <A4VLine key={element.id} {...commonProps} />;
|
||||
case 'a4-band':
|
||||
// Children render INSIDE the band, so their stored y stays relative to
|
||||
// it and CSS does the offset — same containment model as the matrix
|
||||
// editor, and what layout() assumes when it emits `y + child.y`.
|
||||
return (
|
||||
<A4Band key={element.id} {...commonProps}>
|
||||
{(element.children || [])
|
||||
.map(childId => elements.find(el => el.id === childId))
|
||||
.filter(Boolean)
|
||||
.map(renderElement)}
|
||||
</A4Band>
|
||||
);
|
||||
default:
|
||||
// An unknown type renders nothing rather than throwing, so one bad
|
||||
// element cannot blank the whole canvas.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Ids owned by a band — rendered by their parent, not at the top level. */
|
||||
const childIds = new Set();
|
||||
elements.forEach(el => {
|
||||
if (el.type === 'a4-band') (el.children || []).forEach(id => childIds.add(id));
|
||||
});
|
||||
|
||||
// PREVIEW MODE renders layout()'s output — the exact pages array the print
|
||||
// path consumes. That shared source is what makes "preview page count equals
|
||||
// printed page count" a structural guarantee rather than a coincidence, so
|
||||
// preview must never grow its own positioning logic.
|
||||
if (previewMode) {
|
||||
const { pages } = layout(elements, reportData, pageSetup);
|
||||
|
||||
return (
|
||||
<div className="a4-canvas-container">
|
||||
<div className="a4-preview-pages">
|
||||
{pages.map((pageElements, pageIndex) => (
|
||||
<div key={pageIndex} className="a4-preview-page-wrapper">
|
||||
<div
|
||||
className="a4-page"
|
||||
style={{ width: `${pageWidthPx}px`, height: `${pageHeightPx}px` }}
|
||||
>
|
||||
{pageElements.map(({ el, x, y, text, instanceKey }) => (
|
||||
<PreviewElement
|
||||
key={instanceKey ? `${instanceKey}-${el.id}` : el.id}
|
||||
el={el}
|
||||
x={x}
|
||||
y={y}
|
||||
text={text}
|
||||
zoom={zoom}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="a4-preview-page-break">
|
||||
Page {pageIndex + 1} of {pages.length}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="a4-canvas-container">
|
||||
<div
|
||||
ref={pageRef}
|
||||
className={`a4-page ${ELEMENT_FACTORIES[toolMode] ? 'crosshair' : ''}`}
|
||||
style={{ width: `${pageWidthPx}px`, height: `${pageHeightPx}px` }}
|
||||
onClick={handlePageClick}
|
||||
onMouseDown={handlePageMouseDown}
|
||||
onMouseMove={handlePageMouseMove}
|
||||
>
|
||||
{renderOverlay()}
|
||||
{renderSelectionRect()}
|
||||
{elements.filter(el => !childIds.has(el.id)).map(renderElement)}
|
||||
{/* Last, so guides paint above the elements they align */}
|
||||
{renderGuides()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Canvas;
|
||||
@@ -0,0 +1,61 @@
|
||||
/* A4 inspector — layered on top of the shared ObjectInspector.css shell. */
|
||||
|
||||
.a4-inspector .mm-suffix {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 6px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-buttons {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-btn {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-btn:hover {
|
||||
background: #e8eeff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-btn.active {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.a4-inspector input[type="color"] {
|
||||
padding: 1px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.a4-inspector .a4-expression-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.a4-inspector .inspector-hint {
|
||||
padding: 2px 0 6px;
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.a4-inspector input[type="checkbox"] {
|
||||
flex: 0 0 auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { roundMm } from './units';
|
||||
import { FONT_FAMILIES } from './fonts';
|
||||
import { useReportData } from '../DataContext';
|
||||
import { getAvailableObjects, getAvailableFields } from '../utils/dataResolver';
|
||||
import { getAvailableArrays } from './a4BandRenderer';
|
||||
import '../ObjectInspector.css';
|
||||
import './A4Inspector.css';
|
||||
|
||||
/**
|
||||
* NOTE ON DUPLICATION — deliberate, see the plan's "matrix editor is not
|
||||
* modified" section. The data-binding and expression sections below are adapted
|
||||
* from `ObjectInspector.jsx` rather than imported from it. That file drives the
|
||||
* one working report feature and parses geometry with parseInt; A4 needs
|
||||
* parseFloat millimetres and the `align`/`a4-*` property names. These sections
|
||||
* WILL drift from the matrix ones over time. That is the accepted price of not
|
||||
* touching working code.
|
||||
*/
|
||||
|
||||
const DATA_TYPES = [
|
||||
{ value: 'general', label: 'General' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'time', label: 'Time' },
|
||||
{ value: 'datetime', label: 'Date & Time' }
|
||||
];
|
||||
|
||||
const TYPE_LABELS = {
|
||||
'a4-text': 'Text',
|
||||
'a4-dbtext': 'Data Field',
|
||||
'a4-expr': 'Expression',
|
||||
'a4-rect': 'Rectangle',
|
||||
'a4-hline': 'Horizontal Line',
|
||||
'a4-vline': 'Vertical Line'
|
||||
};
|
||||
|
||||
/**
|
||||
* Millimetre number input with local draft-string state.
|
||||
*
|
||||
* This is why it exists: a naive `parseFloat` + `isNaN` guard makes typing "12."
|
||||
* impossible. The intermediate value parses to 12, re-renders the input as "12",
|
||||
* and the decimal point the user just typed disappears. So the input owns the
|
||||
* raw string while focused, commits whenever the draft parses, and re-syncs from
|
||||
* the prop only when the value changed elsewhere (drag, resize, load).
|
||||
*/
|
||||
export function MmInput({ label, value, onCommit, min, step = 0.1 }) {
|
||||
const [draft, setDraft] = useState(String(value ?? ''));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setDraft(String(value ?? ''));
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
|
||||
// Commit only when the draft is a complete number. "12." and "" stay in the
|
||||
// draft untouched, so the caret and the typed character survive.
|
||||
const parsed = parseFloat(next);
|
||||
if (next.trim() !== '' && !Number.isNaN(parsed) && /^-?\d*\.?\d*$/.test(next.trim())) {
|
||||
const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
|
||||
onCommit(roundMm(clamped));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
const parsed = parseFloat(draft);
|
||||
|
||||
if (draft.trim() === '' || Number.isNaN(parsed)) {
|
||||
// Abandoned edit — snap the field back to the element's real value
|
||||
setDraft(String(value ?? ''));
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
|
||||
const rounded = roundMm(clamped);
|
||||
onCommit(rounded);
|
||||
setDraft(String(rounded));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
{/* type="text" + inputMode="decimal", NOT type="number": a number input
|
||||
sanitises partial values, so the browser throws away the "12." the user
|
||||
is midway through typing and the decimal point appears not to type. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
data-step={step}
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<span className="mm-suffix">mm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Font family / size / style / alignment / colour — shared by all text types. */
|
||||
function FontSection({ element, onUpdate }) {
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Font</div>
|
||||
<div className="property-row">
|
||||
<label>Family</label>
|
||||
<select
|
||||
value={element.fontFamily || 'Arial'}
|
||||
onChange={(e) => onUpdate({ fontFamily: e.target.value })}
|
||||
>
|
||||
{FONT_FAMILIES.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Size (pt)</label>
|
||||
{/* pt, not mm: a number input is fine here because the step is 0.5 and
|
||||
users type whole or half points, never a long decimal tail. */}
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="1"
|
||||
value={element.fontSize ?? 10}
|
||||
onChange={(e) => {
|
||||
const parsed = parseFloat(e.target.value);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) onUpdate({ fontSize: parsed });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Style</label>
|
||||
<div className="a4-style-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className={`a4-style-btn ${element.bold ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ bold: !element.bold })}
|
||||
title="Bold"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`a4-style-btn ${element.italic ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ italic: !element.italic })}
|
||||
title="Italic"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Align</label>
|
||||
<select
|
||||
value={element.align || 'left'}
|
||||
onChange={(e) => onUpdate({ align: e.target.value })}
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.color || '#000000'}
|
||||
onChange={(e) => onUpdate({ color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTypeRow({ element, onUpdate }) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>Data Type</label>
|
||||
<select
|
||||
value={element.dataType || 'general'}
|
||||
onChange={(e) => onUpdate({ dataType: e.target.value })}
|
||||
>
|
||||
{DATA_TYPES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Object / field pickers driven by the live reportData.
|
||||
*
|
||||
* Both are rendered as selects with the stored value present as an option even
|
||||
* when it is absent from the current data. Otherwise loading a saved report
|
||||
* before its preview data arrives would show an empty select and silently
|
||||
* clear the binding on the next change — losing work that was on screen.
|
||||
*/
|
||||
function BindingSection({ element, onUpdate, reportData }) {
|
||||
const objects = getAvailableObjects(reportData);
|
||||
const fields = element.objectKey && reportData?.[element.objectKey]
|
||||
? getAvailableFields(reportData[element.objectKey])
|
||||
: [];
|
||||
|
||||
const objectOptions = element.objectKey && !objects.includes(element.objectKey)
|
||||
? [element.objectKey, ...objects]
|
||||
: objects;
|
||||
const fieldOptions = element.fieldPath && !fields.includes(element.fieldPath)
|
||||
? [element.fieldPath, ...fields]
|
||||
: fields;
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Data Binding</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Object</label>
|
||||
<select
|
||||
value={element.objectKey || ''}
|
||||
onChange={(e) => onUpdate({ objectKey: e.target.value, fieldPath: '' })}
|
||||
>
|
||||
<option value="">-- object --</option>
|
||||
{objectOptions.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Field</label>
|
||||
<select
|
||||
value={element.fieldPath || ''}
|
||||
onChange={(e) => onUpdate({ fieldPath: e.target.value })}
|
||||
disabled={!element.objectKey}
|
||||
>
|
||||
<option value="">-- field --</option>
|
||||
{fieldOptions.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{objects.length === 0 && (
|
||||
<div className="inspector-hint">
|
||||
No preview data loaded — type a binding after fetching data, or load a
|
||||
report with an API endpoint configured.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTypeRow element={element} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expression source.
|
||||
*
|
||||
* NOT `ExpressionEditorPanel.jsx`. That component is coupled to the matrix
|
||||
* editor in two ways that would misbehave here: it looks up the parent band
|
||||
* with `el.type === 'band'` (A4 bands are `a4-band`), and its alignment select
|
||||
* writes `element.alignment`, whereas every A4 element and renderer reads
|
||||
* `element.align`. Using it would silently produce a second, dead alignment
|
||||
* property on A4 elements. A plain textarea plus a field-insert picker covers
|
||||
* the same ground without that coupling.
|
||||
*/
|
||||
function ExpressionSection({ element, onUpdate, reportData }) {
|
||||
const [selectedField, setSelectedField] = useState('');
|
||||
|
||||
const availableFields = [];
|
||||
getAvailableObjects(reportData).forEach(objectKey => {
|
||||
getAvailableFields(reportData[objectKey])
|
||||
.forEach(f => availableFields.push(`${objectKey}.${f}`));
|
||||
});
|
||||
|
||||
const insertField = () => {
|
||||
if (!selectedField) return;
|
||||
onUpdate({ expression: `${element.expression || ''}[${selectedField}]` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Expression</div>
|
||||
|
||||
<textarea
|
||||
className="a4-expression-input"
|
||||
value={element.expression || ''}
|
||||
onChange={(e) => onUpdate({ expression: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="[vehicle.gross] - [vehicle.tare]"
|
||||
/>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Insert</label>
|
||||
<select value={selectedField} onChange={(e) => setSelectedField(e.target.value)}>
|
||||
<option value="">-- field --</option>
|
||||
{availableFields.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<button type="button" onClick={insertField} disabled={!selectedField}>
|
||||
Insert Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTypeRow element={element} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RectSection({ element, onUpdate }) {
|
||||
const hasFill = element.fillColor !== null && element.fillColor !== undefined;
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Border & Fill</div>
|
||||
|
||||
<MmInput
|
||||
label="Border"
|
||||
value={element.borderWidth ?? 0.3}
|
||||
min={0}
|
||||
onCommit={(borderWidth) => onUpdate({ borderWidth })}
|
||||
/>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Border Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.borderColor || '#000000'}
|
||||
onChange={(e) => onUpdate({ borderColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Fill</label>
|
||||
{/* `fillColor: null` is "no fill" — distinct from white, which would
|
||||
hide anything the rect is framing. A colour input cannot express
|
||||
null, so the checkbox owns that state. */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasFill}
|
||||
onChange={(e) => onUpdate({ fillColor: e.target.checked ? '#ffffff' : null })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasFill && (
|
||||
<div className="property-row">
|
||||
<label>Fill Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.fillColor}
|
||||
onChange={(e) => onUpdate({ fillColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineSection({ element, onUpdate }) {
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Line</div>
|
||||
<MmInput
|
||||
label="Thickness"
|
||||
value={element.thickness ?? 0.3}
|
||||
min={0.1}
|
||||
onCommit={(thickness) => onUpdate({ thickness })}
|
||||
/>
|
||||
<div className="property-row">
|
||||
<label>Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.color || '#000000'}
|
||||
onChange={(e) => onUpdate({ color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BAND_TYPES = [
|
||||
{ value: 'header', label: 'Header — repeats at the top of every page' },
|
||||
{ value: 'detail', label: 'Detail — one instance per data row' },
|
||||
{ value: 'subdetail', label: 'Subdetail — nested inside a detail band' },
|
||||
{ value: 'summary', label: 'Summary — once, after the detail rows' },
|
||||
{ value: 'footer', label: 'Footer — repeats at the bottom of every page' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Band properties.
|
||||
*
|
||||
* `dataSource` lists the arrays actually present in the current report data, so
|
||||
* a user picks a real one rather than typing a path that silently resolves to
|
||||
* nothing. X and Width are absent by design — a band spans the page's content
|
||||
* width, derived from the margins (see A4Band.jsx).
|
||||
*/
|
||||
function BandSection({ element, onUpdate, reportData, allElements }) {
|
||||
const arrays = getAvailableArrays(reportData);
|
||||
const otherBands = (allElements || []).filter(
|
||||
el => el.type === 'a4-band' && el.id !== element.id
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Band</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Type</label>
|
||||
<select
|
||||
value={element.bandType || 'detail'}
|
||||
onChange={(e) => onUpdate({ bandType: e.target.value })}
|
||||
>
|
||||
{BAND_TYPES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Data source</label>
|
||||
<select
|
||||
value={element.dataSource || ''}
|
||||
onChange={(e) => onUpdate({ dataSource: e.target.value })}
|
||||
>
|
||||
<option value="">(none — renders once)</option>
|
||||
{arrays.map(({ key, path }) => (
|
||||
<option key={path} value={path}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{element.bandType === 'subdetail' && (
|
||||
<div className="property-row">
|
||||
<label>Inside band</label>
|
||||
<select
|
||||
value={element.parentBandId || ''}
|
||||
onChange={(e) => onUpdate({ parentBandId: e.target.value || null })}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{otherBands.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.caption || b.bandType || b.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="property-row">
|
||||
<label>Caption</label>
|
||||
<input
|
||||
type="text"
|
||||
value={element.caption || ''}
|
||||
onChange={(e) => onUpdate({ caption: e.target.value })}
|
||||
placeholder="Shown in the editor only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="inspector-hint">
|
||||
Elements dropped inside a band repeat with it. Anything outside a band
|
||||
prints on page 1 only — put what should repeat on every page into a
|
||||
header or footer band.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function A4Inspector({ element, onUpdate, allElements }) {
|
||||
const { reportData } = useReportData();
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="object-inspector a4-inspector">
|
||||
<div className="inspector-header">Properties</div>
|
||||
<div className="inspector-empty">No element selected</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { type } = element;
|
||||
const isTextual = type === 'a4-text' || type === 'a4-dbtext' || type === 'a4-expr';
|
||||
|
||||
return (
|
||||
<div className="object-inspector a4-inspector">
|
||||
<div className="inspector-header">
|
||||
{TYPE_LABELS[type] || type} · {element.id}
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Geometry</div>
|
||||
{type !== 'a4-band' && (
|
||||
<MmInput label="X" value={element.x} min={0} onCommit={(x) => onUpdate({ x })} />
|
||||
)}
|
||||
<MmInput label="Y" value={element.y} min={0} onCommit={(y) => onUpdate({ y })} />
|
||||
{/* A line has only the dimension it runs along; offering the other
|
||||
would write a property its renderer never reads. A band's width and
|
||||
x come from the page margins, so they are not user-set either. */}
|
||||
{type !== 'a4-vline' && type !== 'a4-band' && (
|
||||
<MmInput label="Width" value={element.width} min={1}
|
||||
onCommit={(width) => onUpdate({ width })} />
|
||||
)}
|
||||
{type !== 'a4-hline' && (
|
||||
<MmInput label="Height" value={element.height} min={1}
|
||||
onCommit={(height) => onUpdate({ height })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'a4-text' && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Content</div>
|
||||
<div className="property-row">
|
||||
<label>Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={element.content || ''}
|
||||
onChange={(e) => onUpdate({ content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'a4-dbtext' && (
|
||||
<BindingSection element={element} onUpdate={onUpdate} reportData={reportData} />
|
||||
)}
|
||||
|
||||
{type === 'a4-expr' && (
|
||||
<ExpressionSection element={element} onUpdate={onUpdate} reportData={reportData} />
|
||||
)}
|
||||
|
||||
{type === 'a4-rect' && <RectSection element={element} onUpdate={onUpdate} />}
|
||||
|
||||
{(type === 'a4-hline' || type === 'a4-vline') && (
|
||||
<LineSection element={element} onUpdate={onUpdate} />
|
||||
)}
|
||||
|
||||
{type === 'a4-band' && (
|
||||
<BandSection
|
||||
element={element}
|
||||
onUpdate={onUpdate}
|
||||
reportData={reportData}
|
||||
allElements={allElements}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTextual && <FontSection element={element} onUpdate={onUpdate} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Inspector;
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Kind chooser — sits above the load dialog (z-index 1000 in ReportEditor.css). */
|
||||
|
||||
.a4-kind-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.a4-kind-dialog {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
width: 460px;
|
||||
max-width: 92vw;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.a4-kind-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.a4-kind-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.a4-kind-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.a4-kind-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.a4-kind-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.a4-kind-option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.a4-kind-option:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f3ff;
|
||||
}
|
||||
|
||||
.a4-kind-option-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.a4-kind-option-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.a4-kind-option-desc {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Cross-kind group in the load dialog — dimmed, because these are reports the
|
||||
current editor cannot open; clicking one navigates to the other editor. */
|
||||
.load-dialog-group-title {
|
||||
padding: 10px 4px 4px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.load-dialog-item--other {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.load-dialog-item--other:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.load-dialog-kind-badge {
|
||||
margin-left: 8px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9px;
|
||||
background: #e8e8e8;
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import './A4KindChooser.css';
|
||||
|
||||
/**
|
||||
* "What kind of report?" — shown by the load dialog's + New Report button.
|
||||
*
|
||||
* Two kinds exist and they are not interchangeable: a matrix report is a
|
||||
* character grid for a dot-matrix printer, an A4 report is millimetres for a
|
||||
* laser printer or PDF. Picking the wrong one is only discovered after the
|
||||
* layout has been drawn, so the choice is made up front rather than being a
|
||||
* setting to change later (there is no converter between the two).
|
||||
*
|
||||
* @param {(kind: 'matrix'|'a4') => void} onChoose
|
||||
*/
|
||||
function A4KindChooser({ onChoose, onCancel }) {
|
||||
return (
|
||||
<div className="a4-kind-overlay" onClick={onCancel}>
|
||||
<div className="a4-kind-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="a4-kind-header">
|
||||
<h2>New report</h2>
|
||||
<button className="a4-kind-close" onClick={onCancel} title="Cancel">×</button>
|
||||
</div>
|
||||
<div className="a4-kind-options">
|
||||
<button className="a4-kind-option" onClick={() => onChoose('matrix')}>
|
||||
<span className="a4-kind-option-icon">▦</span>
|
||||
<span className="a4-kind-option-title">Character matrix</span>
|
||||
<span className="a4-kind-option-desc">
|
||||
80 × 66 character grid for a dot-matrix printer. Frames and lines
|
||||
drawn with box characters.
|
||||
</span>
|
||||
</button>
|
||||
<button className="a4-kind-option" onClick={() => onChoose('a4')}>
|
||||
<span className="a4-kind-option-icon">📄</span>
|
||||
<span className="a4-kind-option-title">A4 page</span>
|
||||
<span className="a4-kind-option-desc">
|
||||
Free millimetre positioning for a laser printer or Save as PDF.
|
||||
Any installed font.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4KindChooser;
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Page setup dialog — a modal, not a docked panel, because it is opened
|
||||
occasionally and would otherwise compete with the inspector for the left rail. */
|
||||
|
||||
.a4-page-setup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.a4-page-setup {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
width: 340px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.a4-page-setup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.a4-page-setup-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.a4-page-setup-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.a4-page-setup-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.a4-page-setup-body {
|
||||
padding: 8px 18px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.a4-page-setup-dims,
|
||||
.a4-page-setup-hint {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.a4-page-setup-dims {
|
||||
color: #667eea;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { MmInput } from './A4Inspector';
|
||||
import { FONT_FAMILIES } from './fonts';
|
||||
import { PAGE } from './units';
|
||||
import '../ObjectInspector.css';
|
||||
import './A4Inspector.css';
|
||||
import './A4PageSetup.css';
|
||||
|
||||
/**
|
||||
* Page setup panel: orientation, the four margins in mm, and the defaults new
|
||||
* elements inherit.
|
||||
*
|
||||
* Margins are NOT cosmetic. A band's x/width are derived from the content area
|
||||
* (A4Canvas `contentWidthOf`), so changing a margin has to reflow every existing
|
||||
* band — otherwise a band keeps the width it was created with and silently
|
||||
* overhangs the new margin. `A4ReportEditor.handlePageSetupChange` owns that
|
||||
* reflow; this panel only reports the new setup.
|
||||
*
|
||||
* Margins use MmInput for the same reason every other mm field does: a
|
||||
* `type="number"` input discards the intermediate "1." and the decimal point
|
||||
* appears not to type. Font size is pt with a 0.5 step, matching the inspector.
|
||||
*/
|
||||
|
||||
const MARGIN_FIELDS = [
|
||||
{ key: 'top', label: 'Top' },
|
||||
{ key: 'right', label: 'Right' },
|
||||
{ key: 'bottom', label: 'Bottom' },
|
||||
{ key: 'left', label: 'Left' }
|
||||
];
|
||||
|
||||
const FONT_SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48];
|
||||
|
||||
function A4PageSetup({ pageSetup, onChange, onClose }) {
|
||||
const setup = pageSetup || {};
|
||||
const margins = setup.margins || {};
|
||||
const orientation = setup.orientation === 'landscape' ? 'landscape' : 'portrait';
|
||||
const size = PAGE.A4[orientation];
|
||||
|
||||
const handleMarginChange = (key, value) => {
|
||||
onChange({ ...setup, margins: { ...margins, [key]: value } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="a4-page-setup-overlay" onClick={onClose}>
|
||||
<div className="a4-page-setup" onClick={e => e.stopPropagation()}>
|
||||
<div className="a4-page-setup-header">
|
||||
<h2>Page Setup</h2>
|
||||
<button className="a4-page-setup-close" onClick={onClose} title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div className="a4-page-setup-body a4-inspector">
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Page</div>
|
||||
<div className="property-row">
|
||||
<label>Orientation</label>
|
||||
<select
|
||||
value={orientation}
|
||||
onChange={e => onChange({ ...setup, orientation: e.target.value })}
|
||||
>
|
||||
<option value="portrait">Portrait</option>
|
||||
<option value="landscape">Landscape</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="a4-page-setup-dims">
|
||||
A4 {orientation} — {size.w} × {size.h} mm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Margins (mm)</div>
|
||||
{MARGIN_FIELDS.map(({ key, label }) => (
|
||||
<MmInput
|
||||
key={key}
|
||||
label={label}
|
||||
value={margins[key] ?? 0}
|
||||
min={0}
|
||||
onCommit={(v) => handleMarginChange(key, v)}
|
||||
/>
|
||||
))}
|
||||
<div className="a4-page-setup-hint">
|
||||
Bands span the content width, so changing a side margin resizes
|
||||
every band in the report.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Defaults for new elements</div>
|
||||
<div className="property-row">
|
||||
<label>Font</label>
|
||||
<select
|
||||
value={setup.defaultFont || 'Arial'}
|
||||
onChange={e => onChange({ ...setup, defaultFont: e.target.value })}
|
||||
>
|
||||
{FONT_FAMILIES.map(f => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Size (pt)</label>
|
||||
<select
|
||||
value={setup.defaultFontSize || 10}
|
||||
onChange={e => onChange({ ...setup, defaultFontSize: parseFloat(e.target.value) })}
|
||||
>
|
||||
{FONT_SIZES.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="a4-page-setup-hint">
|
||||
Applies to elements created from now on. Existing elements keep
|
||||
the font they were given.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4PageSetup;
|
||||
@@ -0,0 +1,21 @@
|
||||
/* A4 editor shell — layered on the shared ReportEditor.css. */
|
||||
|
||||
/* The inspector is position:fixed on the left, so the canvas keeps clear of it. */
|
||||
.a4-editor-layout {
|
||||
margin-left: 250px;
|
||||
}
|
||||
|
||||
.toolbar-zoom {
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar-zoom:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { DataProvider, useReportData } from '../DataContext';
|
||||
import { BandProvider } from '../BandContext';
|
||||
import { useNomenclatureData } from '../../../contexts/NomenclatureDataContext';
|
||||
import { roundMm, getPageSize } from './units';
|
||||
import A4Toolbar from './A4Toolbar';
|
||||
import A4Canvas from './A4Canvas';
|
||||
import A4Inspector from './A4Inspector';
|
||||
import A4PageSetup from './A4PageSetup';
|
||||
import A4KindChooser from './A4KindChooser';
|
||||
import { buildSampleVehicleData } from './sampleData';
|
||||
import { printA4Report } from './print/printReport';
|
||||
import { buildCalibrationReport } from './calibrationReport';
|
||||
import api from '../../../services/api';
|
||||
import '../ReportEditor.css';
|
||||
import './A4ReportEditor.css';
|
||||
|
||||
/**
|
||||
* A4 free-positioning report designer.
|
||||
*
|
||||
* Deliberately a sibling of the matrix editor rather than a refactor of it:
|
||||
* the matrix tree is the code path the one working report feature depends on.
|
||||
* Where logic looks similar it is copied, not extracted (see the plan's
|
||||
* "matrix editor is not modified" section). Inspector sections will therefore
|
||||
* drift between the two editors — that is the accepted price.
|
||||
*/
|
||||
|
||||
/** A band's x/width come from the page content area, never from the user, so a
|
||||
* margin change must resize every existing band. Without this a band keeps the
|
||||
* width it was created with and overhangs (or falls short of) the new margin,
|
||||
* and — because layout() positions band children relative to the band — the
|
||||
* whole printed page drifts. Mirrors A4Canvas's `contentWidthOf`. */
|
||||
const reflowBands = (elements, pageSetup) => {
|
||||
const size = getPageSize(pageSetup);
|
||||
const m = pageSetup?.margins || {};
|
||||
const x = roundMm(m.left || 0);
|
||||
const width = roundMm(size.w - (m.left || 0) - (m.right || 0));
|
||||
|
||||
return elements.map(el =>
|
||||
el.type === 'a4-band' ? { ...el, x, width } : el
|
||||
);
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SETUP = {
|
||||
page: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial',
|
||||
defaultFontSize: 10
|
||||
};
|
||||
|
||||
/** Arrow key → [property to change, direction]. */
|
||||
const NUDGE_AXES = {
|
||||
ArrowLeft: ['x', -1],
|
||||
ArrowRight: ['x', 1],
|
||||
ArrowUp: ['y', -1],
|
||||
ArrowDown: ['y', 1]
|
||||
};
|
||||
|
||||
const createEmptyReport = () => ({
|
||||
name: 'Untitled A4 Report',
|
||||
kind: 'a4',
|
||||
pageSetup: { ...DEFAULT_PAGE_SETUP, margins: { ...DEFAULT_PAGE_SETUP.margins } },
|
||||
apiEndpoint: '',
|
||||
elements: []
|
||||
});
|
||||
|
||||
/** API row (snake_case, page_setup possibly `{}`) → the editor's in-memory shape.
|
||||
* Shared by the load dialog and the router-state auto-load so the two cannot
|
||||
* disagree about defaults. */
|
||||
const fromApiReport = (data) => {
|
||||
const loadedSetup = data.page_setup && Object.keys(data.page_setup).length > 0
|
||||
? data.page_setup
|
||||
: DEFAULT_PAGE_SETUP;
|
||||
|
||||
const pageSetup = {
|
||||
...DEFAULT_PAGE_SETUP,
|
||||
...loadedSetup,
|
||||
margins: { ...DEFAULT_PAGE_SETUP.margins, ...(loadedSetup.margins || {}) }
|
||||
};
|
||||
|
||||
return {
|
||||
name: data.name,
|
||||
kind: 'a4',
|
||||
pageSetup,
|
||||
apiEndpoint: data.api_endpoint || '',
|
||||
// Reflow on the way in as well as on a margin change: a report saved before
|
||||
// bands were pinned can hold a band at an arbitrary x, and nothing else
|
||||
// would ever straighten it — the drag lock keeps x, it does not correct it.
|
||||
elements: reflowBands(data.elements || [], pageSetup)
|
||||
};
|
||||
};
|
||||
|
||||
function A4ReportEditorContent() {
|
||||
const [report, setReport] = useState(createEmptyReport);
|
||||
const [selectedElementIds, setSelectedElementIds] = useState([]);
|
||||
const [toolMode, setToolMode] = useState('select');
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const { reportData, setData } = useReportData();
|
||||
|
||||
const [reportId, setReportId] = useState(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
const [savedReports, setSavedReports] = useState([]);
|
||||
const [otherKindReports, setOtherKindReports] = useState([]);
|
||||
const [loadingReports, setLoadingReports] = useState(false);
|
||||
const [showPageSetup, setShowPageSetup] = useState(false);
|
||||
const [showKindChooser, setShowKindChooser] = useState(false);
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { definitions, entries, isLoaded } = useNomenclatureData();
|
||||
|
||||
/**
|
||||
* Auto-load the report named in router state — how "Design Notice" in
|
||||
* Main.jsx lands on the right document with the operator's live vehicle on it.
|
||||
*
|
||||
* Ported from the matrix editor (ReportEditor.jsx:41-67) with one change: the
|
||||
* lookup is a server-side filter (`?kind=a4&name=...`) rather than
|
||||
* fetch-all-then-find, now that django-filter is actually wired up. Filtering
|
||||
* on kind matters — a matrix report of the same name must not be loaded into
|
||||
* the mm editor, where its character coordinates would read as millimetres.
|
||||
*
|
||||
* Runs once on mount, deliberately: re-running on a location change would
|
||||
* discard unsaved edits.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { reportName, vehicleData } = location.state || {};
|
||||
if (!reportName) return;
|
||||
|
||||
api.get(`/api/reports/?kind=a4&name=${encodeURIComponent(reportName)}`)
|
||||
.then(res => {
|
||||
const found = (res.data.results || res.data || [])[0];
|
||||
if (found) {
|
||||
setReport(fromApiReport(found));
|
||||
setReportId(found.id);
|
||||
} else {
|
||||
// Nothing saved under that name yet — pre-fill it so Save creates it.
|
||||
setReport(prev => ({ ...prev, name: reportName }));
|
||||
}
|
||||
if (vehicleData) setData(vehicleData);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to auto-load report:', err);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* No vehicle data passed (someone opened the editor directly) → synthesise
|
||||
* sample data once nomenclatures have loaded, so field bindings can be picked
|
||||
* and previewed against something. Ported from ReportEditor.jsx:71-116.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLoaded || location.state?.vehicleData) return;
|
||||
setData(buildSampleVehicleData(definitions, entries));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoaded]);
|
||||
|
||||
/**
|
||||
* Add an element, adopting it into a band if it was dropped inside one.
|
||||
*
|
||||
* Ported from the matrix editor (ReportEditor.jsx:118-162) and kept in mm.
|
||||
* A child's stored `y` becomes RELATIVE to its band, which is what both the
|
||||
* canvas (children render inside the band element) and layout() (`y + child.y`
|
||||
* per instance) assume. A band is never itself adopted — nesting is set
|
||||
* explicitly via parentBandId, not by position.
|
||||
*/
|
||||
const handleAddElement = (element) => {
|
||||
setReport(prev => {
|
||||
const containingBand = element.type === 'a4-band'
|
||||
? null
|
||||
: prev.elements.find(el =>
|
||||
el.type === 'a4-band' &&
|
||||
element.y >= el.y &&
|
||||
element.y < el.y + el.height
|
||||
);
|
||||
|
||||
const adopted = containingBand
|
||||
? { ...element, y: roundMm(element.y - containingBand.y) }
|
||||
: element;
|
||||
|
||||
let elements = [...prev.elements, adopted];
|
||||
if (containingBand) {
|
||||
elements = elements.map(el =>
|
||||
el.id === containingBand.id
|
||||
? { ...el, children: [...(el.children || []), adopted.id] }
|
||||
: el
|
||||
);
|
||||
}
|
||||
|
||||
return { ...prev, elements };
|
||||
});
|
||||
setSelectedElementIds([element.id]);
|
||||
setToolMode('select');
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an element. Growing or shrinking a band pushes everything below it,
|
||||
* so bands stay in flow order and cannot silently overlap — the matrix
|
||||
* editor's behaviour (ReportEditor.jsx:164-199), in mm.
|
||||
*/
|
||||
const handleElementUpdate = useCallback((elementId, updates) => {
|
||||
setReport(prev => {
|
||||
const element = prev.elements.find(el => el.id === elementId);
|
||||
|
||||
if (element?.type === 'a4-band' &&
|
||||
updates.height !== undefined &&
|
||||
updates.height !== element.height) {
|
||||
const delta = updates.height - element.height;
|
||||
const bandEnd = element.y + element.height;
|
||||
const childIds = new Set(element.children || []);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
elements: prev.elements.map(el => {
|
||||
if (el.id === elementId) return { ...el, ...updates };
|
||||
// Children move WITH the band (their y is relative), so only
|
||||
// elements that are both outside it and below it shift.
|
||||
if (!childIds.has(el.id) && el.y >= bandEnd) {
|
||||
return { ...el, y: roundMm(el.y + delta) };
|
||||
}
|
||||
return el;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
elements: prev.elements.map(el =>
|
||||
el.id === elementId ? { ...el, ...updates } : el
|
||||
)
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Deleting a band deletes its children too — an orphan keeps a relative y
|
||||
* with nothing to be relative to, and would jump to the page origin. */
|
||||
const handleElementDelete = useCallback((elementId) => {
|
||||
setReport(prev => {
|
||||
const element = prev.elements.find(el => el.id === elementId);
|
||||
const doomed = new Set([elementId]);
|
||||
if (element?.type === 'a4-band') {
|
||||
(element.children || []).forEach(id => doomed.add(id));
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
elements: prev.elements
|
||||
.filter(el => !doomed.has(el.id))
|
||||
// Drop the id from any band that owned it.
|
||||
.map(el => el.type === 'a4-band' && el.children?.some(id => doomed.has(id))
|
||||
? { ...el, children: el.children.filter(id => !doomed.has(id)) }
|
||||
: el
|
||||
)
|
||||
};
|
||||
});
|
||||
setSelectedElementIds(prev => prev.filter(id => id !== elementId));
|
||||
}, []);
|
||||
|
||||
const handleSelectElement = useCallback((elementId, addToSelection = false) => {
|
||||
setSelectedElementIds(prev => {
|
||||
if (addToSelection) {
|
||||
return prev.includes(elementId)
|
||||
? prev.filter(id => id !== elementId)
|
||||
: [...prev, elementId];
|
||||
}
|
||||
// Clicking an element that is part of a multi-selection keeps that
|
||||
// selection, so the whole group can be dragged together.
|
||||
if (prev.length > 1 && prev.includes(elementId)) return prev;
|
||||
return [elementId];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectMultiple = useCallback((ids) => setSelectedElementIds(ids), []);
|
||||
const handleDeselectAll = useCallback(() => setSelectedElementIds([]), []);
|
||||
|
||||
const handleToolChange = (newTool) => {
|
||||
setToolMode(newTool);
|
||||
if (newTool !== 'select') setSelectedElementIds([]);
|
||||
};
|
||||
|
||||
// Save — the backend also has page_width/page_height, but they are character
|
||||
// columns/rows for matrix reports and carry model defaults (80/66), so an A4
|
||||
// report simply omits them rather than overloading them with millimetres.
|
||||
const handleSave = async () => {
|
||||
if (!report.name.trim()) {
|
||||
alert('Please enter a report name');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: report.name,
|
||||
kind: 'a4',
|
||||
page_setup: report.pageSetup,
|
||||
api_endpoint: report.apiEndpoint,
|
||||
elements: report.elements
|
||||
};
|
||||
|
||||
if (reportId) {
|
||||
await api.put(`/api/reports/${reportId}/`, payload);
|
||||
} else {
|
||||
const res = await api.post('/api/reports/', payload);
|
||||
setReportId(res.data.id);
|
||||
}
|
||||
alert('Report saved successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to save report:', err);
|
||||
alert('Failed to save report: ' + (err.response?.data?.detail || err.message));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Prints the in-memory report, so an unsaved design can be checked on paper
|
||||
// immediately. Phase 5 will pass real resolved data in place of {}.
|
||||
const handlePrint = useCallback(() => {
|
||||
printA4Report(report, {});
|
||||
}, [report]);
|
||||
|
||||
// Loads the ruler-test fixture. Deliberately not persisted to the backend —
|
||||
// it is a measurement tool, not a report, and leaving rows behind in the
|
||||
// customer's report list would be noise.
|
||||
const handleLoadCalibration = useCallback(() => {
|
||||
if (!window.confirm('Replace the current report with the calibration sheet?')) return;
|
||||
setReport(buildCalibrationReport());
|
||||
setReportId(null);
|
||||
setSelectedElementIds([]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* List A4 reports, plus matrix ones in a dimmed group.
|
||||
*
|
||||
* The cross-kind group is not a nicety: an operator who saves an A4 report and
|
||||
* then opens the matrix editor finds it missing and files a support call. The
|
||||
* fetches are independent, so a failure of the secondary one must not blank
|
||||
* the primary list — hence the separate catch rather than a Promise.all.
|
||||
*/
|
||||
const handleLoadClick = async () => {
|
||||
setLoadingReports(true);
|
||||
setShowLoadDialog(true);
|
||||
try {
|
||||
const res = await api.get('/api/reports/?kind=a4');
|
||||
setSavedReports(res.data.results || res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reports list:', err);
|
||||
alert('Failed to load reports list');
|
||||
} finally {
|
||||
setLoadingReports(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.get('/api/reports/?kind=matrix');
|
||||
setOtherKindReports(res.data.results || res.data || []);
|
||||
} catch (err) {
|
||||
// Non-fatal — the A4 list above is what the dialog is for.
|
||||
console.error('Failed to load other-kind reports:', err);
|
||||
setOtherKindReports([]);
|
||||
}
|
||||
};
|
||||
|
||||
/** Open a matrix report where it can actually be edited. Router state mirrors
|
||||
* what Main.jsx sends, so the matrix editor's own auto-load picks it up. */
|
||||
const handleOpenOtherKind = (r) => {
|
||||
navigate('/report-editor', {
|
||||
state: { reportName: r.name, vehicleData: location.state?.vehicleData }
|
||||
});
|
||||
};
|
||||
|
||||
const handleChooseKind = (kind) => {
|
||||
setShowKindChooser(false);
|
||||
if (kind === 'a4') {
|
||||
startNewReport();
|
||||
return;
|
||||
}
|
||||
setShowLoadDialog(false);
|
||||
navigate('/report-editor');
|
||||
};
|
||||
|
||||
/** Page setup changes reflow bands in the same commit as the setup itself, so
|
||||
* the two can never be briefly inconsistent. */
|
||||
const handlePageSetupChange = useCallback((pageSetup) => {
|
||||
setReport(prev => ({
|
||||
...prev,
|
||||
pageSetup,
|
||||
elements: reflowBands(prev.elements, pageSetup)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleLoadReport = async (id) => {
|
||||
try {
|
||||
const res = await api.get(`/api/reports/${id}/`);
|
||||
const data = res.data;
|
||||
setReport(fromApiReport(data));
|
||||
setReportId(data.id);
|
||||
setSelectedElementIds([]);
|
||||
setShowLoadDialog(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load report:', err);
|
||||
alert('Failed to load report');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReport = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this report?')) return;
|
||||
try {
|
||||
await api.delete(`/api/reports/${id}/`);
|
||||
setSavedReports(prev => prev.filter(r => r.id !== id));
|
||||
if (reportId === id) setReportId(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete report:', err);
|
||||
alert('Failed to delete report');
|
||||
}
|
||||
};
|
||||
|
||||
const startNewReport = () => {
|
||||
setReport(createEmptyReport());
|
||||
setReportId(null);
|
||||
setSelectedElementIds([]);
|
||||
setShowLoadDialog(false);
|
||||
};
|
||||
|
||||
/** "+ New Report" asks which kind first. The two are not convertible, so the
|
||||
* choice has to be made before any layout work, not discovered after it. */
|
||||
const handleNewReport = () => {
|
||||
if (report.elements.length > 0) {
|
||||
if (!window.confirm('Discard current report and create a new one?')) return;
|
||||
}
|
||||
setShowKindChooser(true);
|
||||
};
|
||||
|
||||
// Keyboard shortcuts. Typing in an input must never delete elements or hijack
|
||||
// Ctrl+A, so text entry short-circuits everything except Escape.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
const tag = e.target.tagName;
|
||||
const isTextEntry = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setSelectedElementIds([]);
|
||||
setToolMode('select');
|
||||
return;
|
||||
}
|
||||
if (isTextEntry) return;
|
||||
|
||||
if (e.key === 'Delete' && selectedElementIds.length > 0) {
|
||||
setReport(prev => ({
|
||||
...prev,
|
||||
elements: prev.elements.filter(el => !selectedElementIds.includes(el.id))
|
||||
}));
|
||||
setSelectedElementIds([]);
|
||||
} else if (e.key.toLowerCase() === 'a' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
setSelectedElementIds(report.elements.map(el => el.id));
|
||||
} else if (NUDGE_AXES[e.key] && selectedElementIds.length > 0) {
|
||||
// Arrow nudge: 1mm, or 0.1mm with Shift — the storage precision, so
|
||||
// Shift is as fine as the format can express. preventDefault stops the
|
||||
// canvas scrolling out from under the element being nudged.
|
||||
e.preventDefault();
|
||||
const [axis, sign] = NUDGE_AXES[e.key];
|
||||
const step = sign * (e.shiftKey ? 0.1 : 1);
|
||||
setReport(prev => ({
|
||||
...prev,
|
||||
elements: prev.elements.map(el =>
|
||||
selectedElementIds.includes(el.id)
|
||||
? { ...el, [axis]: roundMm(el[axis] + step) }
|
||||
: el
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedElementIds, report.elements]);
|
||||
|
||||
const selectedElement = selectedElementIds.length === 1
|
||||
? report.elements.find(el => el.id === selectedElementIds[0])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="report-editor">
|
||||
<A4Toolbar
|
||||
toolMode={toolMode}
|
||||
onToolChange={handleToolChange}
|
||||
zoom={zoom}
|
||||
onZoomChange={setZoom}
|
||||
reportName={report.name}
|
||||
onReportNameChange={(name) => setReport(prev => ({ ...prev, name }))}
|
||||
onSave={handleSave}
|
||||
onLoad={handleLoadClick}
|
||||
onPrint={handlePrint}
|
||||
onPageSetup={() => setShowPageSetup(true)}
|
||||
onLoadCalibration={handleLoadCalibration}
|
||||
previewMode={previewMode}
|
||||
onTogglePreview={() => setPreviewMode(p => !p)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<div className="editor-layout a4-editor-layout">
|
||||
<A4Canvas
|
||||
elements={report.elements}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onElementSelect={handleSelectElement}
|
||||
onSelectMultiple={handleSelectMultiple}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onElementUpdate={handleElementUpdate}
|
||||
onElementDelete={handleElementDelete}
|
||||
onAddElement={handleAddElement}
|
||||
toolMode={toolMode}
|
||||
pageSetup={report.pageSetup}
|
||||
zoom={zoom}
|
||||
previewMode={previewMode}
|
||||
reportData={reportData}
|
||||
/>
|
||||
</div>
|
||||
{/* The inspector edits the design, so it is meaningless over a preview. */}
|
||||
{selectedElement && !previewMode && (
|
||||
<A4Inspector
|
||||
element={selectedElement}
|
||||
allElements={report.elements}
|
||||
reportData={reportData}
|
||||
onUpdate={(updates) => handleElementUpdate(selectedElement.id, updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLoadDialog && (
|
||||
<div className="load-dialog-overlay" onClick={() => setShowLoadDialog(false)}>
|
||||
<div className="load-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="load-dialog-header">
|
||||
<h2>Load A4 Report</h2>
|
||||
<button className="load-dialog-close" onClick={() => setShowLoadDialog(false)}>×</button>
|
||||
</div>
|
||||
<div className="load-dialog-actions">
|
||||
<button className="load-dialog-new-btn" onClick={handleNewReport}>
|
||||
+ New Report
|
||||
</button>
|
||||
</div>
|
||||
<div className="load-dialog-content">
|
||||
{loadingReports ? (
|
||||
<div className="load-dialog-loading">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{savedReports.length === 0 ? (
|
||||
<div className="load-dialog-empty">No saved A4 reports</div>
|
||||
) : (
|
||||
<ul className="load-dialog-list">
|
||||
{savedReports.map(r => (
|
||||
<li
|
||||
key={r.id}
|
||||
className={`load-dialog-item ${reportId === r.id ? 'load-dialog-item--current' : ''}`}
|
||||
>
|
||||
<div className="load-dialog-item-info" onClick={() => handleLoadReport(r.id)}>
|
||||
<span className="load-dialog-item-name">{r.name}</span>
|
||||
<span className="load-dialog-item-date">
|
||||
{new Date(r.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="load-dialog-item-delete"
|
||||
onClick={() => handleDeleteReport(r.id)}
|
||||
title="Delete report"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Matrix reports, dimmed. Listed so a report saved in the
|
||||
other editor is visibly elsewhere rather than apparently
|
||||
lost; clicking one opens the editor that can edit it. */}
|
||||
{otherKindReports.length > 0 && (
|
||||
<>
|
||||
<div className="load-dialog-group-title">Other reports</div>
|
||||
<ul className="load-dialog-list">
|
||||
{otherKindReports.map(r => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="load-dialog-item load-dialog-item--other"
|
||||
title="Opens in the character matrix editor"
|
||||
>
|
||||
<div className="load-dialog-item-info" onClick={() => handleOpenOtherKind(r)}>
|
||||
<span className="load-dialog-item-name">
|
||||
{r.name}
|
||||
<span className="load-dialog-kind-badge">matrix</span>
|
||||
</span>
|
||||
<span className="load-dialog-item-date">
|
||||
{new Date(r.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPageSetup && (
|
||||
<A4PageSetup
|
||||
pageSetup={report.pageSetup}
|
||||
onChange={handlePageSetupChange}
|
||||
onClose={() => setShowPageSetup(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showKindChooser && (
|
||||
<A4KindChooser
|
||||
onChoose={handleChooseKind}
|
||||
onCancel={() => setShowKindChooser(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function A4ReportEditor() {
|
||||
return (
|
||||
<DataProvider>
|
||||
<BandProvider>
|
||||
<A4ReportEditorContent />
|
||||
</BandProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4ReportEditor;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import '../Toolbar.css';
|
||||
|
||||
const ZOOM_LEVELS = [0.5, 0.75, 1, 1.5];
|
||||
|
||||
/** Creation tools, in the order they appear. Adding a type is one row here
|
||||
* plus one factory in A4Canvas and one case in its render switch. */
|
||||
const TOOLS = [
|
||||
{ mode: 'addText', label: 'T Text', title: 'Add Text Field' },
|
||||
{ mode: 'addDBText', label: '{} Data', title: 'Add Data Field (bound to a report field)' },
|
||||
{ mode: 'addExpr', label: 'Σ Expr', title: 'Add Expression Field (computed)' },
|
||||
{ mode: 'addRect', label: '▭ Rect', title: 'Add Rectangle' },
|
||||
{ mode: 'addHLine', label: '─ H-Line', title: 'Add Horizontal Line' },
|
||||
{ mode: 'addVLine', label: '│ V-Line', title: 'Add Vertical Line' },
|
||||
{ mode: 'addBand', label: '▤ Band', title: 'Add Band (repeats once per data row)' }
|
||||
];
|
||||
|
||||
/** Toolbar for the A4 designer. */
|
||||
function A4Toolbar({
|
||||
toolMode,
|
||||
onToolChange,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
reportName,
|
||||
onReportNameChange,
|
||||
onSave,
|
||||
onLoad,
|
||||
onPrint,
|
||||
onPageSetup,
|
||||
onLoadCalibration,
|
||||
previewMode = false,
|
||||
onTogglePreview,
|
||||
isSaving = false
|
||||
}) {
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
className={`toolbar-button ${toolMode === 'select' ? 'active' : ''}`}
|
||||
onClick={() => onToolChange('select')}
|
||||
title="Select Tool"
|
||||
>
|
||||
↖️ Select
|
||||
</button>
|
||||
{TOOLS.map(({ mode, label, title }) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`toolbar-button ${toolMode === mode ? 'active' : ''}`}
|
||||
onClick={() => onToolChange(mode)}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="toolbar-separator"></div>
|
||||
<div className="toolbar-group">
|
||||
<label className="toolbar-label">Zoom:</label>
|
||||
<select
|
||||
className="toolbar-zoom"
|
||||
value={zoom}
|
||||
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
|
||||
title="Zoom level"
|
||||
>
|
||||
{ZOOM_LEVELS.map(z => (
|
||||
<option key={z} value={z}>{Math.round(z * 100)}%</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="toolbar-separator"></div>
|
||||
<div className="toolbar-group toolbar-group--file">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-report-name"
|
||||
value={reportName || ''}
|
||||
onChange={(e) => onReportNameChange(e.target.value)}
|
||||
placeholder="Report name"
|
||||
title="Report name"
|
||||
/>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
title="Save Report"
|
||||
>
|
||||
{isSaving ? '...' : '💾 Save'}
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onLoad}
|
||||
title="Load Report"
|
||||
>
|
||||
📂 Load
|
||||
</button>
|
||||
{/* Preview renders layout()'s pages — the same output the print path
|
||||
consumes, so its page count is the printed page count. */}
|
||||
<button
|
||||
className={`toolbar-button ${previewMode ? 'active' : ''}`}
|
||||
onClick={onTogglePreview}
|
||||
title="Preview with data, paginated as it will print"
|
||||
>
|
||||
👁 Preview
|
||||
</button>
|
||||
{/* Prints the CURRENT in-memory report — no save required first, which
|
||||
is what makes design iteration quick. */}
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onPrint}
|
||||
title="Print (choose 'Save as PDF' as the destination for a file)"
|
||||
>
|
||||
🖨 Print
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-separator"></div>
|
||||
<div className="toolbar-group">
|
||||
{/* Orientation and margins live here rather than on the canvas: they
|
||||
change the page every element sits on, so they are a document
|
||||
property, not a selection property. */}
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onPageSetup}
|
||||
title="Page setup — orientation, margins, default font"
|
||||
>
|
||||
⚙ Page Setup
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onLoadCalibration}
|
||||
title="Load the calibration report for the ruler test (replaces the current report)"
|
||||
>
|
||||
📐 Calibration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Toolbar;
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Phase 6 — bands and multi-page pagination.
|
||||
*
|
||||
* The load-bearing assertion in this file is that layout() is the SINGLE source
|
||||
* of pagination: preview and print both consume its `pages` array, so their page
|
||||
* counts cannot drift. Everything else here protects the arithmetic that feeds
|
||||
* it — band instancing in millimetres, atomic instances, repeating headers and
|
||||
* footers, and static elements staying on page 1.
|
||||
*
|
||||
* The mm arithmetic is the part most likely to regress: the matrix engine this
|
||||
* was ported from uses INCLUSIVE cell offsets (`- 1` / `+ 1`), which are wrong
|
||||
* for continuous millimetres. A gap test below pins that.
|
||||
*/
|
||||
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
// React 18's concurrent act() needs this flag, or every unmount logs a warning.
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
import A4Canvas from '../A4Canvas';
|
||||
import PrintDocument from '../print/PrintDocument';
|
||||
import { layout } from '../layout';
|
||||
import { calculateBandInstances } from '../a4BandRenderer';
|
||||
|
||||
const PAGE_SETUP = {
|
||||
page: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial',
|
||||
defaultFontSize: 10
|
||||
};
|
||||
|
||||
const CONTENT_BOTTOM = 297 - 15;
|
||||
|
||||
const band = (over = {}) => ({
|
||||
id: 'band-1',
|
||||
type: 'a4-band',
|
||||
x: 20,
|
||||
y: 15,
|
||||
width: 175,
|
||||
height: 8,
|
||||
bandType: 'detail',
|
||||
dataSource: 'rows',
|
||||
caption: '',
|
||||
children: [],
|
||||
parentBandId: null,
|
||||
...over
|
||||
});
|
||||
|
||||
const text = (over = {}) => ({
|
||||
id: 'text-1',
|
||||
type: 'a4-text',
|
||||
x: 20,
|
||||
y: 1,
|
||||
width: 60,
|
||||
height: 6,
|
||||
content: 'X',
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 10,
|
||||
bold: false,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
color: '#000000',
|
||||
...over
|
||||
});
|
||||
|
||||
const rows = (n) => Array.from({ length: n }, (_, i) => ({ name: `Row ${i + 1}` }));
|
||||
|
||||
describe('band instancing (millimetres)', () => {
|
||||
test('one instance per data row, stacked by height', () => {
|
||||
const b = band({ height: 8, children: ['text-1'] });
|
||||
const instances = calculateBandInstances([b], { rows: rows(3) }, [b, text()]);
|
||||
|
||||
expect(instances).toHaveLength(3);
|
||||
expect(instances[0].y).toBeCloseTo(15, 5);
|
||||
expect(instances[1].y).toBeCloseTo(23, 5);
|
||||
expect(instances[2].y).toBeCloseTo(31, 5);
|
||||
});
|
||||
|
||||
test('a band with no dataSource renders exactly once', () => {
|
||||
const b = band({ dataSource: '', bandType: 'header' });
|
||||
const instances = calculateBandInstances([b], {}, [b]);
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].data).toBeNull();
|
||||
});
|
||||
|
||||
test('the design gap between bands is preserved exactly, with no off-by-one', () => {
|
||||
// The matrix engine subtracts 1 here because rows are inclusive cells.
|
||||
// In mm a 5mm gap must stay 5mm — this is the ported arithmetic's real risk.
|
||||
const first = band({ id: 'b1', y: 15, height: 10, dataSource: '' });
|
||||
const second = band({ id: 'b2', y: 30, height: 10, dataSource: '' });
|
||||
const instances = calculateBandInstances([first, second], {}, [first, second]);
|
||||
|
||||
const gap = instances[1].y - (instances[0].y + instances[0].height);
|
||||
expect(gap).toBeCloseTo(5, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
const multiPage = () => {
|
||||
const header = band({
|
||||
id: 'hdr', y: 15, height: 10, bandType: 'header',
|
||||
dataSource: '', children: ['hdr-text']
|
||||
});
|
||||
const detail = band({
|
||||
id: 'det', y: 30, height: 8, bandType: 'detail',
|
||||
dataSource: 'rows', children: ['cell']
|
||||
});
|
||||
const elements = [
|
||||
header,
|
||||
text({ id: 'hdr-text', y: 2, content: 'HEADER' }),
|
||||
detail,
|
||||
text({ id: 'cell', type: 'a4-dbtext', y: 1, objectKey: '', fieldPath: 'name', dataType: 'general' })
|
||||
];
|
||||
return layout(elements, { rows: rows(60) }, PAGE_SETUP);
|
||||
};
|
||||
|
||||
test('60 rows of 8mm overflow onto a second page', () => {
|
||||
const { pages } = multiPage();
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('a header band repeats at the top of every page', () => {
|
||||
const { pages } = multiPage();
|
||||
pages.forEach(page => {
|
||||
expect(page.some(p => p.el.id === 'hdr-text')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('every data row is emitted exactly once across all pages', () => {
|
||||
const { pages } = multiPage();
|
||||
const values = pages.flat().filter(p => p.el.id === 'cell').map(p => p.text);
|
||||
expect(values).toHaveLength(60);
|
||||
expect(new Set(values).size).toBe(60);
|
||||
});
|
||||
|
||||
test('no instance is split across a page boundary', () => {
|
||||
const { pages } = multiPage();
|
||||
pages.flat().forEach(({ el, y }) => {
|
||||
const h = el.type === 'a4-hline' ? el.thickness : (el.height || 0);
|
||||
expect(y + h).toBeLessThanOrEqual(CONTENT_BOTTOM + 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
test('a footer band repeats at the bottom of every page', () => {
|
||||
const footer = band({
|
||||
id: 'ftr', y: 270, height: 8, bandType: 'footer',
|
||||
dataSource: '', children: ['ftr-text']
|
||||
});
|
||||
const detail = band({ id: 'det', y: 30, height: 8, dataSource: 'rows', children: [] });
|
||||
const { pages } = layout(
|
||||
[footer, text({ id: 'ftr-text', y: 1, content: 'FOOTER' }), detail],
|
||||
{ rows: rows(60) },
|
||||
PAGE_SETUP
|
||||
);
|
||||
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
pages.forEach(page => {
|
||||
expect(page.some(p => p.el.id === 'ftr-text')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('static elements print on page 1 only', () => {
|
||||
const detail = band({ id: 'det', y: 40, height: 8, dataSource: 'rows', children: [] });
|
||||
const { pages } = layout(
|
||||
[text({ id: 'static', y: 20, content: 'ONCE' }), detail],
|
||||
{ rows: rows(60) },
|
||||
PAGE_SETUP
|
||||
);
|
||||
|
||||
expect(pages[0].filter(p => p.el.id === 'static')).toHaveLength(1);
|
||||
expect(pages.slice(1).flat().filter(p => p.el.id === 'static')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('an instance taller than the page emits once and does not loop forever', () => {
|
||||
const huge = band({ id: 'huge', y: 15, height: 400, dataSource: 'rows', children: ['cell'] });
|
||||
const { pages } = layout(
|
||||
[huge, text({ id: 'cell', y: 1 })],
|
||||
{ rows: rows(2) },
|
||||
PAGE_SETUP
|
||||
);
|
||||
|
||||
const emitted = pages.flat().filter(p => p.el.id === 'cell');
|
||||
expect(emitted).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('band children resolve against their own row, not the top-level data', () => {
|
||||
const detail = band({ id: 'det', y: 15, height: 8, dataSource: 'rows', children: ['cell'] });
|
||||
const cell = text({
|
||||
id: 'cell', type: 'a4-dbtext', y: 1,
|
||||
objectKey: '', fieldPath: 'name', dataType: 'general'
|
||||
});
|
||||
const { pages } = layout([detail, cell], { rows: rows(3) }, PAGE_SETUP);
|
||||
|
||||
const values = pages.flat().filter(p => p.el.id === 'cell').map(p => p.text);
|
||||
expect(values).toEqual(['Row 1', 'Row 2', 'Row 3']);
|
||||
});
|
||||
|
||||
test('Cyrillic survives band resolution', () => {
|
||||
const detail = band({ id: 'det', y: 15, height: 8, dataSource: 'rows', children: ['cell'] });
|
||||
const cell = text({
|
||||
id: 'cell', type: 'a4-dbtext', y: 1,
|
||||
objectKey: '', fieldPath: 'plate', dataType: 'general'
|
||||
});
|
||||
const { pages } = layout([detail, cell], { rows: [{ plate: 'АА1234ВВ' }] }, PAGE_SETUP);
|
||||
|
||||
expect(pages.flat().find(p => p.el.id === 'cell').text).toBe('АА1234ВВ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview and print consume the same pages', () => {
|
||||
const build = () => {
|
||||
const header = band({
|
||||
id: 'hdr', y: 15, height: 10, bandType: 'header', dataSource: '', children: ['hdr-text']
|
||||
});
|
||||
const detail = band({
|
||||
id: 'det', y: 30, height: 8, dataSource: 'rows', children: ['cell']
|
||||
});
|
||||
return {
|
||||
elements: [
|
||||
header,
|
||||
text({ id: 'hdr-text', y: 2, content: 'HEADER' }),
|
||||
detail,
|
||||
text({ id: 'cell', type: 'a4-dbtext', y: 1, objectKey: '', fieldPath: 'name' })
|
||||
],
|
||||
data: { rows: rows(60) }
|
||||
};
|
||||
};
|
||||
|
||||
let container;
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
container = null;
|
||||
});
|
||||
|
||||
test('preview page count === layout page count === print page count', () => {
|
||||
const { elements, data } = build();
|
||||
const { pages } = layout(elements, data, PAGE_SETUP);
|
||||
|
||||
// Preview
|
||||
const previewRoot = createRoot(container);
|
||||
act(() => {
|
||||
previewRoot.render(
|
||||
<A4Canvas
|
||||
elements={elements}
|
||||
selectedElementIds={[]}
|
||||
onElementSelect={() => {}}
|
||||
onSelectMultiple={() => {}}
|
||||
onDeselectAll={() => {}}
|
||||
onElementUpdate={() => {}}
|
||||
onElementDelete={() => {}}
|
||||
onAddElement={() => {}}
|
||||
toolMode="select"
|
||||
pageSetup={PAGE_SETUP}
|
||||
zoom={1}
|
||||
previewMode
|
||||
reportData={data}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const previewPages = container.querySelectorAll('.a4-preview-page-wrapper').length;
|
||||
act(() => previewRoot.unmount());
|
||||
|
||||
// Print
|
||||
const printRoot = createRoot(container);
|
||||
act(() => {
|
||||
printRoot.render(<PrintDocument pages={pages} pageSetup={PAGE_SETUP} />);
|
||||
});
|
||||
const printPages = container.querySelectorAll('.a4-print-page').length;
|
||||
act(() => printRoot.unmount());
|
||||
|
||||
expect(previewPages).toBe(pages.length);
|
||||
expect(printPages).toBe(pages.length);
|
||||
expect(previewPages).toBe(printPages);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bands do not print themselves', () => {
|
||||
test('only children reach the page, never the band box', () => {
|
||||
const detail = band({ id: 'det', y: 15, height: 8, dataSource: 'rows', children: ['cell'] });
|
||||
const { pages } = layout([detail, text({ id: 'cell', y: 1 })], { rows: rows(2) }, PAGE_SETUP);
|
||||
|
||||
expect(pages.flat().some(p => p.el.type === 'a4-band')).toBe(false);
|
||||
expect(pages.flat().filter(p => p.el.id === 'cell')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Preview/print must agree with the designer about WHERE a band sits.
|
||||
*
|
||||
* Both halves of the same complaint: a band previewed at the top-left of the
|
||||
* paper rather than where it was drawn inside the margins.
|
||||
*/
|
||||
describe('band placement matches the design', () => {
|
||||
test('a band previews at the Y it was placed at, not the top margin', () => {
|
||||
// Placed well down the page, with no data so it renders exactly once.
|
||||
const b = band({ y: 120, height: 10, dataSource: '', children: ['text-1'] });
|
||||
const child = text({ id: 'text-1', x: 5, y: 2 });
|
||||
|
||||
const { pages } = layout([b, child], {}, PAGE_SETUP);
|
||||
const [placed] = pages[0];
|
||||
|
||||
// Design Y (120) + the child's band-relative y (2) — NOT contentTop (15).
|
||||
expect(placed.y).toBeCloseTo(122, 6);
|
||||
});
|
||||
|
||||
test("a child's x is measured from the band's left edge, not the paper edge", () => {
|
||||
const b = band({ y: 40, dataSource: '', children: ['text-1'] });
|
||||
const child = text({ id: 'text-1', x: 5, y: 1 });
|
||||
|
||||
const { pages } = layout([b, child], {}, PAGE_SETUP);
|
||||
const [placed] = pages[0];
|
||||
|
||||
// Band x is the 20mm left margin, child sits 5mm into it => 25mm.
|
||||
expect(placed.x).toBeCloseTo(25, 6);
|
||||
// The bug put it at 5mm — inside the margin, against the paper edge.
|
||||
expect(placed.x).not.toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
test('designed gaps still hold once a band grows with its rows', () => {
|
||||
const first = band({ id: 'b1', y: 50, height: 10, dataSource: 'rows', children: [] });
|
||||
const second = band({ id: 'b2', y: 80, height: 10, dataSource: '', children: ['text-1'] });
|
||||
const child = text({ id: 'text-1', x: 0, y: 0 });
|
||||
|
||||
// Design: first band 50..60, second at 80 => a drawn gap of 20mm.
|
||||
// 3 rows grow the first band to 50..80, and the 20mm gap is kept AFTER it.
|
||||
const { pages } = layout([first, second, child], { rows: rows(3) }, PAGE_SETUP);
|
||||
const [placed] = pages[0];
|
||||
|
||||
expect(placed.y).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
test('a band pushed past its design Y by growth is not pulled back up', () => {
|
||||
const first = band({ id: 'b1', y: 50, height: 10, dataSource: 'rows' });
|
||||
const second = band({ id: 'b2', y: 70, height: 10, dataSource: '', children: ['text-1'] });
|
||||
const child = text({ id: 'text-1', x: 0, y: 0 });
|
||||
|
||||
// 5 rows => first band runs 50..100, overrunning the second's design y (70).
|
||||
const { pages } = layout([first, second, child], { rows: rows(5) }, PAGE_SETUP);
|
||||
const [placed] = pages[0];
|
||||
|
||||
// Flow wins: the second band sits after the first, never overlapping it.
|
||||
expect(placed.y).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The banded-report model: everything prints where it was drawn, and the only
|
||||
* thing that moves anything is a band GROWING as it repeats per data row.
|
||||
*/
|
||||
describe('growth pushes what is below it', () => {
|
||||
const header = (over = {}) => band({
|
||||
id: 'hdr', y: 75, height: 12, bandType: 'header', dataSource: '',
|
||||
children: ['h-text'], ...over
|
||||
});
|
||||
const detail = (over = {}) => band({
|
||||
id: 'det', y: 90, height: 30, bandType: 'detail', dataSource: 'rows',
|
||||
children: ['d-text'], ...over
|
||||
});
|
||||
const above = text({ id: 'above', x: 20, y: 60, content: 'title' });
|
||||
const below = text({ id: 'below', x: 20, y: 130, content: 'total' });
|
||||
const hText = text({ id: 'h-text', x: 5, y: 2, content: 'Col' });
|
||||
const dText = text({ id: 'd-text', x: 8, y: 2, content: 'Row' });
|
||||
|
||||
const at = (pages, id, nth = 0) =>
|
||||
pages.flat().filter(p => p.el.id === id)[nth];
|
||||
|
||||
test('with one row, everything keeps its design position', () => {
|
||||
const els = [above, header(), hText, detail({ dataSource: '' }), dText, below];
|
||||
const { pages } = layout(els, {}, PAGE_SETUP);
|
||||
|
||||
expect(at(pages, 'above').y).toBeCloseTo(60, 6); // static, untouched
|
||||
expect(at(pages, 'h-text').y).toBeCloseTo(77, 6); // header at ITS y, not contentTop
|
||||
expect(at(pages, 'd-text').y).toBeCloseTo(92, 6);
|
||||
expect(at(pages, 'below').y).toBeCloseTo(130, 6);
|
||||
});
|
||||
|
||||
test('a static below a grown band keeps its drawn gap', () => {
|
||||
const els = [above, header(), hText, detail(), dText, below];
|
||||
const { pages } = layout(els, { rows: rows(4) }, PAGE_SETUP);
|
||||
|
||||
// 4 rows x 30mm from y=90 => the band now ends at 210.
|
||||
// `below` was drawn 10mm under the band's design bottom (120), so 210 + 10.
|
||||
expect(at(pages, 'below').y).toBeCloseTo(220, 6);
|
||||
// A static ABOVE every band does not move.
|
||||
expect(at(pages, 'above').y).toBeCloseTo(60, 6);
|
||||
});
|
||||
|
||||
test('the gap is measured from the LAST row, not the first', () => {
|
||||
// Regression: every instance of one band shares a design position, so
|
||||
// keying the displacement on the instance's own laid-out y measured the gap
|
||||
// from row 1 and let the static sit on top of rows 2..n.
|
||||
const els = [detail(), dText, below];
|
||||
const { pages } = layout(els, { rows: rows(4) }, PAGE_SETUP);
|
||||
|
||||
const lastRow = at(pages, 'd-text', 3);
|
||||
expect(at(pages, 'below').y).toBeGreaterThan(lastRow.y);
|
||||
});
|
||||
|
||||
test('a header prints where drawn, then repeats on a continuation page', () => {
|
||||
const els = [header({ y: 40, height: 10 }), hText, detail({ y: 55, height: 20 }), dText];
|
||||
const { pages } = layout(els, { rows: rows(12) }, PAGE_SETUP);
|
||||
|
||||
expect(pages.length).toBe(2);
|
||||
// Page 1: at its DESIGN y, not hoisted to the top margin.
|
||||
expect(at(pages, 'h-text').y).toBeCloseTo(42, 6);
|
||||
// Page 2: repeated at the top, because the rows it captions continue there.
|
||||
const onPage2 = pages[1].filter(p => p.el.id === 'h-text');
|
||||
expect(onPage2).toHaveLength(1);
|
||||
expect(onPage2[0].y).toBeCloseTo(PAGE_SETUP.margins.top + 2, 6);
|
||||
});
|
||||
|
||||
test('a header does not repeat when nothing overflows', () => {
|
||||
const els = [header({ y: 40, height: 10 }), hText, detail({ y: 55, height: 20 }), dText];
|
||||
const { pages } = layout(els, { rows: rows(2) }, PAGE_SETUP);
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
expect(pages[0].filter(p => p.el.id === 'h-text')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a static pushed past the bottom margin follows onto the next page', () => {
|
||||
const els = [detail({ y: 30, height: 20 }), dText, text({ id: 'below', x: 20, y: 60 })];
|
||||
const { pages } = layout(els, { rows: rows(12) }, PAGE_SETUP);
|
||||
|
||||
const placed = at(pages, 'below');
|
||||
expect(placed).toBeTruthy(); // not silently dropped
|
||||
expect(placed.y).toBeLessThanOrEqual(CONTENT_BOTTOM);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Phase 7 — UI entry points.
|
||||
*
|
||||
* The thing worth testing here is DISPATCH, not rendering: given a saved report
|
||||
* of a particular kind, does the operator end up in the right place? Getting
|
||||
* that wrong is silent and expensive — a matrix report opened in the mm editor
|
||||
* reads its character coordinates as millimetres and looks like a corrupt
|
||||
* layout rather than a routing bug.
|
||||
*
|
||||
* Main.jsx itself is not mounted: it needs the auth, serial, nomenclature,
|
||||
* scale-protocol and licence providers, so mounting it would test the provider
|
||||
* stack rather than the dispatch. The dispatch is reproduced here against the
|
||||
* same mocked api module Main.jsx uses, with the branch conditions copied from
|
||||
* it verbatim — see `noticeDispatch` below.
|
||||
*/
|
||||
|
||||
// A DELIBERATE behavioural stub, not a module-resolution workaround: these
|
||||
// tests drive the report auto-load by controlling what the API returns (see the
|
||||
// `api.get.mockResolvedValue` calls below), so the axios instance that
|
||||
// services/api.js builds at import time has to be a mock. `create` and
|
||||
// `interceptors` are here because api.js configures an instance on import.
|
||||
vi.mock('axios', () => {
|
||||
const instance = {
|
||||
get: vi.fn(() => Promise.resolve({ data: [] })),
|
||||
post: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
put: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() }
|
||||
}
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { ...instance, create: vi.fn(() => instance) }
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../contexts/NomenclatureDataContext', () => ({
|
||||
useNomenclatureData: () => ({
|
||||
definitions: global.__NOM_DEFS__ || {},
|
||||
entries: global.__NOM_ENTRIES__ || {},
|
||||
isLoaded: global.__NOM_LOADED__ !== false
|
||||
})
|
||||
}));
|
||||
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import api from '../../../../services/api';
|
||||
import A4ReportEditor from '../A4ReportEditor';
|
||||
import { buildSampleVehicleData } from '../sampleData';
|
||||
import { getPageSize } from '../units';
|
||||
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container;
|
||||
let root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
vi.clearAllMocks();
|
||||
global.__NOM_DEFS__ = {};
|
||||
global.__NOM_ENTRIES__ = {};
|
||||
global.__NOM_LOADED__ = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
};
|
||||
|
||||
const buttonMatching = (re) =>
|
||||
[...container.querySelectorAll('button')].find(b => re.test(b.textContent));
|
||||
|
||||
const click = (el) =>
|
||||
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main.jsx's Print / Design Notice dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The branch under test, mirroring Main.jsx's handlePrintNotice /
|
||||
* handleDesignNotice. Kept as one function so a change to the real dispatch
|
||||
* that is not made here shows up as an obviously stale test rather than a
|
||||
* passing one.
|
||||
*/
|
||||
async function noticeDispatch({ onModal, onA4Print, onNavigate }) {
|
||||
const res = await api.get('/api/reports/?name=notice_report');
|
||||
const report = (res.data.results || res.data || [])[0] || null;
|
||||
if (!report) return { found: false };
|
||||
|
||||
if (report.kind === 'a4') {
|
||||
onA4Print(report);
|
||||
} else {
|
||||
onModal();
|
||||
}
|
||||
onNavigate(report.kind === 'a4' ? '/report-editor-a4' : '/report-editor');
|
||||
return { found: true, kind: report.kind };
|
||||
}
|
||||
|
||||
describe('Print / Design Notice dispatch', () => {
|
||||
test('a matrix notice_report opens the existing modal, not the A4 print path', async () => {
|
||||
api.get.mockResolvedValueOnce({
|
||||
data: [{ id: 1, name: 'notice_report', kind: 'matrix', elements: [] }]
|
||||
});
|
||||
|
||||
const onModal = vi.fn();
|
||||
const onA4Print = vi.fn();
|
||||
const onNavigate = vi.fn();
|
||||
const out = await noticeDispatch({ onModal, onA4Print, onNavigate });
|
||||
|
||||
expect(out).toEqual({ found: true, kind: 'matrix' });
|
||||
expect(onModal).toHaveBeenCalledTimes(1);
|
||||
expect(onA4Print).not.toHaveBeenCalled();
|
||||
// Design Notice must route to the MATRIX editor for a matrix report.
|
||||
expect(onNavigate).toHaveBeenCalledWith('/report-editor');
|
||||
});
|
||||
|
||||
test('an a4 notice_report runs the A4 print path, not the modal', async () => {
|
||||
const a4Report = {
|
||||
id: 2, name: 'notice_report', kind: 'a4',
|
||||
page_setup: { orientation: 'portrait', margins: { top: 15, right: 15, bottom: 15, left: 20 } },
|
||||
elements: []
|
||||
};
|
||||
api.get.mockResolvedValueOnce({ data: [a4Report] });
|
||||
|
||||
const onModal = vi.fn();
|
||||
const onA4Print = vi.fn();
|
||||
const onNavigate = vi.fn();
|
||||
const out = await noticeDispatch({ onModal, onA4Print, onNavigate });
|
||||
|
||||
expect(out).toEqual({ found: true, kind: 'a4' });
|
||||
expect(onA4Print).toHaveBeenCalledWith(a4Report);
|
||||
expect(onModal).not.toHaveBeenCalled();
|
||||
expect(onNavigate).toHaveBeenCalledWith('/report-editor-a4');
|
||||
});
|
||||
|
||||
test('a missing notice_report dispatches nothing', async () => {
|
||||
api.get.mockResolvedValueOnce({ data: [] });
|
||||
const onModal = vi.fn();
|
||||
const onA4Print = vi.fn();
|
||||
const out = await noticeDispatch({ onModal, onA4Print, onNavigate: vi.fn() });
|
||||
|
||||
expect(out).toEqual({ found: false });
|
||||
expect(onModal).not.toHaveBeenCalled();
|
||||
expect(onA4Print).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the DRF paginated shape ({results:[...]}) is unwrapped too', async () => {
|
||||
api.get.mockResolvedValueOnce({
|
||||
data: { count: 1, results: [{ id: 3, name: 'notice_report', kind: 'a4' }] }
|
||||
});
|
||||
const onA4Print = vi.fn();
|
||||
const out = await noticeDispatch({ onModal: vi.fn(), onA4Print, onNavigate: vi.fn() });
|
||||
|
||||
expect(out.kind).toBe('a4');
|
||||
expect(onA4Print).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the lookup filters server-side by name rather than fetching every report', async () => {
|
||||
api.get.mockResolvedValueOnce({ data: [] });
|
||||
await noticeDispatch({ onModal: vi.fn(), onA4Print: vi.fn(), onNavigate: vi.fn() });
|
||||
|
||||
// The old code fetched '/api/reports/' and .find()'d. django-filter makes
|
||||
// ?name= a real filter, so the URL must carry it.
|
||||
expect(api.get).toHaveBeenCalledWith('/api/reports/?name=notice_report');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The A4 print entry point really is importable without any React context.
|
||||
* Main.jsx calls it with no editor mounted, which only works because layout()
|
||||
* is pure — this asserts that property rather than trusting it.
|
||||
*/
|
||||
describe('printA4Report as a context-free entry point', () => {
|
||||
test('prints a freshly fetched API row (snake_case page_setup) with no provider', async () => {
|
||||
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {});
|
||||
const { printA4Report } = await import('../print/printReport');
|
||||
|
||||
const apiRow = {
|
||||
id: 9, name: 'notice_report', kind: 'a4',
|
||||
page_setup: {
|
||||
page: 'A4', orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial', defaultFontSize: 10
|
||||
},
|
||||
elements: [{
|
||||
id: 't1', type: 'a4-text', x: 20, y: 50, width: 100, height: 6,
|
||||
content: 'Notice', fontFamily: 'Arial', fontSize: 10,
|
||||
bold: false, italic: false, align: 'left', color: '#000000'
|
||||
}]
|
||||
};
|
||||
|
||||
let printed;
|
||||
await act(async () => {
|
||||
printed = printA4Report(apiRow, { vehicle: { vehicle_number: 'AA1234BB' } });
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
await printed;
|
||||
});
|
||||
|
||||
expect(printSpy).toHaveBeenCalledTimes(1);
|
||||
// Container torn down — otherwise a print leaks a full report's markup
|
||||
// into <body> on every click.
|
||||
expect(document.querySelectorAll('.print-root').length).toBe(0);
|
||||
printSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A4ReportEditor — router-state auto-load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderEditorAt = (state) =>
|
||||
act(() => root.render(
|
||||
<MemoryRouter initialEntries={[{ pathname: '/report-editor-a4', state }]}>
|
||||
<A4ReportEditor />
|
||||
</MemoryRouter>
|
||||
));
|
||||
|
||||
describe('auto-load from router state', () => {
|
||||
test('loads the named report, filtered by kind AND name', async () => {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url.startsWith('/api/reports/?kind=a4&name=')) {
|
||||
return Promise.resolve({
|
||||
data: [{
|
||||
id: 7, name: 'notice_report', kind: 'a4',
|
||||
page_setup: {
|
||||
page: 'A4', orientation: 'landscape',
|
||||
margins: { top: 10, right: 10, bottom: 10, left: 10 },
|
||||
defaultFont: 'Calibri', defaultFontSize: 12
|
||||
},
|
||||
elements: [{
|
||||
id: 'x1', type: 'a4-text', x: 20, y: 30, width: 50, height: 6,
|
||||
content: 'Loaded', fontFamily: 'Arial', fontSize: 10,
|
||||
bold: false, italic: false, align: 'left', color: '#000000'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
|
||||
renderEditorAt({ reportName: 'notice_report' });
|
||||
await flush();
|
||||
|
||||
// Filtering on kind is the load-bearing part: a MATRIX report of the same
|
||||
// name must never be loaded here, where its character x/y would be read as
|
||||
// millimetres.
|
||||
expect(api.get).toHaveBeenCalledWith('/api/reports/?kind=a4&name=notice_report');
|
||||
|
||||
const nameInput = container.querySelector('.toolbar-report-name');
|
||||
expect(nameInput.value).toBe('notice_report');
|
||||
expect(container.textContent).toContain('Loaded');
|
||||
});
|
||||
|
||||
test('a name with characters needing escaping is encoded, not injected raw', async () => {
|
||||
api.get.mockResolvedValue({ data: [] });
|
||||
renderEditorAt({ reportName: 'notice report/v2' });
|
||||
await flush();
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
'/api/reports/?kind=a4&name=notice%20report%2Fv2'
|
||||
);
|
||||
});
|
||||
|
||||
test('a name with nothing saved pre-fills it so Save creates it', async () => {
|
||||
api.get.mockResolvedValue({ data: [] });
|
||||
renderEditorAt({ reportName: 'brand_new_notice' });
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('.toolbar-report-name').value)
|
||||
.toBe('brand_new_notice');
|
||||
});
|
||||
|
||||
test('no router state means no auto-load request at all', async () => {
|
||||
api.get.mockResolvedValue({ data: [] });
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
|
||||
const autoLoads = api.get.mock.calls
|
||||
.filter(([url]) => url.includes('name='));
|
||||
expect(autoLoads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildSampleVehicleData', () => {
|
||||
test('non-lookup nomenclatures become sample text', () => {
|
||||
const data = buildSampleVehicleData(
|
||||
{ DRIVER: { code: 'DRIVER', name: 'Driver', kind: 'field' } }, {}
|
||||
);
|
||||
expect(data.vehicle.extra.Driver).toBe('Sample Text');
|
||||
expect(data.vehicle.vehicle_number).toBe('АА1234ВВ');
|
||||
expect(data.vehicle.net).toBe(20000);
|
||||
});
|
||||
|
||||
test('a lookup prefers a real active entry over a synthesised one', () => {
|
||||
const defs = {
|
||||
CARGO: {
|
||||
code: 'CARGO', name: 'Cargo', kind: 'lookup',
|
||||
fields: [{ key: 'label', field_type: 'text' }]
|
||||
}
|
||||
};
|
||||
const entries = {
|
||||
CARGO: [
|
||||
{ is_active: false, data: { label: 'Inactive' } },
|
||||
{ is_active: true, data: { label: 'Coal' } }
|
||||
]
|
||||
};
|
||||
expect(buildSampleVehicleData(defs, entries).vehicle.extra.Cargo)
|
||||
.toEqual({ label: 'Coal' });
|
||||
});
|
||||
|
||||
test('a lookup with no entries synthesises one typed per field', () => {
|
||||
const defs = {
|
||||
CARGO: {
|
||||
code: 'CARGO', name: 'Cargo', kind: 'lookup',
|
||||
fields: [
|
||||
{ key: 'label', field_type: 'text' },
|
||||
{ key: 'qty', field_type: 'number' },
|
||||
{ key: 'ok', field_type: 'bool' }
|
||||
]
|
||||
}
|
||||
};
|
||||
expect(buildSampleVehicleData(defs, { CARGO: [] }).vehicle.extra.Cargo)
|
||||
.toEqual({ label: 'Sample', qty: 0, ok: false });
|
||||
});
|
||||
|
||||
test('tolerates being called before nomenclatures exist', () => {
|
||||
expect(buildSampleVehicleData(undefined, undefined).vehicle.extra).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kind chooser + cross-kind listing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Renders the editor plus a probe that reports the current route, so a
|
||||
* cross-kind navigation can be asserted as a real route change. */
|
||||
function renderWithRouteProbe(seen) {
|
||||
function Probe() {
|
||||
const loc = useLocation();
|
||||
seen.current = loc;
|
||||
return null;
|
||||
}
|
||||
return act(() => root.render(
|
||||
<MemoryRouter initialEntries={['/report-editor-a4']}>
|
||||
<Routes>
|
||||
<Route path="/report-editor-a4" element={<A4ReportEditor />} />
|
||||
<Route path="/report-editor" element={<Probe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
));
|
||||
}
|
||||
|
||||
const openLoadDialog = async () => {
|
||||
await act(async () => {
|
||||
buttonMatching(/Load/).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
};
|
||||
|
||||
describe('load dialog', () => {
|
||||
const mockLists = (a4 = [], matrix = []) => {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url === '/api/reports/?kind=a4') return Promise.resolve({ data: a4 });
|
||||
if (url === '/api/reports/?kind=matrix') return Promise.resolve({ data: matrix });
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
};
|
||||
|
||||
test('lists A4 reports and matrix reports in a dimmed, badged group', async () => {
|
||||
mockLists(
|
||||
[{ id: 1, name: 'a4 notice', kind: 'a4', updated_at: '2026-01-01T00:00:00Z' }],
|
||||
[{ id: 2, name: 'matrix notice', kind: 'matrix', updated_at: '2026-01-02T00:00:00Z' }]
|
||||
);
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
await openLoadDialog();
|
||||
|
||||
expect(container.textContent).toContain('a4 notice');
|
||||
// The cross-kind group is the whole point: without it a user who saved an
|
||||
// A4 report and opened the matrix editor thinks it was lost.
|
||||
expect(container.textContent).toContain('Other reports');
|
||||
expect(container.textContent).toContain('matrix notice');
|
||||
|
||||
const other = container.querySelector('.load-dialog-item--other');
|
||||
expect(other).not.toBeNull();
|
||||
expect(other.querySelector('.load-dialog-kind-badge').textContent).toBe('matrix');
|
||||
// A cross-kind row offers no delete — it is not this editor's to manage.
|
||||
expect(other.querySelector('.load-dialog-item-delete')).toBeNull();
|
||||
});
|
||||
|
||||
test('clicking a matrix report navigates to the matrix editor with its name', async () => {
|
||||
mockLists([], [{ id: 2, name: 'matrix notice', kind: 'matrix', updated_at: '2026-01-02T00:00:00Z' }]);
|
||||
const seen = { current: null };
|
||||
renderWithRouteProbe(seen);
|
||||
await flush();
|
||||
await openLoadDialog();
|
||||
|
||||
click(container.querySelector('.load-dialog-item--other .load-dialog-item-info'));
|
||||
await flush();
|
||||
|
||||
expect(seen.current.pathname).toBe('/report-editor');
|
||||
expect(seen.current.state.reportName).toBe('matrix notice');
|
||||
});
|
||||
|
||||
test('a failing cross-kind fetch still leaves the A4 list usable', async () => {
|
||||
api.get.mockImplementation((url) => {
|
||||
if (url === '/api/reports/?kind=a4') {
|
||||
return Promise.resolve({ data: [{ id: 1, name: 'a4 notice', updated_at: '2026-01-01T00:00:00Z' }] });
|
||||
}
|
||||
if (url === '/api/reports/?kind=matrix') return Promise.reject(new Error('boom'));
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
await openLoadDialog();
|
||||
|
||||
expect(container.textContent).toContain('a4 notice');
|
||||
expect(container.textContent).not.toContain('Other reports');
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
test('+ New Report asks which kind instead of silently making an A4 one', async () => {
|
||||
mockLists();
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
await openLoadDialog();
|
||||
|
||||
click(buttonMatching(/\+ New Report/));
|
||||
|
||||
expect(container.querySelector('.a4-kind-dialog')).not.toBeNull();
|
||||
expect(container.textContent).toContain('Character matrix');
|
||||
expect(container.textContent).toContain('A4 page');
|
||||
});
|
||||
|
||||
test('choosing "A4 page" stays put and resets to an empty report', async () => {
|
||||
mockLists();
|
||||
const seen = { current: null };
|
||||
renderWithRouteProbe(seen);
|
||||
await flush();
|
||||
await openLoadDialog();
|
||||
|
||||
click(buttonMatching(/\+ New Report/));
|
||||
click(buttonMatching(/A4 page/));
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('.a4-kind-dialog')).toBeNull();
|
||||
expect(container.querySelector('.load-dialog')).toBeNull();
|
||||
expect(container.querySelector('.toolbar-report-name').value)
|
||||
.toBe('Untitled A4 Report');
|
||||
});
|
||||
|
||||
test('choosing "Character matrix" navigates to the matrix editor', async () => {
|
||||
mockLists();
|
||||
const seen = { current: null };
|
||||
renderWithRouteProbe(seen);
|
||||
await flush();
|
||||
await openLoadDialog();
|
||||
|
||||
click(buttonMatching(/\+ New Report/));
|
||||
click(buttonMatching(/Character matrix/));
|
||||
await flush();
|
||||
|
||||
expect(seen.current.pathname).toBe('/report-editor');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a band by picking the Band tool and clicking the page, the way a user
|
||||
* does — so the band's width really comes from A4Canvas's contentWidthOf. */
|
||||
async function createBand() {
|
||||
// jsdom has no layout, so pin the page origin.
|
||||
const original = Element.prototype.getBoundingClientRect;
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
return { left: 0, top: 0, right: 800, bottom: 1130, width: 800, height: 1130, x: 0, y: 0, toJSON() {} };
|
||||
};
|
||||
click(buttonMatching(/Band/));
|
||||
const page = container.querySelector('.a4-page');
|
||||
act(() => {
|
||||
page.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 300 }));
|
||||
});
|
||||
Element.prototype.getBoundingClientRect = original;
|
||||
}
|
||||
|
||||
const openPageSetup = () => click(buttonMatching(/Page Setup/));
|
||||
|
||||
const marginInput = (label) => {
|
||||
const row = [...container.querySelectorAll('.a4-page-setup .property-row')]
|
||||
.find(r => r.querySelector('label')?.textContent === label);
|
||||
return row.querySelector('input');
|
||||
};
|
||||
|
||||
const typeInto = (input, value) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
).set;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
describe('page setup', () => {
|
||||
beforeEach(() => { api.get.mockResolvedValue({ data: [] }); });
|
||||
|
||||
test('orientation switches the page size', async () => {
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
openPageSetup();
|
||||
|
||||
const select = [...container.querySelectorAll('.a4-page-setup select')]
|
||||
.find(s => [...s.options].some(o => o.value === 'landscape'));
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype, 'value'
|
||||
).set;
|
||||
setter.call(select, 'landscape');
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('297 × 210 mm');
|
||||
expect(getPageSize({ orientation: 'landscape' })).toEqual({ w: 297, h: 210 });
|
||||
});
|
||||
|
||||
test('margins are text inputs, so a typed decimal point survives', async () => {
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
openPageSetup();
|
||||
|
||||
const left = marginInput('Left');
|
||||
// type="number" would report "" for the intermediate "12." and the decimal
|
||||
// point would appear not to type at all.
|
||||
expect(left.getAttribute('type')).toBe('text');
|
||||
expect(left.getAttribute('inputMode') || left.inputMode).toBe('decimal');
|
||||
|
||||
// MmInput only holds the raw draft WHILE FOCUSED (otherwise it re-syncs
|
||||
// from the prop, which is what lets a drag update the field). React's
|
||||
// onFocus maps to native focusin, not focus — dispatching 'focus' would
|
||||
// silently do nothing and make this look like a component bug.
|
||||
act(() => { left.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); });
|
||||
|
||||
typeInto(left, '12.');
|
||||
expect(left.value).toBe('12.');
|
||||
typeInto(left, '12.5');
|
||||
expect(left.value).toBe('12.5');
|
||||
});
|
||||
|
||||
test('changing a side margin reflows existing bands to the new content width', async () => {
|
||||
renderEditorAt(undefined);
|
||||
await flush();
|
||||
await createBand();
|
||||
|
||||
const bandEl = container.querySelector('.a4-band, [class*="a4-band"]');
|
||||
expect(bandEl).not.toBeNull();
|
||||
|
||||
openPageSetup();
|
||||
// Default margins are left 20 / right 15 → content width 175mm.
|
||||
// Widening the left margin to 40 must shrink the band to 155mm and move it
|
||||
// to x=40, not leave it at its creation width overhanging the margin.
|
||||
typeInto(marginInput('Left'), '40');
|
||||
|
||||
const band = container.querySelector('.a4-band, [class*="a4-band"]');
|
||||
const leftPx = parseFloat(band.style.left);
|
||||
const widthPx = parseFloat(band.style.width);
|
||||
const PX_PER_MM = 96 / 25.4;
|
||||
|
||||
expect(leftPx / PX_PER_MM).toBeCloseTo(40, 1);
|
||||
expect(widthPx / PX_PER_MM).toBeCloseTo(210 - 40 - 15, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* Smart alignment guides — Phase 3.
|
||||
*
|
||||
* Two layers of test:
|
||||
* 1. guides.js as pure functions — candidates, each snap kind, the no-snap
|
||||
* case, and the zoom-invariance property.
|
||||
* 2. The real A4Canvas driven by real mouse events, so the integration (ref
|
||||
* lifetime, multi-select union, Alt, overlay clearing) is exercised the way
|
||||
* a user exercises it, not through a mock.
|
||||
*/
|
||||
|
||||
import React, { useState, act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import A4Canvas from '../A4Canvas';
|
||||
import A4ReportEditor from '../A4ReportEditor';
|
||||
|
||||
// Phase 7 gave A4ReportEditor two new context dependencies: react-router (for
|
||||
// the "Design Notice" auto-load) and NomenclatureDataContext (for the
|
||||
// sample-data fallback). The router is supplied for real via MemoryRouter
|
||||
// below; the nomenclature stack is mocked, because standing it up would drag in
|
||||
// the SSE client and the auth context for tests that are about drag geometry.
|
||||
vi.mock('../../../../contexts/NomenclatureDataContext', () => ({
|
||||
useNomenclatureData: () => ({ definitions: {}, entries: {}, isLoaded: false })
|
||||
}));
|
||||
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
buildGuideCandidates,
|
||||
resolveSnap,
|
||||
unionRect,
|
||||
snapToleranceMm,
|
||||
SNAP_PX
|
||||
} from '../guides';
|
||||
import { PX_PER_MM, mmToPx } from '../units';
|
||||
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const PAGE_SETUP = {
|
||||
page: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial',
|
||||
defaultFontSize: 10
|
||||
};
|
||||
|
||||
const PAGE_ORIGIN = { left: 100, top: 50 };
|
||||
|
||||
const makeElement = (over = {}) => ({
|
||||
id: 'a4-text-1', type: 'a4-text',
|
||||
x: 20, y: 40, width: 40, height: 6,
|
||||
content: 'Text', fontFamily: 'Arial', fontSize: 10,
|
||||
bold: false, italic: false, align: 'left', color: '#000000',
|
||||
...over
|
||||
});
|
||||
|
||||
/** Positions present on an axis, deduped and sorted — easier to assert against. */
|
||||
const positions = (list) => [...new Set(list.map(c => c.pos))].sort((a, b) => a - b);
|
||||
|
||||
describe('buildGuideCandidates', () => {
|
||||
test('emits 3 vertical and 3 horizontal candidates per element, from exclusive bounds', () => {
|
||||
const el = makeElement({ x: 10, y: 20, width: 40, height: 6 });
|
||||
const { V, H } = buildGuideCandidates([el], [], PAGE_SETUP);
|
||||
|
||||
// 3 from the element + 5 page-level (0, centre, far edge, 2 margins)
|
||||
expect(V).toHaveLength(3 + 5);
|
||||
expect(H).toHaveLength(3 + 5);
|
||||
|
||||
// left / centreX / right — right is 50, NOT 49: bounds are exclusive
|
||||
expect(V.slice(0, 3).map(c => c.pos)).toEqual([10, 30, 50]);
|
||||
expect(H.slice(0, 3).map(c => c.pos)).toEqual([20, 23, 26]);
|
||||
expect(V[1].kind).toBe('element-center');
|
||||
});
|
||||
|
||||
test('excluded ids contribute nothing — a dragged element cannot align to itself', () => {
|
||||
const a = makeElement({ id: 'a', x: 10 });
|
||||
const b = makeElement({ id: 'b', x: 100 });
|
||||
|
||||
const { V } = buildGuideCandidates([a, b], ['a'], PAGE_SETUP);
|
||||
|
||||
expect(V).toHaveLength(3 + 5); // only b's three, plus the page
|
||||
expect(positions(V)).toContain(100); // b's left edge
|
||||
expect(positions(V)).not.toContain(10); // a's left edge is gone
|
||||
});
|
||||
|
||||
test('page candidates cover 0, centre, far edge and both margins on each axis', () => {
|
||||
const { V, H } = buildGuideCandidates([], [], PAGE_SETUP);
|
||||
|
||||
expect(positions(V)).toEqual([0, 20, 105, 195, 210]); // left margin 20, right 210-15
|
||||
expect(positions(H)).toEqual([0, 15, 148.5, 282, 297]); // top 15, bottom 297-15
|
||||
|
||||
expect(V.find(c => c.pos === 105).kind).toBe('page-center');
|
||||
expect(V.find(c => c.pos === 20).kind).toBe('margin');
|
||||
});
|
||||
|
||||
test('bands generate no candidates, but their children do', () => {
|
||||
const band = {
|
||||
id: 'band-1', type: 'a4-band',
|
||||
x: 20, y: 50, width: 175, height: 30,
|
||||
bandType: 'detail', children: ['child-1']
|
||||
};
|
||||
const child = makeElement({ id: 'child-1', x: 60, y: 5, width: 30, height: 6 });
|
||||
|
||||
const { V } = buildGuideCandidates([band, child], [], PAGE_SETUP);
|
||||
|
||||
// Only the child's three plus the page's five: the band contributed none.
|
||||
expect(V).toHaveLength(3 + 5);
|
||||
expect(positions(V)).toContain(60); // child left
|
||||
expect(positions(V)).toContain(90); // child right, exclusive
|
||||
// A band spans the content width, so its edges ARE the margins — which are
|
||||
// already page candidates. Skipping it is what stops those being doubled.
|
||||
expect(V.filter(c => c.pos === 20)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSnap', () => {
|
||||
const candidatesFor = (els) => buildGuideCandidates(els, ['dragged'], PAGE_SETUP);
|
||||
|
||||
test('edge-to-edge: a left edge just short of a neighbour\'s left edge snaps onto it', () => {
|
||||
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
|
||||
const c = candidatesFor([neighbour]);
|
||||
|
||||
// 1mm to the left of 100 — inside the ~1.6mm tolerance at zoom 1
|
||||
const out = resolveSnap({ x: 99, y: 200, width: 40, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x).toBe(100);
|
||||
expect(out.guides).toContainEqual({ axis: 'v', pos: 100, kind: 'element' });
|
||||
});
|
||||
|
||||
test('right edge snaps to a neighbour\'s right edge, shifting the whole box', () => {
|
||||
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
|
||||
const c = candidatesFor([neighbour]);
|
||||
|
||||
// Dragged box is 20mm wide; its right edge sits at 139, 1mm short of 140.
|
||||
const out = resolveSnap({ x: 119, y: 260, width: 20, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x).toBe(120); // origin moved so the RIGHT edge hits 140
|
||||
expect(out.x + 20).toBe(140);
|
||||
});
|
||||
|
||||
test('centre-to-centre snapping is reported with its own kind', () => {
|
||||
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
|
||||
const c = candidatesFor([neighbour]);
|
||||
// neighbour centreX = 120. A 10mm box centred at 119 → origin 114.
|
||||
const out = resolveSnap({ x: 114, y: 260, width: 10, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x + 5).toBe(120);
|
||||
expect(out.guides).toContainEqual({ axis: 'v', pos: 120, kind: 'element-center' });
|
||||
});
|
||||
|
||||
test('page centre snaps, and is labelled page-center', () => {
|
||||
const c = buildGuideCandidates([], [], PAGE_SETUP);
|
||||
// A 40mm box centred at 104 → 1mm short of the page centre, 105.
|
||||
const out = resolveSnap({ x: 84, y: 250, width: 40, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x + 20).toBe(105);
|
||||
expect(out.guides).toContainEqual({ axis: 'v', pos: 105, kind: 'page-center' });
|
||||
});
|
||||
|
||||
test('margins snap on both axes at once', () => {
|
||||
const c = buildGuideCandidates([], [], PAGE_SETUP);
|
||||
// Left edge 1mm off the 20mm left margin; top edge 1mm off the 15mm top.
|
||||
const out = resolveSnap({ x: 21, y: 16, width: 40, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x).toBe(20);
|
||||
expect(out.y).toBe(15);
|
||||
expect(out.guides).toContainEqual({ axis: 'v', pos: 20, kind: 'margin' });
|
||||
expect(out.guides).toContainEqual({ axis: 'h', pos: 15, kind: 'margin' });
|
||||
});
|
||||
|
||||
test('beyond the threshold nothing moves and no guide is offered', () => {
|
||||
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
|
||||
const c = candidatesFor([neighbour]);
|
||||
|
||||
// 5mm away, far outside the ~1.6mm tolerance at zoom 1
|
||||
const out = resolveSnap({ x: 95, y: 250, width: 40, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x).toBe(95);
|
||||
expect(out.y).toBe(250);
|
||||
expect(out.guides).toEqual([]);
|
||||
});
|
||||
|
||||
test('the nearest candidate wins when several are in reach', () => {
|
||||
// Two neighbours whose LEFT edges are both within tolerance of the dragged
|
||||
// box's left edge, at different distances. Their widths are chosen so that
|
||||
// no other edge of either lands inside the tolerance and muddies the test.
|
||||
const near = makeElement({ id: 'near', x: 100.2, y: 200, width: 50, height: 6 });
|
||||
const far = makeElement({ id: 'far', x: 101.2, y: 200, width: 70, height: 6 });
|
||||
const c = buildGuideCandidates([near, far], ['dragged'], PAGE_SETUP);
|
||||
|
||||
// Dragged left edge 100: 0.2 from near, 1.2 from far. Its right edge (103)
|
||||
// and centre (101.5) are far from every candidate the two neighbours emit.
|
||||
const out = resolveSnap({ x: 100, y: 250, width: 3, height: 6 }, c, 1);
|
||||
|
||||
expect(out.x).toBeCloseTo(100.2, 10);
|
||||
expect(out.guides).toContainEqual({ axis: 'v', pos: 100.2, kind: 'element' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom invariance — the property that would silently feel wrong', () => {
|
||||
test('the mm tolerance scales inversely with zoom, keeping the screen grab constant', () => {
|
||||
[0.5, 0.75, 1, 1.5, 3].forEach(zoom => {
|
||||
const tolMm = snapToleranceMm(zoom);
|
||||
// Restated in screen pixels, the magnet is always the same size.
|
||||
expect(tolMm * PX_PER_MM * zoom).toBeCloseTo(SNAP_PX, 10);
|
||||
});
|
||||
|
||||
// Inverse, not merely "different": half the zoom, twice the mm.
|
||||
expect(snapToleranceMm(0.5)).toBeCloseTo(snapToleranceMm(1) * 2, 10);
|
||||
expect(snapToleranceMm(2)).toBeCloseTo(snapToleranceMm(1) / 2, 10);
|
||||
});
|
||||
|
||||
test('the same SCREEN-pixel gap snaps at every zoom; the same mm gap does not', () => {
|
||||
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
|
||||
const c = buildGuideCandidates([neighbour], ['dragged'], PAGE_SETUP);
|
||||
|
||||
const gapPx = 4; // inside SNAP_PX=6 at any zoom
|
||||
[0.5, 1, 1.5].forEach(zoom => {
|
||||
const gapMm = gapPx / (PX_PER_MM * zoom);
|
||||
const out = resolveSnap({ x: 100 - gapMm, y: 250, width: 40, height: 6 }, c, zoom);
|
||||
expect(out.x).toBeCloseTo(100, 10);
|
||||
});
|
||||
|
||||
// A FIXED mm gap is the mirror image: 3mm is ~11px at zoom 1 (no snap) but
|
||||
// ~5.7px at zoom 0.5 (snaps). That asymmetry is exactly what makes a
|
||||
// px-based threshold the right choice.
|
||||
expect(resolveSnap({ x: 97, y: 250, width: 40, height: 6 }, c, 1).x).toBe(97);
|
||||
expect(resolveSnap({ x: 97, y: 250, width: 40, height: 6 }, c, 0.5).x).toBeCloseTo(100, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unionRect', () => {
|
||||
test('spans every box, using exclusive far edges', () => {
|
||||
const r = unionRect([
|
||||
{ x: 10, y: 20, width: 30, height: 5 },
|
||||
{ x: 50, y: 10, width: 20, height: 40 }
|
||||
]);
|
||||
expect(r).toEqual({ x: 10, y: 10, width: 60, height: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
// --- Integration: the real canvas, real mouse events -----------------------
|
||||
|
||||
let container, root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
// jsdom has no layout; pin the page origin so client -> mm math runs.
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
if (this.classList.contains('a4-page')) {
|
||||
return { left: PAGE_ORIGIN.left, top: PAGE_ORIGIN.top, width: 793.7, height: 1122.5,
|
||||
right: 0, bottom: 0, x: PAGE_ORIGIN.left, y: PAGE_ORIGIN.top, toJSON() {} };
|
||||
}
|
||||
return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} };
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function mountCanvas({ elements: initial, zoom = 1, selected = [] }) {
|
||||
const state = { elements: initial };
|
||||
|
||||
function Harness() {
|
||||
const [elements, setElements] = useState(initial);
|
||||
state.elements = elements;
|
||||
return (
|
||||
<A4Canvas
|
||||
elements={elements}
|
||||
selectedElementIds={selected}
|
||||
onElementSelect={() => {}}
|
||||
onSelectMultiple={() => {}}
|
||||
onDeselectAll={() => {}}
|
||||
onElementUpdate={(id, updates) =>
|
||||
setElements(prev => prev.map(el => (el.id === id ? { ...el, ...updates } : el)))
|
||||
}
|
||||
onElementDelete={() => {}}
|
||||
onAddElement={() => {}}
|
||||
toolMode="select"
|
||||
pageSetup={PAGE_SETUP}
|
||||
zoom={zoom}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => root.render(<Harness />));
|
||||
return state;
|
||||
}
|
||||
|
||||
// MouseEvent coerces clientX/clientY to integers, so tests use whole pixels and
|
||||
// derive expectations from those same integers.
|
||||
const mouse = (type, x, y, init = {}) =>
|
||||
new MouseEvent(type, { bubbles: true, clientX: Math.round(x), clientY: Math.round(y), ...init });
|
||||
|
||||
const byId = (state, id) => state.elements.find(el => el.id === id);
|
||||
|
||||
test('dragging near a neighbour\'s left edge shows a guide and snaps onto it', () => {
|
||||
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
|
||||
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
|
||||
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
|
||||
|
||||
const node = container.querySelectorAll('.a4-text-field')[0];
|
||||
// Land the left edge at ~99mm: 1mm short of the neighbour's 100mm edge.
|
||||
const dxPx = Math.round(mmToPx(79, 1));
|
||||
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
|
||||
|
||||
// Snapped exactly, not merely "close": the unsnapped value would be ~99.
|
||||
expect(byId(state, 'drag').x).toBe(100);
|
||||
|
||||
const guide = container.querySelector('.a4-guide--v');
|
||||
expect(guide).toBeTruthy();
|
||||
expect(guide.style.left).toBe(`${mmToPx(100, 1)}px`);
|
||||
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
|
||||
});
|
||||
|
||||
test('guides clear on mouseup and never intercept the mouse', () => {
|
||||
const dragged = makeElement({ id: 'drag', x: 20, y: 100 });
|
||||
const neighbour = makeElement({ id: 'near', x: 100, y: 200 });
|
||||
mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
|
||||
|
||||
const node = container.querySelectorAll('.a4-text-field')[0];
|
||||
const dxPx = Math.round(mmToPx(79, 1));
|
||||
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
|
||||
|
||||
const overlay = container.querySelector('.a4-guide-overlay');
|
||||
expect(overlay).toBeTruthy();
|
||||
// The style lives in A4Canvas.css, which the test runner does not apply, so assert the
|
||||
// class contract the rule is attached to instead of a computed style.
|
||||
expect(overlay.className).toBe('a4-guide-overlay');
|
||||
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
|
||||
|
||||
expect(container.querySelector('.a4-guide-overlay')).toBeNull();
|
||||
expect(container.querySelectorAll('.a4-guide')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('holding Alt suppresses snapping', () => {
|
||||
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
|
||||
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
|
||||
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
|
||||
|
||||
const node = container.querySelectorAll('.a4-text-field')[0];
|
||||
const dxPx = Math.round(mmToPx(79, 1));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }));
|
||||
});
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
|
||||
|
||||
// Unsnapped: within a pixel's worth of 99, and NOT the 100 it would snap to.
|
||||
const x = byId(state, 'drag').x;
|
||||
expect(x).not.toBe(100);
|
||||
expect(Math.abs(x - 99)).toBeLessThan(0.3);
|
||||
expect(container.querySelector('.a4-guide')).toBeNull();
|
||||
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', altKey: false }));
|
||||
});
|
||||
});
|
||||
|
||||
test('releasing Alt restores snapping', () => {
|
||||
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
|
||||
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
|
||||
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }));
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', altKey: false }));
|
||||
});
|
||||
|
||||
const node = container.querySelectorAll('.a4-text-field')[0];
|
||||
const dxPx = Math.round(mmToPx(79, 1));
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
|
||||
|
||||
expect(byId(state, 'drag').x).toBe(100);
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
|
||||
});
|
||||
|
||||
test('multi-select drags as one unit: the group snaps and offsets are preserved', () => {
|
||||
const a = makeElement({ id: 'a', x: 20, y: 100, width: 40, height: 6 });
|
||||
const b = makeElement({ id: 'b', x: 30, y: 120, width: 25, height: 6 });
|
||||
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
|
||||
|
||||
const state = mountCanvas({
|
||||
elements: [a, b, neighbour],
|
||||
selected: ['a', 'b']
|
||||
});
|
||||
|
||||
// Union left edge is a.x = 20. Move it to ~99mm so it snaps to 100.
|
||||
const node = container.querySelectorAll('.a4-text-field')[0];
|
||||
const dxPx = Math.round(mmToPx(79, 1));
|
||||
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
|
||||
|
||||
const movedA = byId(state, 'a');
|
||||
const movedB = byId(state, 'b');
|
||||
|
||||
// The union's leading edge landed exactly on the candidate...
|
||||
expect(movedA.x).toBe(100);
|
||||
// ...and b took the SAME delta, so the 10mm gap between them survives.
|
||||
expect(movedB.x - movedA.x).toBeCloseTo(10, 10);
|
||||
expect(movedB.y - movedA.y).toBeCloseTo(20, 10);
|
||||
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
|
||||
});
|
||||
|
||||
test('a fresh candidate set is built per drag, so a moved neighbour is respected', () => {
|
||||
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
|
||||
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
|
||||
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
|
||||
|
||||
const node = container.querySelectorAll('.a4-text-field')[0];
|
||||
const dxPx = Math.round(mmToPx(79, 1));
|
||||
|
||||
// First drag snaps to the neighbour at 100.
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
|
||||
expect(byId(state, 'drag').x).toBe(100);
|
||||
|
||||
// Drag back to the same neighbourhood. If candidates were cached across
|
||||
// drags, the dragged element's OWN old position (100) would still be a
|
||||
// candidate and it would stick there.
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 500, 400)); });
|
||||
const back = Math.round(mmToPx(20, 1));
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 500 + back, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 500 + back, 400)); });
|
||||
|
||||
// Landed near 120, free of its own stale candidate at 100.
|
||||
expect(Math.abs(byId(state, 'drag').x - 120)).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
// --- Arrow-key nudge, on the real editor ----------------------------------
|
||||
|
||||
describe('arrow-key nudge', () => {
|
||||
/** Mount the whole A4ReportEditor and place one element by clicking the page. */
|
||||
function mountEditorWithElement() {
|
||||
act(() => root.render(<MemoryRouter><A4ReportEditor /></MemoryRouter>));
|
||||
|
||||
// Pick the text tool, then click the page: this is how a user creates an
|
||||
// element, so the test never has to reach into editor state to set one up.
|
||||
const textBtn = [...container.querySelectorAll('button')]
|
||||
.find(b => /text/i.test(b.textContent) && !/db/i.test(b.textContent));
|
||||
act(() => { textBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
|
||||
const page = container.querySelector('.a4-page');
|
||||
act(() => {
|
||||
page.dispatchEvent(mouse(
|
||||
'click',
|
||||
PAGE_ORIGIN.left + Math.round(mmToPx(50, 1)),
|
||||
PAGE_ORIGIN.top + Math.round(mmToPx(80, 1))
|
||||
));
|
||||
});
|
||||
|
||||
return () => {
|
||||
const node = container.querySelector('.a4-text-field');
|
||||
return {
|
||||
x: parseFloat(node.style.left) / mmToPx(1, 1),
|
||||
y: parseFloat(node.style.top) / mmToPx(1, 1)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const key = (k, init = {}) =>
|
||||
new KeyboardEvent('keydown', { key: k, bubbles: true, ...init });
|
||||
|
||||
test('a bare arrow moves the selection by exactly 1mm', () => {
|
||||
const read = mountEditorWithElement();
|
||||
const before = read();
|
||||
|
||||
act(() => { document.dispatchEvent(key('ArrowRight')); });
|
||||
expect(read().x).toBeCloseTo(before.x + 1, 6);
|
||||
|
||||
act(() => { document.dispatchEvent(key('ArrowDown')); });
|
||||
expect(read().y).toBeCloseTo(before.y + 1, 6);
|
||||
|
||||
act(() => { document.dispatchEvent(key('ArrowLeft')); });
|
||||
act(() => { document.dispatchEvent(key('ArrowUp')); });
|
||||
expect(read().x).toBeCloseTo(before.x, 6);
|
||||
expect(read().y).toBeCloseTo(before.y, 6);
|
||||
});
|
||||
|
||||
test('Shift+arrow moves by 0.1mm — the storage precision', () => {
|
||||
const read = mountEditorWithElement();
|
||||
const before = read();
|
||||
|
||||
act(() => { document.dispatchEvent(key('ArrowRight', { shiftKey: true })); });
|
||||
expect(read().x).toBeCloseTo(before.x + 0.1, 6);
|
||||
|
||||
// Ten Shift-nudges equal one plain nudge, and 0.1mm steps must not
|
||||
// accumulate a floating-point tail — roundMm is what guarantees that.
|
||||
for (let i = 0; i < 9; i++) {
|
||||
act(() => { document.dispatchEvent(key('ArrowRight', { shiftKey: true })); });
|
||||
}
|
||||
expect(read().x).toBeCloseTo(before.x + 1, 6);
|
||||
});
|
||||
|
||||
test('arrows do nothing while an input has focus', () => {
|
||||
const read = mountEditorWithElement();
|
||||
const before = read();
|
||||
|
||||
// The report-name field in the toolbar is a real text input in the editor.
|
||||
const input = container.querySelector('input[type="text"]');
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
act(() => { input.dispatchEvent(key('ArrowRight')); });
|
||||
act(() => { input.dispatchEvent(key('ArrowDown', { shiftKey: true })); });
|
||||
|
||||
// Unmoved: the handler short-circuits on INPUT/TEXTAREA/SELECT targets, so
|
||||
// arrowing through typed text cannot silently displace the element.
|
||||
expect(read().x).toBeCloseTo(before.x, 6);
|
||||
expect(read().y).toBeCloseTo(before.y, 6);
|
||||
});
|
||||
|
||||
test('a nudge moves every selected element, keeping their offsets', () => {
|
||||
const read = mountEditorWithElement();
|
||||
|
||||
// Add a second element, then Ctrl+A to select both.
|
||||
const textBtn = [...container.querySelectorAll('button')]
|
||||
.find(b => /text/i.test(b.textContent) && !/db/i.test(b.textContent));
|
||||
act(() => { textBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
const page = container.querySelector('.a4-page');
|
||||
act(() => {
|
||||
page.dispatchEvent(mouse(
|
||||
'click',
|
||||
PAGE_ORIGIN.left + Math.round(mmToPx(90, 1)),
|
||||
PAGE_ORIGIN.top + Math.round(mmToPx(150, 1))
|
||||
));
|
||||
});
|
||||
|
||||
act(() => { document.dispatchEvent(key('a', { ctrlKey: true })); });
|
||||
|
||||
const nodes = () => [...container.querySelectorAll('.a4-text-field')]
|
||||
.map(n => parseFloat(n.style.left) / mmToPx(1, 1));
|
||||
const before = nodes();
|
||||
expect(before).toHaveLength(2);
|
||||
|
||||
act(() => { document.dispatchEvent(key('ArrowRight')); });
|
||||
|
||||
const after = nodes();
|
||||
expect(after[0]).toBeCloseTo(before[0] + 1, 6);
|
||||
expect(after[1]).toBeCloseTo(before[1] + 1, 6);
|
||||
// Unused, but asserts the helper still resolves the first element.
|
||||
expect(read().x).toBeCloseTo(after[0], 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Integration checks for the A4 designer's millimetre behaviour.
|
||||
*
|
||||
* These mount the REAL A4Canvas / A4Inspector and dispatch real mouse and input
|
||||
* events, so they exercise the same code paths a user does: drag, resize, zoom,
|
||||
* and inspector typing. jsdom reports zero-sized rects, so getBoundingClientRect
|
||||
* is stubbed to place the page at a known origin — everything else is genuine.
|
||||
*/
|
||||
|
||||
import React, { useState, act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import A4Canvas from '../A4Canvas';
|
||||
import A4Inspector from '../A4Inspector';
|
||||
import { DataProvider } from '../../DataContext';
|
||||
|
||||
import { mmToPx, pxToMm, roundMm } from '../units';
|
||||
import { getA4Bounds } from '../geometry';
|
||||
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const PAGE_SETUP = {
|
||||
page: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial',
|
||||
defaultFontSize: 10
|
||||
};
|
||||
|
||||
const PAGE_ORIGIN = { left: 100, top: 50 };
|
||||
|
||||
const makeElement = (over = {}) => ({
|
||||
id: 'a4-text-1', type: 'a4-text',
|
||||
x: 20, y: 40, width: 40, height: 6,
|
||||
content: 'Text', fontFamily: 'Arial', fontSize: 10,
|
||||
bold: false, italic: false, align: 'left', color: '#000000',
|
||||
...over
|
||||
});
|
||||
|
||||
let container, root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
// jsdom has no layout; pin the page element's origin so client->mm math runs.
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
if (this.classList.contains('a4-page')) {
|
||||
return { left: PAGE_ORIGIN.left, top: PAGE_ORIGIN.top, width: 793.7, height: 1122.5,
|
||||
right: 0, bottom: 0, x: PAGE_ORIGIN.left, y: PAGE_ORIGIN.top, toJSON() {} };
|
||||
}
|
||||
return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} };
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
/** Mount A4Canvas with live element state; returns helpers + a state reader. */
|
||||
function mountCanvas({ elements: initial, zoom = 1, selected = [] }) {
|
||||
const state = { elements: initial };
|
||||
let setElements;
|
||||
|
||||
function Harness() {
|
||||
const [elements, _setElements] = useState(initial);
|
||||
setElements = _setElements;
|
||||
state.elements = elements;
|
||||
return (
|
||||
<A4Canvas
|
||||
elements={elements}
|
||||
selectedElementIds={selected}
|
||||
onElementSelect={() => {}}
|
||||
onSelectMultiple={() => {}}
|
||||
onDeselectAll={() => {}}
|
||||
onElementUpdate={(id, updates) =>
|
||||
_setElements(prev => prev.map(el => (el.id === id ? { ...el, ...updates } : el)))
|
||||
}
|
||||
onElementDelete={() => {}}
|
||||
onAddElement={(el) => _setElements(prev => [...prev, el])}
|
||||
toolMode="select"
|
||||
pageSetup={PAGE_SETUP}
|
||||
zoom={zoom}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => root.render(<Harness />));
|
||||
return { state, setElements };
|
||||
}
|
||||
|
||||
// MouseEvent coerces clientX/clientY to integers, so tests must use whole
|
||||
// pixels and derive their expectations from those same integers — otherwise the
|
||||
// truncation shows up as a bogus ~0.3mm "error" in the component.
|
||||
const mouse = (type, x, y) =>
|
||||
new MouseEvent(type, { bubbles: true, clientX: Math.round(x), clientY: Math.round(y) });
|
||||
|
||||
test('drag stores a 0.1mm-rounded float, not a pixel or a long tail', () => {
|
||||
const { state } = mountCanvas({ elements: [makeElement()], selected: ['a4-text-1'] });
|
||||
|
||||
const el = container.querySelector('.a4-text-field');
|
||||
act(() => { el.dispatchEvent(mouse('mousedown', 300, 300)); });
|
||||
// Drag by 37px right / 19px down at zoom 1
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 337, 319)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 337, 319)); });
|
||||
|
||||
const moved = state.elements[0];
|
||||
expect(moved.x).toBeCloseTo(roundMm(20 + pxToMm(37, 1)), 10);
|
||||
expect(moved.y).toBeCloseTo(roundMm(40 + pxToMm(19, 1)), 10);
|
||||
// 0.1mm quantised: one decimal place, no 12.700000000000001
|
||||
expect(Math.round(moved.x * 10)).toBe(moved.x * 10);
|
||||
expect(String(moved.x)).toMatch(/^\d+(\.\d)?$/);
|
||||
});
|
||||
|
||||
test('the same pixel drag yields the same mm distance at every zoom', () => {
|
||||
const results = [0.5, 1, 1.5].map(zoom => {
|
||||
const { state } = mountCanvas({ elements: [makeElement()], selected: ['a4-text-1'], zoom });
|
||||
const el = container.querySelector('.a4-text-field');
|
||||
// Move by whatever 10mm is on screen at this zoom, snapped to whole pixels
|
||||
const dx = Math.round(mmToPx(10, zoom));
|
||||
act(() => { el.dispatchEvent(mouse('mousedown', 400, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dx, 400)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dx, 400)); });
|
||||
const x = state.elements[0].x;
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
// Expected mm for the pixels actually delivered at this zoom
|
||||
return { x, expected: roundMm(20 + pxToMm(dx, zoom)) };
|
||||
});
|
||||
|
||||
results.forEach(({ x, expected }) => {
|
||||
expect(x).toBeCloseTo(expected, 10);
|
||||
// A whole-pixel drag of "10mm" lands within a pixel's worth of 30mm at
|
||||
// every zoom — the point being that mm are zoom-independent, not that
|
||||
// integer screen pixels can express 10mm exactly at 50%.
|
||||
expect(Math.abs(x - 30)).toBeLessThan(0.6);
|
||||
});
|
||||
});
|
||||
|
||||
test('zoom changes only pixel rendering; stored mm are untouched', () => {
|
||||
const element = makeElement({ x: 20.5, y: 100.3 });
|
||||
|
||||
const readPx = (zoom) => {
|
||||
const { state } = mountCanvas({ elements: [element], zoom });
|
||||
const node = container.querySelector('.a4-text-field');
|
||||
const px = { left: node.style.left, top: node.style.top };
|
||||
const mm = { x: state.elements[0].x, y: state.elements[0].y };
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
return { px, mm };
|
||||
};
|
||||
|
||||
const at50 = readPx(0.5);
|
||||
const at150 = readPx(1.5);
|
||||
|
||||
// mm identical across zoom
|
||||
expect(at50.mm).toEqual({ x: 20.5, y: 100.3 });
|
||||
expect(at150.mm).toEqual({ x: 20.5, y: 100.3 });
|
||||
// px scale by exactly 3x between 50% and 150%
|
||||
expect(parseFloat(at150.px.left)).toBeCloseTo(parseFloat(at50.px.left) * 3, 6);
|
||||
expect(parseFloat(at50.px.left)).toBeCloseTo(mmToPx(20.5, 0.5), 6);
|
||||
});
|
||||
|
||||
test('click-to-create places a text element at the clicked mm, with a4- type prefix', () => {
|
||||
const state = { created: null };
|
||||
function Harness() {
|
||||
const [elements, setElements] = useState([]);
|
||||
state.created = elements[0] || null;
|
||||
return (
|
||||
<A4Canvas
|
||||
elements={elements} selectedElementIds={[]}
|
||||
onElementSelect={() => {}} onSelectMultiple={() => {}} onDeselectAll={() => {}}
|
||||
onElementUpdate={() => {}} onElementDelete={() => {}}
|
||||
onAddElement={(el) => setElements([el])}
|
||||
toolMode="addText" pageSetup={PAGE_SETUP} zoom={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
act(() => root.render(<Harness />));
|
||||
|
||||
const page = container.querySelector('.a4-page');
|
||||
// Click 50mm right / 80mm down from the page origin, on whole pixels
|
||||
const dxPx = Math.round(mmToPx(50, 1));
|
||||
const dyPx = Math.round(mmToPx(80, 1));
|
||||
act(() => {
|
||||
page.dispatchEvent(mouse('click', PAGE_ORIGIN.left + dxPx, PAGE_ORIGIN.top + dyPx));
|
||||
});
|
||||
|
||||
expect(state.created).toBeTruthy();
|
||||
expect(state.created.type).toBe('a4-text');
|
||||
expect(state.created.id).toMatch(/^a4-text-\d+$/);
|
||||
expect(state.created.x).toBeCloseTo(roundMm(pxToMm(dxPx, 1)), 10);
|
||||
expect(state.created.y).toBeCloseTo(roundMm(pxToMm(dyPx, 1)), 10);
|
||||
// Within a pixel's worth of the intended 50/80mm
|
||||
expect(Math.abs(state.created.x - 50)).toBeLessThan(0.3);
|
||||
expect(Math.abs(state.created.y - 80)).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
test('resize handle changes size in mm floats, not snapped to a grid', () => {
|
||||
const { state } = mountCanvas({
|
||||
elements: [makeElement()], selected: ['a4-text-1']
|
||||
});
|
||||
|
||||
const se = container.querySelectorAll('.a4-resize-handle')[7]; // 'se'
|
||||
act(() => { se.dispatchEvent(mouse('mousedown', 500, 500)); });
|
||||
// 25px right, 11px down -> deliberately NOT a whole number of mm
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 525, 511)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 525, 511)); });
|
||||
|
||||
const el = state.elements[0];
|
||||
expect(el.width).toBeCloseTo(roundMm(40 + pxToMm(25, 1)), 10);
|
||||
expect(el.height).toBeCloseTo(roundMm(6 + pxToMm(11, 1)), 10);
|
||||
// A grid-snapping implementation would have produced whole numbers here
|
||||
expect(Number.isInteger(el.width)).toBe(false);
|
||||
});
|
||||
|
||||
test('margin and centre overlay sit at the right mm and ignore the mouse', () => {
|
||||
mountCanvas({ elements: [] });
|
||||
|
||||
const overlay = container.querySelector('.a4-overlay');
|
||||
expect(overlay).toBeTruthy();
|
||||
|
||||
const vertical = container.querySelectorAll('.a4-margin-line.vertical');
|
||||
expect(vertical[0].style.left).toBe(`${mmToPx(20, 1)}px`); // left margin 20mm
|
||||
expect(vertical[1].style.left).toBe(`${mmToPx(195, 1)}px`); // 210 - 15 right
|
||||
|
||||
const horizontal = container.querySelectorAll('.a4-margin-line.horizontal');
|
||||
expect(horizontal[0].style.top).toBe(`${mmToPx(15, 1)}px`); // top margin
|
||||
expect(horizontal[1].style.top).toBe(`${mmToPx(282, 1)}px`); // 297 - 15 bottom
|
||||
|
||||
const cv = container.querySelector('.a4-center-line.vertical');
|
||||
const ch = container.querySelector('.a4-center-line.horizontal');
|
||||
expect(cv.style.left).toBe(`${mmToPx(105, 1)}px`); // 210/2
|
||||
expect(ch.style.top).toBe(`${mmToPx(148.5, 1)}px`); // 297/2
|
||||
});
|
||||
|
||||
describe('A4Inspector MmInput', () => {
|
||||
function mountInspector(element) {
|
||||
const state = { element };
|
||||
function Harness() {
|
||||
const [el, setEl] = useState(element);
|
||||
state.element = el;
|
||||
// Phase 5: the inspector's data-binding section reads reportData, so the
|
||||
// provider is required even for an element that has no binding.
|
||||
return (
|
||||
<DataProvider>
|
||||
<A4Inspector element={el} onUpdate={(u) => setEl(prev => ({ ...prev, ...u }))} />
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
act(() => root.render(<Harness />));
|
||||
return state;
|
||||
}
|
||||
|
||||
const xInput = () => container.querySelectorAll('.inspector-section input[inputmode="decimal"]')[0];
|
||||
|
||||
// React maps onFocus/onBlur to the native focusin/focusout events, so
|
||||
// dispatching 'blur' here would be silently ignored.
|
||||
const focusIn = (input) =>
|
||||
act(() => input.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
|
||||
const focusOut = (input) =>
|
||||
act(() => input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
|
||||
|
||||
const typeInto = (input, value) => {
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
).set;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
test('typing 20.5 into X moves the element to exactly 20.5mm', () => {
|
||||
const state = mountInspector(makeElement({ x: 10 }));
|
||||
const input = xInput();
|
||||
focusIn(input);
|
||||
typeInto(input, '20.5');
|
||||
expect(state.element.x).toBe(20.5);
|
||||
});
|
||||
|
||||
test('typing "12." mid-edit does not wipe the field', () => {
|
||||
const state = mountInspector(makeElement({ x: 10 }));
|
||||
const input = xInput();
|
||||
focusIn(input);
|
||||
|
||||
// Character by character, as a user types
|
||||
typeInto(input, '1');
|
||||
typeInto(input, '12');
|
||||
typeInto(input, '12.');
|
||||
// THE regression: the draft must still read "12.", not have snapped to "12"
|
||||
expect(input.value).toBe('12.');
|
||||
|
||||
typeInto(input, '12.7');
|
||||
expect(input.value).toBe('12.7');
|
||||
expect(state.element.x).toBe(12.7);
|
||||
});
|
||||
|
||||
test('blur with an incomplete draft restores the last good value', () => {
|
||||
const state = mountInspector(makeElement({ x: 10 }));
|
||||
const input = xInput();
|
||||
focusIn(input);
|
||||
typeInto(input, '');
|
||||
focusOut(input);
|
||||
expect(state.element.x).toBe(10);
|
||||
expect(input.value).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
test('getA4Bounds is exclusive: a 40mm box at x=10 ends at 50', () => {
|
||||
const b = getA4Bounds(makeElement({ x: 10, width: 40, y: 20, height: 6 }));
|
||||
expect(b.right).toBe(50);
|
||||
expect(b.bottom).toBe(26);
|
||||
expect(b.centerX).toBe(30);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: mouseup must end the drag even when a guide is on screen.
|
||||
*
|
||||
* The drag listeners used to be re-subscribed on every render during a drag
|
||||
* (the effect depended on the unmemoized onDrag). Each re-subscribe removed the
|
||||
* mouseup listener before adding the next one, so a release landing in that gap
|
||||
* was lost and the element went on tracking the mouse until the next click.
|
||||
*
|
||||
* A snap is what made it routine rather than rare: once the element is held on
|
||||
* a guide its x/y stop changing, so the render triggered by that mousemove is
|
||||
* the guide overlay's alone, and it lands right between the move and the up.
|
||||
*/
|
||||
test('mouseup ends the drag while snapped to a guide, and the element stays put', () => {
|
||||
// A neighbour at x=100 gives a left-edge guide candidate to snap to.
|
||||
const neighbour = makeElement({ id: 'a4-text-2', x: 100, y: 150 });
|
||||
const dragged = makeElement({ id: 'a4-text-1', x: 20, y: 40 });
|
||||
const { state } = mountCanvas({
|
||||
elements: [dragged, neighbour], selected: ['a4-text-1']
|
||||
});
|
||||
|
||||
const el = container.querySelector('.a4-text-field');
|
||||
// Land the left edge within SNAP_PX of the neighbour's left edge (100mm).
|
||||
const toSnapPx = Math.round(mmToPx(100 - 20, 1)) - 2;
|
||||
|
||||
act(() => { el.dispatchEvent(mouse('mousedown', 300, 300)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 300 + toSnapPx, 300)); });
|
||||
|
||||
// The guide must actually be showing, or this test proves nothing.
|
||||
expect(container.querySelector('.a4-guide')).toBeTruthy();
|
||||
expect(state.elements[0].x).toBeCloseTo(100, 6);
|
||||
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 300 + toSnapPx, 300)); });
|
||||
|
||||
// The drag is over: further mouse movement must not move anything.
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 600, 500)); });
|
||||
|
||||
expect(state.elements[0].x).toBeCloseTo(100, 6);
|
||||
expect(state.elements[0].y).toBeCloseTo(40, 6);
|
||||
expect(container.querySelector('.a4-text-field').className).not.toMatch(/dragging/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same failure without any snapping: a re-render arriving between mousemove
|
||||
* and mouseup. This is the underlying bug; the guide case above is how a user
|
||||
* happens to hit it.
|
||||
*/
|
||||
test('mouseup ends the drag even if the canvas re-renders mid-drag', () => {
|
||||
const { state, setElements } = mountCanvas({
|
||||
elements: [makeElement()], selected: ['a4-text-1']
|
||||
});
|
||||
|
||||
const el = container.querySelector('.a4-text-field');
|
||||
act(() => { el.dispatchEvent(mouse('mousedown', 300, 300)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 337, 319)); });
|
||||
|
||||
// Force a re-render between the move and the release.
|
||||
act(() => { setElements(prev => [...prev]); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 337, 319)); });
|
||||
|
||||
const after = { x: state.elements[0].x, y: state.elements[0].y };
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 900, 900)); });
|
||||
|
||||
expect(state.elements[0].x).toBeCloseTo(after.x, 10);
|
||||
expect(state.elements[0].y).toBeCloseTo(after.y, 10);
|
||||
});
|
||||
|
||||
/**
|
||||
* A band spans the page's content width and is positioned by the margins, so it
|
||||
* moves only up and down. Dragging one sideways would leave it out of line with
|
||||
* the children that print inside it — and a later page-setup change would snap
|
||||
* it back anyway (reflowBands), silently discarding the move.
|
||||
*/
|
||||
describe('band drag is vertical-only', () => {
|
||||
const makeBand = (over = {}) => ({
|
||||
id: 'a4-band-1', type: 'a4-band',
|
||||
x: 20, y: 60, width: 175, height: 20,
|
||||
bandType: 'detail', dataSource: '', caption: '',
|
||||
children: [], parentBandId: null,
|
||||
...over
|
||||
});
|
||||
|
||||
test('a diagonal drag moves the band in y and leaves x untouched', () => {
|
||||
const { state } = mountCanvas({
|
||||
elements: [makeBand()], selected: ['a4-band-1']
|
||||
});
|
||||
|
||||
const node = container.querySelector('.a4-band');
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 300, 300)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 380, 340)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 380, 340)); });
|
||||
|
||||
const moved = state.elements[0];
|
||||
expect(moved.x).toBe(20); // pinned
|
||||
expect(moved.y).toBeCloseTo(roundMm(60 + pxToMm(40, 1)), 6); // free
|
||||
});
|
||||
|
||||
test('a band caught in a multi-selection holds x while the others move', () => {
|
||||
const { state } = mountCanvas({
|
||||
elements: [makeBand(), makeElement({ id: 'a4-text-1', x: 30, y: 150 })],
|
||||
selected: ['a4-band-1', 'a4-text-1']
|
||||
});
|
||||
|
||||
const node = container.querySelector('.a4-band');
|
||||
act(() => { node.dispatchEvent(mouse('mousedown', 300, 300)); });
|
||||
act(() => { document.dispatchEvent(mouse('mousemove', 360, 320)); });
|
||||
act(() => { document.dispatchEvent(mouse('mouseup', 360, 320)); });
|
||||
|
||||
const [bandAfter, textAfter] = state.elements;
|
||||
expect(bandAfter.x).toBe(20);
|
||||
expect(textAfter.x).toBeGreaterThan(30); // the plain element still moves in x
|
||||
// Both took the same vertical delta, so their relative offset survives.
|
||||
expect(bandAfter.y - 60).toBeCloseTo(textAfter.y - 150, 6);
|
||||
});
|
||||
|
||||
test('creating a band ignores the click X and pins it to the left margin', () => {
|
||||
const state = { created: null };
|
||||
function Harness() {
|
||||
const [elements, setElements] = useState([]);
|
||||
state.created = elements[0] || null;
|
||||
return (
|
||||
<A4Canvas
|
||||
elements={elements} selectedElementIds={[]}
|
||||
onElementSelect={() => {}} onSelectMultiple={() => {}} onDeselectAll={() => {}}
|
||||
onElementUpdate={() => {}} onElementDelete={() => {}}
|
||||
onAddElement={(el) => setElements([el])}
|
||||
toolMode="addBand" pageSetup={PAGE_SETUP} zoom={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
act(() => root.render(<Harness />));
|
||||
|
||||
// Click far to the RIGHT of the left margin; only the Y should be taken.
|
||||
const dxPx = Math.round(mmToPx(120, 1));
|
||||
const dyPx = Math.round(mmToPx(90, 1));
|
||||
act(() => {
|
||||
container.querySelector('.a4-page')
|
||||
.dispatchEvent(mouse('click', PAGE_ORIGIN.left + dxPx, PAGE_ORIGIN.top + dyPx));
|
||||
});
|
||||
|
||||
expect(state.created.type).toBe('a4-band');
|
||||
expect(state.created.x).toBe(PAGE_SETUP.margins.left); // 20, not 120
|
||||
expect(state.created.width).toBe(210 - 20 - 15); // content width
|
||||
expect(state.created.y).toBeCloseTo(roundMm(pxToMm(dyPx, 1)), 6); // Y still honoured
|
||||
});
|
||||
|
||||
test('the band renders as a strip: no left/right border', () => {
|
||||
mountCanvas({ elements: [makeBand()] });
|
||||
const node = container.querySelector('.a4-band');
|
||||
// Class is what carries the styling; assert the element is a band and that
|
||||
// the stylesheet rule (border-left/right: none) is the one in force.
|
||||
expect(node.className).toMatch(/a4-band--detail/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Phase 4 — the print path.
|
||||
*
|
||||
* These cover the three things that can silently destroy millimetre fidelity:
|
||||
* layout() emitting wrong coordinates, PrintDocument emitting wrong CSS units,
|
||||
* and the @media print visibility rule failing to match the DOM it is mounted
|
||||
* into. The physical ruler test on paper is the user's half; this is the half
|
||||
* that can be automated.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { layout } from '../layout';
|
||||
import { formatValue } from '../formatValue';
|
||||
import PrintDocument from '../print/PrintDocument';
|
||||
import { printA4Report } from '../print/printReport';
|
||||
import { buildCalibrationReport } from '../calibrationReport';
|
||||
import { getFontStack } from '../fonts';
|
||||
import { getA4Bounds } from '../geometry';
|
||||
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const PAGE_SETUP = {
|
||||
page: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial',
|
||||
defaultFontSize: 10
|
||||
};
|
||||
|
||||
const makeElement = (over = {}) => ({
|
||||
id: 'a4-text-1', type: 'a4-text',
|
||||
x: 20, y: 50, width: 170, height: 6,
|
||||
content: 'Text', fontFamily: 'Arial', fontSize: 10,
|
||||
bold: false, italic: false, align: 'left', color: '#000000',
|
||||
...over
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// layout() — purity and coordinates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('layout()', () => {
|
||||
test('places elements on pages[0] at their design mm, with text resolved', () => {
|
||||
const els = [
|
||||
makeElement({ id: 'a', x: 20, y: 50, content: 'Line' }),
|
||||
makeElement({ id: 'b', x: 12.7, y: 100.4, content: 'АА1234ВВ' })
|
||||
];
|
||||
|
||||
const { pages } = layout(els, {}, PAGE_SETUP);
|
||||
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]).toHaveLength(2);
|
||||
expect(pages[0][0]).toMatchObject({ x: 20, y: 50, text: 'Line' });
|
||||
// Floats survive untouched — layout() must not round or snap.
|
||||
expect(pages[0][1]).toMatchObject({ x: 12.7, y: 100.4, text: 'АА1234ВВ' });
|
||||
// The element itself is carried through by reference for the renderer.
|
||||
expect(pages[0][0].el).toBe(els[0]);
|
||||
});
|
||||
|
||||
test('is pure: it does not mutate the elements it is given', () => {
|
||||
const els = [makeElement()];
|
||||
const snapshot = JSON.parse(JSON.stringify(els));
|
||||
|
||||
layout(els, {}, PAGE_SETUP);
|
||||
|
||||
expect(els).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test('is callable with no React context, DOM or providers mounted', () => {
|
||||
// The whole reason printReport.js can be called from Main.jsx in Phase 7.
|
||||
expect(() => layout([makeElement()], {}, PAGE_SETUP)).not.toThrow();
|
||||
// Repeated calls give identical output — no hidden state between runs.
|
||||
const a = layout([makeElement()], {}, PAGE_SETUP);
|
||||
const b = layout([makeElement()], {}, PAGE_SETUP);
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
});
|
||||
|
||||
test('empty and missing element lists still return the pages[] shape', () => {
|
||||
// Phase 6 grows this array; every caller must already handle the shape.
|
||||
expect(layout([], {}, PAGE_SETUP)).toEqual({ pages: [[]] });
|
||||
expect(layout(undefined, {}, PAGE_SETUP)).toEqual({ pages: [[]] });
|
||||
});
|
||||
|
||||
test('static text resolves to its content, empty content to an empty string', () => {
|
||||
const { pages } = layout(
|
||||
[makeElement({ content: '' }), makeElement({ id: 'x', content: undefined })],
|
||||
{}, PAGE_SETUP
|
||||
);
|
||||
expect(pages[0][0].text).toBe('');
|
||||
expect(pages[0][1].text).toBe('');
|
||||
});
|
||||
|
||||
test('bands are skipped in Phase 4 rather than printed as empty boxes', () => {
|
||||
const { pages } = layout(
|
||||
[makeElement(), { id: 'b1', type: 'a4-band', x: 0, y: 10, width: 210, height: 20 }],
|
||||
{}, PAGE_SETUP
|
||||
);
|
||||
expect(pages[0]).toHaveLength(1);
|
||||
expect(pages[0][0].el.id).toBe('a4-text-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValue (copied from DBTextField for the pure path)', () => {
|
||||
test('matches the matrix editor behaviour for each data type', () => {
|
||||
expect(formatValue('', 'number')).toBe('');
|
||||
expect(formatValue(null, 'text')).toBe('');
|
||||
expect(formatValue(undefined, 'text')).toBe('');
|
||||
expect(formatValue('42', 'number')).toBe('42');
|
||||
expect(formatValue('abc', 'number')).toBe('');
|
||||
expect(formatValue('not-a-date', 'date')).toBe('');
|
||||
expect(formatValue('АА1234ВВ', 'text')).toBe('АА1234ВВ');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PrintDocument — the actual CSS millimetre values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PrintDocument', () => {
|
||||
let container, root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (elements, pageSetup = PAGE_SETUP) => {
|
||||
const { pages } = layout(elements, {}, pageSetup);
|
||||
act(() => root.render(<PrintDocument pages={pages} pageSetup={pageSetup} />));
|
||||
};
|
||||
|
||||
test('emits mm units for position and size — not px, not unitless', () => {
|
||||
render([makeElement({ x: 20, y: 50, width: 170, height: 6 })]);
|
||||
|
||||
const el = container.querySelector('.a4-print-el');
|
||||
// This is the criterion the ruler physically measures.
|
||||
expect(el.style.left).toBe('20mm');
|
||||
expect(el.style.top).toBe('50mm');
|
||||
expect(el.style.width).toBe('170mm');
|
||||
expect(el.style.height).toBe('6mm');
|
||||
});
|
||||
|
||||
test('emits fractional mm without rounding', () => {
|
||||
render([makeElement({ x: 12.7, y: 100.4, width: 85.5 })]);
|
||||
|
||||
const el = container.querySelector('.a4-print-el');
|
||||
expect(el.style.left).toBe('12.7mm');
|
||||
expect(el.style.top).toBe('100.4mm');
|
||||
expect(el.style.width).toBe('85.5mm');
|
||||
});
|
||||
|
||||
test('font size is emitted in pt with no conversion', () => {
|
||||
// The element stores pt and CSS has real pt units, so the print path must
|
||||
// not apply the pt->px*zoom conversion the screen path needs.
|
||||
render([makeElement({ fontSize: 14 })]);
|
||||
expect(container.querySelector('.a4-print-el').style.fontSize).toBe('14pt');
|
||||
});
|
||||
|
||||
test('font, weight, style, alignment and colour reach the inline style', () => {
|
||||
render([makeElement({
|
||||
fontFamily: 'Calibri', bold: true, italic: true, align: 'right', color: '#ff0000'
|
||||
})]);
|
||||
|
||||
const el = container.querySelector('.a4-print-el');
|
||||
// Same stack string the screen uses — WYSIWYG by construction.
|
||||
expect(el.style.fontFamily).toBe(getFontStack('Calibri'));
|
||||
expect(el.style.fontWeight).toBe('700');
|
||||
expect(el.style.fontStyle).toBe('italic');
|
||||
expect(el.style.textAlign).toBe('right');
|
||||
expect(el.style.color).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('renders the resolved text, Cyrillic intact', () => {
|
||||
render([makeElement({ content: 'АА1234ВВ' })]);
|
||||
expect(container.querySelector('.a4-print-el').textContent).toBe('АА1234ВВ');
|
||||
});
|
||||
|
||||
test('page box is 210x297mm portrait and 297x210mm landscape', () => {
|
||||
render([makeElement()]);
|
||||
let page = container.querySelector('.a4-print-page');
|
||||
expect(page.className).not.toContain('landscape');
|
||||
|
||||
render([makeElement()], { ...PAGE_SETUP, orientation: 'landscape' });
|
||||
page = container.querySelector('.a4-print-page');
|
||||
expect(page.className).toContain('a4-print-page--landscape');
|
||||
});
|
||||
|
||||
test('uses .a4-print-page, NOT .a4-page — A4Canvas.css already owns that name', () => {
|
||||
render([makeElement()]);
|
||||
// A collision here would make the editor canvas and the printed page fight
|
||||
// over background/shadow, since both stylesheets load together.
|
||||
expect(container.querySelector('.a4-page')).toBeNull();
|
||||
expect(container.querySelector('.a4-print-page')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('one page element per layout page, elements nested inside it', () => {
|
||||
render([makeElement({ id: 'a' }), makeElement({ id: 'b' })]);
|
||||
const pages = container.querySelectorAll('.a4-print-page');
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0].querySelectorAll('.a4-print-el')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('an element className is preserved alongside a4-print-el', () => {
|
||||
render([makeElement({ className: 'a4-calibration-mark' })]);
|
||||
const el = container.querySelector('.a4-print-el');
|
||||
expect(el.classList.contains('a4-calibration-mark')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The @media print visibility rule vs. the real mounted DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('print visibility rule', () => {
|
||||
test('matches against the REAL mounted app tree, not a synthetic one', async () => {
|
||||
// The app mounts into <div id="root">, a direct child of <body> (see
|
||||
// public/index.html + src/index.jsx). This renders a real component into
|
||||
// that real structure, then starts a real print, and checks the selector
|
||||
// against what is actually in the document — the one check that catches
|
||||
// "the print root ended up nested and the whole app printed instead".
|
||||
const appRoot = document.createElement('div');
|
||||
appRoot.id = 'root';
|
||||
document.body.appendChild(appRoot);
|
||||
const appReactRoot = createRoot(appRoot);
|
||||
act(() => appReactRoot.render(<div className="report-editor">editor</div>));
|
||||
|
||||
window.print = vi.fn();
|
||||
let finished;
|
||||
await act(async () => {
|
||||
finished = printA4Report(
|
||||
{ pageSetup: PAGE_SETUP, elements: [makeElement()] }, {}
|
||||
);
|
||||
});
|
||||
|
||||
const hidden = [...document.body.children]
|
||||
.filter(n => n.matches('body > *:not(.print-root)'));
|
||||
const shown = [...document.body.children]
|
||||
.filter(n => n.matches('.print-root'));
|
||||
|
||||
// Exactly one print root, and it is NOT among the hidden nodes.
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0].querySelector('.a4-print-el')).not.toBeNull();
|
||||
expect(hidden).not.toContain(shown[0]);
|
||||
// The live editor tree IS hidden by the rule.
|
||||
expect(hidden).toContain(appRoot);
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
await finished;
|
||||
});
|
||||
|
||||
act(() => appReactRoot.unmount());
|
||||
appRoot.remove();
|
||||
});
|
||||
|
||||
test('the print container is a DIRECT child of body and carries .print-root', () => {
|
||||
// print.css hides everything with `body > *:not(.print-root)`, a CHILD
|
||||
// combinator. If the class sat only on PrintDocument's inner div, the
|
||||
// appended container would match the hiding rule and the print would be
|
||||
// blank. This asserts the structural contract that rule depends on.
|
||||
const appRoot = document.createElement('div');
|
||||
appRoot.id = 'root';
|
||||
document.body.appendChild(appRoot);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'print-root';
|
||||
document.body.appendChild(container);
|
||||
|
||||
expect(container.parentElement).toBe(document.body);
|
||||
expect(container.matches('body > *:not(.print-root)')).toBe(false);
|
||||
// ...and the app's own root, a sibling, IS hidden by it.
|
||||
expect(appRoot.matches('body > *:not(.print-root)')).toBe(true);
|
||||
|
||||
appRoot.remove();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('print.css really contains the rules the tests assert against', () => {
|
||||
// The test runner stubs CSS imports to an empty module, so the selector tests
|
||||
// above would still pass if print.css were deleted. Read the file itself so
|
||||
// the shipped stylesheet is what is being verified.
|
||||
const css = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'print', 'print.css'), 'utf8'
|
||||
);
|
||||
|
||||
expect(css).toMatch(/@page\s*\{[^}]*size:\s*A4 portrait/);
|
||||
expect(css).toMatch(/@page\s*\{[^}]*margin:\s*0/);
|
||||
expect(css).toContain('body > *:not(.print-root)');
|
||||
expect(css).toMatch(/\.a4-print-page\s*\{[^}]*width:\s*210mm/);
|
||||
expect(css).toMatch(/\.a4-print-page\s*\{[^}]*height:\s*297mm/);
|
||||
expect(css).toMatch(/\.a4-print-page--landscape\s*\{[^}]*width:\s*297mm/);
|
||||
expect(css).toMatch(/\.a4-print-el\s*\{[^}]*position:\s*absolute/);
|
||||
// Must NOT style .a4-page — A4Canvas.css owns that name for the editor.
|
||||
expect(css).not.toMatch(/^\.a4-page[\s{,]/m);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// printReport() — mounting and, critically, teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('printA4Report', () => {
|
||||
let printSpy;
|
||||
const originalPrint = window.print;
|
||||
|
||||
beforeEach(() => {
|
||||
printSpy = vi.fn();
|
||||
window.print = printSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.print = originalPrint;
|
||||
document.getElementById('a4-print-landscape-page-rule')?.remove();
|
||||
});
|
||||
|
||||
const report = () => ({
|
||||
name: 'T', kind: 'a4', pageSetup: PAGE_SETUP,
|
||||
elements: [makeElement({ content: 'АА1234ВВ' })]
|
||||
});
|
||||
|
||||
/**
|
||||
* Fire afterprint and let printA4Report's teardown complete. Cleanup is
|
||||
* deferred through a setTimeout(0) (React forbids unmounting from inside the
|
||||
* event that triggered it), so the timer has to be flushed before asserting.
|
||||
*/
|
||||
const finishPrint = async (pending) => {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
await pending;
|
||||
});
|
||||
};
|
||||
|
||||
test('mounts a .print-root on body, calls window.print, then removes it', async () => {
|
||||
const before = document.body.innerHTML;
|
||||
|
||||
let finished;
|
||||
await act(async () => { finished = printA4Report(report(), {}); });
|
||||
|
||||
// Mounted and rendered before print() was called.
|
||||
const mounted = document.querySelector('.print-root');
|
||||
expect(mounted).not.toBeNull();
|
||||
expect(mounted.parentElement).toBe(document.body);
|
||||
expect(mounted.querySelector('.a4-print-el').style.left).toBe('20mm');
|
||||
expect(printSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await finishPrint(finished);
|
||||
|
||||
// Teardown: the editor's DOM is exactly as it was.
|
||||
expect(document.querySelector('.print-root')).toBeNull();
|
||||
expect(document.body.innerHTML).toBe(before);
|
||||
});
|
||||
|
||||
test('accepts the API shape (page_setup) as well as the editor shape', async () => {
|
||||
let finished;
|
||||
await act(async () => {
|
||||
finished = printA4Report(
|
||||
{ elements: [makeElement()], page_setup: { ...PAGE_SETUP, orientation: 'landscape' } },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.querySelector('.a4-print-page--landscape')).not.toBeNull();
|
||||
// @page size cannot come from a class, so a rule is injected for landscape.
|
||||
expect(document.getElementById('a4-print-landscape-page-rule')).not.toBeNull();
|
||||
|
||||
await finishPrint(finished);
|
||||
|
||||
expect(document.getElementById('a4-print-landscape-page-rule')).toBeNull();
|
||||
});
|
||||
|
||||
test('portrait does not inject the landscape @page rule', async () => {
|
||||
let finished;
|
||||
await act(async () => { finished = printA4Report(report(), {}); });
|
||||
expect(document.getElementById('a4-print-landscape-page-rule')).toBeNull();
|
||||
await finishPrint(finished);
|
||||
});
|
||||
|
||||
test('repeated prints do not accumulate containers', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let finished;
|
||||
await act(async () => { finished = printA4Report(report(), {}); });
|
||||
await finishPrint(finished);
|
||||
// Never more than one at a time, and none left behind.
|
||||
expect(document.querySelectorAll('.print-root')).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a duplicate afterprint does not double-unmount', async () => {
|
||||
let finished;
|
||||
await act(async () => { finished = printA4Report(report(), {}); });
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
await finished;
|
||||
});
|
||||
expect(document.querySelectorAll('.print-root')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a throwing window.print still tears the container down', async () => {
|
||||
// Otherwise a blocked dialog leaves hidden markup stuck in <body>.
|
||||
window.print = () => { throw new Error('blocked'); };
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await act(async () => { await printA4Report(report(), {}); });
|
||||
|
||||
expect(document.querySelectorAll('.print-root')).toHaveLength(0);
|
||||
err.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The calibration fixture — the numbers the user will measure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('calibration report (ruler-test fixture)', () => {
|
||||
test('the measured line is at x=20, y=50, width=170, 1mm thick', () => {
|
||||
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
|
||||
const line = pages[0].find(p => p.el.id === 'a4-cal-line');
|
||||
|
||||
expect(line.x).toBe(20);
|
||||
expect(line.y).toBe(50);
|
||||
expect(line.el.width).toBe(170);
|
||||
// Phase 5: a REAL a4-hline. Its thickness, not a height, is what the ruler
|
||||
// measures — and it is a different render path from every boxed type.
|
||||
expect(line.el.type).toBe('a4-hline');
|
||||
expect(line.el.thickness).toBe(1);
|
||||
});
|
||||
|
||||
test('the Cyrillic sample is at x=20, y=100 and survives layout', () => {
|
||||
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
|
||||
const text = pages[0].find(p => p.el.id === 'a4-cal-text');
|
||||
|
||||
expect(text.x).toBe(20);
|
||||
expect(text.y).toBe(100);
|
||||
expect(text.text).toBe('АА1234ВВ');
|
||||
});
|
||||
|
||||
test('crosshairs are centred on the four specified corners', () => {
|
||||
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
|
||||
const at = (id) => pages[0].find(p => p.el.id === id);
|
||||
|
||||
// Each crosshair is a 10mm horizontal arm and a 10mm vertical arm whose
|
||||
// centre lines cross at the named point.
|
||||
[['tl', 10, 10], ['tr', 190, 10], ['bl', 10, 287], ['br', 190, 287]]
|
||||
.forEach(([corner, cx, cy]) => {
|
||||
const h = at(`a4-cal-${corner}-h`);
|
||||
const v = at(`a4-cal-${corner}-v`);
|
||||
expect(h.x).toBe(cx - 5);
|
||||
expect(h.y).toBe(cy);
|
||||
expect(h.el.type).toBe('a4-hline');
|
||||
expect(h.el.width).toBe(10);
|
||||
expect(v.x).toBe(cx);
|
||||
expect(v.y).toBe(cy - 5);
|
||||
expect(v.el.type).toBe('a4-vline');
|
||||
expect(v.el.height).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
test('every calibration element fits inside the A4 page box', () => {
|
||||
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
|
||||
pages[0].forEach(({ el, x, y }) => {
|
||||
// Via getA4Bounds, not el.width/el.height: a line has only the dimension
|
||||
// it runs along, so reading the raw props would compare against undefined
|
||||
// and pass vacuously.
|
||||
const b = getA4Bounds(el);
|
||||
expect(x).toBeGreaterThanOrEqual(0);
|
||||
expect(y).toBeGreaterThanOrEqual(0);
|
||||
expect(b.right).toBeLessThanOrEqual(210);
|
||||
expect(b.bottom).toBeLessThanOrEqual(297);
|
||||
});
|
||||
});
|
||||
|
||||
test('the marks render as mm boxes in the print DOM', () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
|
||||
|
||||
act(() => root.render(<PrintDocument pages={pages} pageSetup={PAGE_SETUP} />));
|
||||
|
||||
const line = [...container.querySelectorAll('.a4-calibration-mark')]
|
||||
.find(n => n.style.width === '170mm');
|
||||
expect(line).toBeTruthy();
|
||||
expect(line.style.left).toBe('20mm');
|
||||
expect(line.style.top).toBe('50mm');
|
||||
expect(line.style.height).toBe('1mm');
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Band instancing for the A4 designer — millimetre floats, PURE (no React).
|
||||
*
|
||||
* Ported from `utils/bandRenderer.js` (the matrix editor's engine), which is on
|
||||
* the protected list and is NOT modified. Three differences, all forced by the
|
||||
* change of unit:
|
||||
*
|
||||
* 1. Arithmetic is in mm floats, not integer character rows.
|
||||
* 2. The inclusive `-1`/`+1` offsets are GONE. The matrix version tracks
|
||||
* `band.y + band.height - 1` as a band's end because row 10 of a 3-row band
|
||||
* starting at 8 is the LAST occupied row. Millimetres are continuous: a 3mm
|
||||
* band at y=8 occupies [8, 11) and ends at 11. Keeping the -1 would shave a
|
||||
* millimetre off every band and open a 1mm hole between consecutive bands.
|
||||
* 3. `calculateTotalContentHeight`'s hardcoded 66 (rows) becomes the page's
|
||||
* content height in mm, passed in by the caller.
|
||||
*
|
||||
* Everything else — including the recursive `parentBandId` nesting — ports
|
||||
* structurally unchanged, because it was already unit-agnostic.
|
||||
*/
|
||||
|
||||
/** Resolve a dotted path in an object (e.g. 'contact.phone'). */
|
||||
function resolveNestedPath(obj, path) {
|
||||
if (!path || !obj) return undefined;
|
||||
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The array a band iterates over.
|
||||
*
|
||||
* @param {Object} reportData full report data
|
||||
* @param {string} dataSource key/path of the array
|
||||
* @param {Object} parentBandData current row of the enclosing band, if nested
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getBandDataArray(reportData, dataSource, parentBandData = null) {
|
||||
if (!dataSource) return [];
|
||||
|
||||
// Inside a parent band the source is resolved against that band's CURRENT row,
|
||||
// which is what makes a subdetail iterate per-parent rather than globally.
|
||||
if (parentBandData) {
|
||||
const data = resolveNestedPath(parentBandData, dataSource);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
if (!reportData) return [];
|
||||
const data = reportData[dataSource];
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand bands into positioned instances, one per data row.
|
||||
*
|
||||
* @param {Array} bands the `a4-band` elements
|
||||
* @param {Object} reportData full report data
|
||||
* @param {Array} allElements every element (children are looked up by id)
|
||||
* @param {Object} parentBandData current row of the enclosing band, if nested
|
||||
* @param {number} yOffset laid-out Y of the parent instance (nested only)
|
||||
* @param {number} parentDesignY design Y of the parent band (nested only)
|
||||
* @returns {Array<{bandId, instanceIndex, y, bandX, height, data, childElements,
|
||||
* originalBandY, bandType}>} in flow order
|
||||
*/
|
||||
export function calculateBandInstances(
|
||||
bands,
|
||||
reportData,
|
||||
allElements,
|
||||
parentBandData = null,
|
||||
yOffset = null,
|
||||
parentDesignY = null
|
||||
) {
|
||||
const instances = [];
|
||||
|
||||
const currentLevelBands = bands.filter(band =>
|
||||
parentBandData ? band.parentBandId === parentBandData.parentBandId : !band.parentBandId
|
||||
);
|
||||
|
||||
const sortedBands = [...currentLevelBands].sort((a, b) => a.y - b.y);
|
||||
|
||||
// Where the previous band ended, in DESIGN space and in LAID-OUT space. The
|
||||
// difference between the two is the accumulated growth from data iteration.
|
||||
let previousBandDesignEnd = null;
|
||||
let previousBandPreviewEnd = null;
|
||||
let currentY = 0;
|
||||
|
||||
for (const band of sortedBands) {
|
||||
if (previousBandDesignEnd === null) {
|
||||
if (yOffset !== null && parentDesignY !== null) {
|
||||
// Nested: keep the designed offset from the parent's top.
|
||||
currentY = yOffset + (band.y - parentDesignY);
|
||||
} else {
|
||||
currentY = band.y;
|
||||
}
|
||||
} else {
|
||||
// Preserve the designed gap between bands, after the previous band has
|
||||
// grown by however many rows it rendered.
|
||||
const designGap = band.y - previousBandDesignEnd;
|
||||
currentY = previousBandPreviewEnd + designGap;
|
||||
}
|
||||
|
||||
const dataArray = getBandDataArray(reportData, band.dataSource, parentBandData);
|
||||
const childElements = allElements.filter(
|
||||
el => band.children && band.children.includes(el.id)
|
||||
);
|
||||
const nestedBands = bands.filter(b => b.parentBandId === band.id);
|
||||
|
||||
const emit = (data, instanceIndex) => {
|
||||
instances.push({
|
||||
bandId: band.id,
|
||||
instanceIndex,
|
||||
y: currentY,
|
||||
// The band's left edge, so layout() can turn a child's band-relative x
|
||||
// into a page x. Children render inside the band div on the canvas, so
|
||||
// CSS supplies this offset there for free.
|
||||
bandX: band.x || 0,
|
||||
height: band.height,
|
||||
data,
|
||||
childElements,
|
||||
originalBandY: band.y,
|
||||
bandType: band.bandType
|
||||
});
|
||||
|
||||
if (nestedBands.length > 0) {
|
||||
const nestedInstances = calculateBandInstances(
|
||||
nestedBands,
|
||||
reportData,
|
||||
allElements,
|
||||
// A static band (no data) passes no row down, so a nested band under it
|
||||
// resolves its own source against reportData instead.
|
||||
data === null ? null : { ...data, parentBandId: band.id },
|
||||
currentY,
|
||||
band.y
|
||||
);
|
||||
instances.push(...nestedInstances);
|
||||
currentY = nestedInstances.reduce(
|
||||
(max, ni) => Math.max(max, ni.y + ni.height),
|
||||
currentY + band.height
|
||||
);
|
||||
} else {
|
||||
currentY += band.height;
|
||||
}
|
||||
};
|
||||
|
||||
if (dataArray.length === 0) {
|
||||
// No data — render once with null. This is how header/footer/summary bands
|
||||
// and any unbound band get exactly one instance.
|
||||
emit(null, 0);
|
||||
} else {
|
||||
dataArray.forEach((item, index) => emit(item, index));
|
||||
}
|
||||
|
||||
previousBandDesignEnd = band.y + band.height;
|
||||
previousBandPreviewEnd = currentY;
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total laid-out height in mm, before pagination splits it into pages.
|
||||
*
|
||||
* @param {number} minHeightMm the page's content height — the matrix version
|
||||
* hardcoded 66 rows here, which is meaningless in mm.
|
||||
*/
|
||||
export function calculateTotalContentHeight(bands, reportData, allElements, minHeightMm) {
|
||||
if (!bands || bands.length === 0) return minHeightMm;
|
||||
|
||||
const instances = calculateBandInstances(bands, reportData, allElements);
|
||||
if (instances.length === 0) return minHeightMm;
|
||||
|
||||
return instances.reduce((max, i) => Math.max(max, i.y + i.height), minHeightMm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrays available as a band's dataSource — drives the inspector dropdown.
|
||||
*
|
||||
* With a parent band, the candidates come from a SAMPLE ROW of the parent's
|
||||
* array rather than from the top level, because a nested band's source is
|
||||
* resolved against the parent's current row.
|
||||
*/
|
||||
export function getAvailableArrays(reportData, parentBand = null) {
|
||||
const arrays = [];
|
||||
|
||||
if (parentBand && parentBand.dataSource) {
|
||||
const parentDataArray = reportData?.[parentBand.dataSource];
|
||||
if (Array.isArray(parentDataArray) && parentDataArray.length > 0) {
|
||||
findArraysInObject(parentDataArray[0], arrays);
|
||||
return arrays;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (reportData && typeof reportData === 'object') {
|
||||
for (const key of Object.keys(reportData)) {
|
||||
if (Array.isArray(reportData[key])) {
|
||||
arrays.push({ key, path: key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arrays;
|
||||
}
|
||||
|
||||
/** Collect arrays inside an object, recursing through plain objects only. */
|
||||
function findArraysInObject(obj, arrays, prefix = '') {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
arrays.push({ key, path });
|
||||
// Deliberately no recursion INTO arrays — only immediate arrays are
|
||||
// usable as a data source.
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
findArraysInObject(value, arrays, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/* A4 designer element styles — CSS borders and real fonts, no box characters. */
|
||||
|
||||
.a4-text-field {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
cursor: move;
|
||||
outline: 1px dashed transparent;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.a4-text-field:hover {
|
||||
outline-color: #b9c2f0;
|
||||
}
|
||||
|
||||
.a4-text-field.selected {
|
||||
outline: 1px solid #667eea;
|
||||
}
|
||||
|
||||
.a4-text-field.dragging {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.a4-text-field-content {
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.a4-text-field-input {
|
||||
width: 100%;
|
||||
border: 1px solid #667eea;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.a4-text-field-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Data-bound and expression fields share the text box's behaviour. */
|
||||
.a4-db-text-field,
|
||||
.a4-expr-field {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
cursor: move;
|
||||
outline: 1px dashed transparent;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.a4-db-text-field:hover,
|
||||
.a4-expr-field:hover {
|
||||
outline-color: #b9c2f0;
|
||||
}
|
||||
|
||||
.a4-db-text-field.selected,
|
||||
.a4-expr-field.selected {
|
||||
outline: 1px solid #667eea;
|
||||
}
|
||||
|
||||
.a4-db-text-field.dragging,
|
||||
.a4-expr-field.dragging {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* An unbound field is invisible on a blank page otherwise — this is a DESIGN
|
||||
affordance only; nothing in the print path reads this class. */
|
||||
.a4-db-text-field.unresolved,
|
||||
.a4-expr-field.unresolved {
|
||||
outline: 1px dashed #e08a3c;
|
||||
background: rgba(224, 138, 60, 0.06);
|
||||
}
|
||||
|
||||
.a4-field-content {
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Rectangle — a real CSS border, sized in mm. */
|
||||
.a4-rect {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.a4-rect.selected {
|
||||
outline: 1px solid #667eea;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.a4-rect.dragging {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Lines. The visible box IS the line, so selection must never be drawn as a
|
||||
border (that would change the apparent thickness) — an offset outline keeps
|
||||
the measured geometry honest. */
|
||||
.a4-hline,
|
||||
.a4-vline {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.a4-hline.selected,
|
||||
.a4-vline.selected {
|
||||
outline: 1px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.a4-hline.dragging,
|
||||
.a4-vline.dragging {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* A 0.3mm line is about 1px on screen and effectively unclickable. This
|
||||
invisible pseudo-element widens the GRAB area to ~9px across without
|
||||
touching the painted line's millimetres. */
|
||||
.a4-hline::before,
|
||||
.a4-vline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.a4-hline::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
.a4-vline::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
/* Calibration marks (the ruler-test fixture). Now that a4-hline/a4-vline
|
||||
exist, the calibration report uses REAL lines; this class survives only to
|
||||
mark them visually distinct in the editor. Same rule in print.css. */
|
||||
.a4-calibration-mark {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.a4-resize-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #667eea;
|
||||
border-radius: 1px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.a4-resize-handle:hover {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
/* Transitions are disabled globally while useElementDrag is active */
|
||||
body.dragging-active .a4-text-field,
|
||||
body.dragging-active .a4-db-text-field,
|
||||
body.dragging-active .a4-expr-field,
|
||||
body.dragging-active .a4-rect,
|
||||
body.dragging-active .a4-hline,
|
||||
body.dragging-active .a4-vline {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Band container — a DESIGN-TIME affordance only. layout() never emits the band
|
||||
itself, so none of this reaches paper; only its children print. */
|
||||
/* A horizontal STRIP, not a box: top/bottom rules only, as in the matrix
|
||||
editor. A band spans the content width and moves only up and down, so side
|
||||
borders would suggest a left/right edge the user can take hold of. */
|
||||
.a4-band {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #7a8bb8;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
background: rgba(122, 139, 184, 0.05);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.a4-band.selected {
|
||||
border-color: #667eea;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.a4-band.dragging {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Colour-code the band types so a stack of them is readable at a glance. */
|
||||
.a4-band--header { border-color: #3c8ae0; }
|
||||
.a4-band--footer { border-color: #3c8ae0; }
|
||||
.a4-band--detail { border-color: #4caf7d; }
|
||||
.a4-band--subdetail { border-color: #9c8ae0; }
|
||||
.a4-band--summary { border-color: #e0913c; }
|
||||
|
||||
.a4-band-caption {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0 4px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
color: #40506f;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
body.dragging-active .a4-band {
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The calibration report — the fixture for the plan's §8 "ruler test".
|
||||
*
|
||||
* This is the acceptance test for millimetre correctness, and it runs on PAPER,
|
||||
* with a steel rule. Everything in the A4 designer is built on the assumption
|
||||
* that a stored millimetre prints as a physical millimetre; this report is what
|
||||
* proves it.
|
||||
*
|
||||
* PHASE 5 CHANGE — the marks are now REAL LINES.
|
||||
* Phase 4 had only one element type, `a4-text`, so each "line" was a text box
|
||||
* with empty content whose painted background stood in for the line. That
|
||||
* measured the shared coordinate pipeline, but it could not measure the hline
|
||||
* render path, because there was none. Now that `a4-hline` and `a4-vline`
|
||||
* exist, the fixture uses them: the ruler test now exercises exactly the
|
||||
* element type a real report will use for a rule, including its own
|
||||
* `thickness` -> CSS mm height conversion, which is a separate code path from
|
||||
* the `height` every other type uses.
|
||||
*
|
||||
* The geometry is unchanged from Phase 4, so a sheet printed before and after
|
||||
* this change must measure identically. If it does not, the hline render path
|
||||
* is what differs — which is precisely what this change makes measurable.
|
||||
*
|
||||
* The marks are 1mm thick, so "measure the line's top edge" is unambiguous.
|
||||
*/
|
||||
|
||||
const MARK_THICKNESS_MM = 1;
|
||||
|
||||
const hline = (id, x, y, width) => ({
|
||||
id,
|
||||
type: 'a4-hline',
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
thickness: MARK_THICKNESS_MM,
|
||||
color: '#000000',
|
||||
className: 'a4-calibration-mark'
|
||||
});
|
||||
|
||||
const vline = (id, x, y, height) => ({
|
||||
id,
|
||||
type: 'a4-vline',
|
||||
x,
|
||||
y,
|
||||
height,
|
||||
thickness: MARK_THICKNESS_MM,
|
||||
color: '#000000',
|
||||
className: 'a4-calibration-mark'
|
||||
});
|
||||
|
||||
/** Crosshair at (cx, cy): a 10mm horizontal and a 10mm vertical arm, 1mm thick. */
|
||||
const crosshair = (id, cx, cy) => [
|
||||
hline(`${id}-h`, cx - 5, cy, 10),
|
||||
vline(`${id}-v`, cx, cy - 5, 10)
|
||||
];
|
||||
|
||||
const label = (id, y, content, bold = false) => ({
|
||||
id,
|
||||
type: 'a4-text',
|
||||
x: 20,
|
||||
y,
|
||||
width: 170,
|
||||
height: 6,
|
||||
content,
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 9,
|
||||
bold,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
export const CALIBRATION_PAGE_SETUP = {
|
||||
page: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 20 },
|
||||
defaultFont: 'Arial',
|
||||
defaultFontSize: 10
|
||||
};
|
||||
|
||||
export function buildCalibrationReport() {
|
||||
return {
|
||||
name: 'A4 calibration (ruler test)',
|
||||
kind: 'a4',
|
||||
pageSetup: { ...CALIBRATION_PAGE_SETUP, margins: { ...CALIBRATION_PAGE_SETUP.margins } },
|
||||
apiEndpoint: '',
|
||||
elements: [
|
||||
// THE MEASUREMENT. Left end at x=20mm, top edge at y=50mm, length 170mm.
|
||||
hline('a4-cal-line', 20, 50, 170),
|
||||
|
||||
// Cyrillic, to prove the font really renders it (and is not a fallback
|
||||
// box). 14pt so it is legible enough to compare against Word on paper.
|
||||
{
|
||||
id: 'a4-cal-text',
|
||||
type: 'a4-text',
|
||||
x: 20,
|
||||
y: 100,
|
||||
width: 80,
|
||||
height: 8,
|
||||
content: 'АА1234ВВ',
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 14,
|
||||
bold: false,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
color: '#000000'
|
||||
},
|
||||
|
||||
// Corner crosshairs — a consistent offset in all four proves a page-setup
|
||||
// or unprintable-area problem; an offset in only one or two proves skew.
|
||||
...crosshair('a4-cal-tl', 10, 10),
|
||||
...crosshair('a4-cal-tr', 190, 10),
|
||||
...crosshair('a4-cal-bl', 10, 287),
|
||||
...crosshair('a4-cal-br', 190, 287),
|
||||
|
||||
// Labels so the printed sheet documents its own expected values — the
|
||||
// person with the ruler should not have to read this file.
|
||||
label('a4-cal-label-line', 40,
|
||||
'Line below: left edge = 20mm, top edge = 50mm, length = 170mm'),
|
||||
label('a4-cal-label-text', 92,
|
||||
'Cyrillic below at x=20mm, y=100mm. Crosshair centres: 10/10, 190/10, 10/287, 190/287'),
|
||||
label('a4-cal-label-scale', 120,
|
||||
'Print at 100% / Actual size, with Headers and footers UNTICKED.', true)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export default buildCalibrationReport;
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import A4ResizeHandles, { VERTICAL_HANDLES } from './A4ResizeHandles';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { mmToPx } from '../units';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Band container for the A4 designer.
|
||||
*
|
||||
* DESIGN-TIME ONLY. The dashed border and the caption are affordances for the
|
||||
* editor; `layout()` never emits the band itself, so nothing here reaches paper.
|
||||
* What prints is the band's CHILDREN, once per data row.
|
||||
*
|
||||
* Two deliberate constraints:
|
||||
*
|
||||
* - X and WIDTH are not user-set. A band spans the page's content width, which
|
||||
* is derived from page_setup's margins. Offering a width control would let a
|
||||
* user set one that the next margin change silently contradicts.
|
||||
* - Only the VERTICAL resize handles are offered, for the same reason — a west
|
||||
* or east handle would write a width the layout engine recomputes anyway.
|
||||
*
|
||||
* Children are rendered INSIDE this element, so their stored `y` is relative to
|
||||
* the band (matching the matrix editor) and CSS positioning does the offset for
|
||||
* free rather than every child needing to know its parent's position.
|
||||
*/
|
||||
function A4Band({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode,
|
||||
children
|
||||
}) {
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
// A mousedown that started on a CHILD must not also start a band drag, or
|
||||
// moving one field inside a band would drag the whole band with it.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${mmToPx(element.width, zoom)}px`,
|
||||
height: `${mmToPx(element.height, zoom)}px`
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-band',
|
||||
`a4-band--${element.bandType || 'detail'}`,
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const caption = [
|
||||
element.caption,
|
||||
element.bandType || 'detail',
|
||||
element.dataSource ? `→ ${element.dataSource}` : null
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-band-type={element.bandType || 'detail'}
|
||||
>
|
||||
<span className="a4-band-caption">{caption}</span>
|
||||
{children}
|
||||
{isSingleSelection && (
|
||||
<A4ResizeHandles
|
||||
element={element}
|
||||
onResize={onUpdate}
|
||||
zoom={zoom}
|
||||
handles={VERTICAL_HANDLES}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Band;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import A4ResizeHandles from './A4ResizeHandles';
|
||||
import { useReportData } from '../../DataContext';
|
||||
import { resolveDBTextValue } from '../../utils/dataResolver';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { formatValue } from '../formatValue';
|
||||
import { mmToPx, ptToPx } from '../units';
|
||||
import { getFontStack } from '../fonts';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Data-bound field for the A4 designer.
|
||||
*
|
||||
* Resolution logic follows the matrix `DBTextField.jsx`: design mode shows the
|
||||
* binding as a `{object.field}` placeholder so an unbound field is visible on a
|
||||
* blank canvas, preview mode shows the resolved and formatted value. The
|
||||
* styling is mm-native instead of charWidth/charHeight, and it uses the shared
|
||||
* `a4/formatValue.js` rather than a third local copy of that function.
|
||||
*
|
||||
* Bands are Phase 6, so there is no parentBandId here yet: `resolveDBTextValue`
|
||||
* is called with the top-level reportData only.
|
||||
*/
|
||||
function A4DBTextField({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode,
|
||||
previewMode = false
|
||||
}) {
|
||||
const { reportData } = useReportData();
|
||||
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const displayContent = previewMode
|
||||
? formatValue(
|
||||
resolveDBTextValue(reportData, element.objectKey, element.fieldPath),
|
||||
element.dataType || 'general'
|
||||
)
|
||||
: `{${element.objectKey || '?'}.${element.fieldPath || '?'}}`;
|
||||
|
||||
const isUnresolved = !element.objectKey || !element.fieldPath ||
|
||||
(previewMode && !displayContent);
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${mmToPx(element.width, zoom)}px`,
|
||||
height: `${mmToPx(element.height, zoom)}px`,
|
||||
fontFamily: getFontStack(element.fontFamily),
|
||||
fontSize: `${ptToPx(element.fontSize, zoom)}px`,
|
||||
fontWeight: element.bold ? 700 : 400,
|
||||
fontStyle: element.italic ? 'italic' : 'normal',
|
||||
textAlign: element.align || 'left',
|
||||
color: element.color || '#000000'
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-db-text-field',
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : '',
|
||||
isUnresolved ? 'unresolved' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="a4-field-content">{displayContent || '(No Data)'}</span>
|
||||
{isSingleSelection && (
|
||||
<A4ResizeHandles element={element} onResize={onUpdate} zoom={zoom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4DBTextField;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import A4ResizeHandles from './A4ResizeHandles';
|
||||
import { useReportData } from '../../DataContext';
|
||||
import { evaluateExpression } from '../../utils/expressionEvaluator';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { formatValue } from '../formatValue';
|
||||
import { makeFieldResolver } from '../layout';
|
||||
import { mmToPx, ptToPx } from '../units';
|
||||
import { getFontStack } from '../fonts';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Computed-expression field for the A4 designer.
|
||||
*
|
||||
* Same shape as the matrix `ExpressionField.jsx`: design mode shows the raw
|
||||
* expression source, preview mode shows the evaluated and formatted result.
|
||||
*
|
||||
* The `[object.field]` resolver is IMPORTED from layout.js rather than copied.
|
||||
* That matters beyond tidiness — the screen and the printed page must agree on
|
||||
* what a reference resolves to, and one function is the only way to guarantee
|
||||
* that. It is safe to import because layout.js is pure: no React, no DOM.
|
||||
*/
|
||||
function A4ExprField({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode,
|
||||
previewMode = false
|
||||
}) {
|
||||
const { reportData } = useReportData();
|
||||
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const displayContent = previewMode
|
||||
? formatValue(
|
||||
evaluateExpression(element.expression, makeFieldResolver(reportData)),
|
||||
element.dataType || 'general'
|
||||
)
|
||||
: (element.expression || '(expr)');
|
||||
|
||||
const isUnresolved = !element.expression;
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${mmToPx(element.width, zoom)}px`,
|
||||
height: `${mmToPx(element.height, zoom)}px`,
|
||||
fontFamily: getFontStack(element.fontFamily),
|
||||
fontSize: `${ptToPx(element.fontSize, zoom)}px`,
|
||||
fontWeight: element.bold ? 700 : 400,
|
||||
fontStyle: element.italic ? 'italic' : 'normal',
|
||||
textAlign: element.align || 'left',
|
||||
color: element.color || '#000000'
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-expr-field',
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : '',
|
||||
isUnresolved ? 'unresolved' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="a4-field-content">{displayContent || '(No Expr)'}</span>
|
||||
{isSingleSelection && (
|
||||
<A4ResizeHandles element={element} onResize={onUpdate} zoom={zoom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4ExprField;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import A4ResizeHandles, { HORIZONTAL_HANDLES } from './A4ResizeHandles';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { mmToPx } from '../units';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Horizontal rule for the A4 designer.
|
||||
*
|
||||
* A filled box `width` mm long and `thickness` mm tall — NOT a box-drawing
|
||||
* character, and with NO crossroad/junction detection. Junctions are a
|
||||
* character-grid concept: on a grid two lines meeting must agree on which glyph
|
||||
* occupies the shared cell. Millimetre lines simply overlap, and the browser
|
||||
* paints both.
|
||||
*
|
||||
* Background rather than border-top, so the painted height is exactly the
|
||||
* stored thickness with no box-sizing interaction to reason about.
|
||||
*
|
||||
* Hit target: a 0.3mm line is ~1px on screen, which is unclickable. The CSS
|
||||
* gives the element a transparent padded hit area via a pseudo-element, so the
|
||||
* visible line stays honest while the grab zone is usable.
|
||||
*/
|
||||
function A4HLine({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode
|
||||
}) {
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${mmToPx(element.width, zoom)}px`,
|
||||
// Floored at 1px so a hairline does not vanish on screen when zoomed out.
|
||||
// The printed thickness comes from PrintDocument's true mm, not from here.
|
||||
height: `${Math.max(1, mmToPx(element.thickness || 0, zoom))}px`,
|
||||
background: element.color || '#000000'
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-hline',
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{isSingleSelection && (
|
||||
<A4ResizeHandles
|
||||
element={element}
|
||||
onResize={onUpdate}
|
||||
zoom={zoom}
|
||||
handles={HORIZONTAL_HANDLES}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4HLine;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import A4ResizeHandles from './A4ResizeHandles';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { mmToPx } from '../units';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Rectangle / frame for the A4 designer.
|
||||
*
|
||||
* A REAL CSS border, not the matrix editor's box-drawing characters. Those
|
||||
* exist because a dot-matrix printer draws with a character grid; on A4 a
|
||||
* border is a border, and its thickness is a millimetre float.
|
||||
*
|
||||
* Border width is converted through the zoom like every other mm value, but
|
||||
* is floored at 1px on screen: at 50% zoom a 0.3mm border is 0.57px, which
|
||||
* some browsers round away to nothing, making the rect look deleted. Print is
|
||||
* unaffected — PrintDocument emits the true mm.
|
||||
*
|
||||
* `fillColor: null` means no fill (the default), so a rect can frame content
|
||||
* without hiding it. Only an explicit colour paints a background.
|
||||
*/
|
||||
function A4Rect({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode
|
||||
}) {
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const borderPx = element.borderWidth > 0
|
||||
? Math.max(1, mmToPx(element.borderWidth, zoom))
|
||||
: 0;
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${mmToPx(element.width, zoom)}px`,
|
||||
height: `${mmToPx(element.height, zoom)}px`,
|
||||
border: borderPx > 0
|
||||
? `${borderPx}px solid ${element.borderColor || '#000000'}`
|
||||
: 'none',
|
||||
background: element.fillColor || 'transparent'
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-rect',
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{isSingleSelection && (
|
||||
<A4ResizeHandles element={element} onResize={onUpdate} zoom={zoom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Rect;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { mmToPx, pxToMm, roundMm } from '../units';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Resize in millimetres.
|
||||
*
|
||||
* Structural reference: the matrix `ResizeHandles.jsx` — but deltas convert to mm
|
||||
* floats via pxToMm(delta, zoom) + roundMm. There is NO rounding to integer cells.
|
||||
*
|
||||
* The handle SET is a prop because lines have only one resizable dimension: an
|
||||
* hline is `width` + `thickness`, with no `height` at all. Offering it a south
|
||||
* handle would write a `height` the renderer never reads — a silent no-op the
|
||||
* user would read as a broken control. So an hline gets ['w','e'] and a vline
|
||||
* ['n','s'], and the code below only ever touches the axis its handle names.
|
||||
*/
|
||||
|
||||
const MIN_WIDTH_MM = 5;
|
||||
const MIN_HEIGHT_MM = 3;
|
||||
|
||||
export const ALL_HANDLES = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'];
|
||||
export const HORIZONTAL_HANDLES = ['w', 'e'];
|
||||
export const VERTICAL_HANDLES = ['n', 's'];
|
||||
|
||||
const CURSORS = {
|
||||
nw: 'nw-resize',
|
||||
n: 'n-resize',
|
||||
ne: 'ne-resize',
|
||||
w: 'w-resize',
|
||||
e: 'e-resize',
|
||||
sw: 'sw-resize',
|
||||
s: 's-resize',
|
||||
se: 'se-resize'
|
||||
};
|
||||
|
||||
function A4ResizeHandles({ element, onResize, zoom, handles = ALL_HANDLES }) {
|
||||
const [dragData, setDragData] = useState(null);
|
||||
|
||||
const getHandlePosition = (handle) => {
|
||||
// Lines carry only the dimension they run along; the other extent is their
|
||||
// thickness. Same substitution geometry.js makes, for the same reason.
|
||||
const width = mmToPx(
|
||||
element.type === 'a4-vline' ? (element.thickness || 0) : element.width, zoom
|
||||
);
|
||||
const height = mmToPx(
|
||||
element.type === 'a4-hline' ? (element.thickness || 0) : element.height, zoom
|
||||
);
|
||||
|
||||
const positions = {
|
||||
nw: { left: -4, top: -4 },
|
||||
n: { left: width / 2 - 4, top: -4 },
|
||||
ne: { left: width - 4, top: -4 },
|
||||
w: { left: -4, top: height / 2 - 4 },
|
||||
e: { left: width - 4, top: height / 2 - 4 },
|
||||
sw: { left: -4, top: height - 4 },
|
||||
s: { left: width / 2 - 4, top: height - 4 },
|
||||
se: { left: width - 4, top: height - 4 }
|
||||
};
|
||||
|
||||
return positions[handle];
|
||||
};
|
||||
|
||||
const handleMouseDown = (e, handle) => {
|
||||
e.stopPropagation();
|
||||
setDragData({
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
initialElement: { ...element }
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragData) return;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const deltaMmX = pxToMm(e.clientX - dragData.startX, zoom);
|
||||
const deltaMmY = pxToMm(e.clientY - dragData.startY, zoom);
|
||||
const initial = dragData.initialElement;
|
||||
const { handle } = dragData;
|
||||
|
||||
const updates = {};
|
||||
|
||||
// Horizontal: west handles move the origin, east handles only grow width
|
||||
if (handle.includes('w')) {
|
||||
const newWidth = Math.max(MIN_WIDTH_MM, initial.width - deltaMmX);
|
||||
updates.x = roundMm(initial.x + (initial.width - newWidth));
|
||||
updates.width = roundMm(newWidth);
|
||||
} else if (handle.includes('e')) {
|
||||
updates.width = roundMm(Math.max(MIN_WIDTH_MM, initial.width + deltaMmX));
|
||||
}
|
||||
|
||||
// Vertical: north handles move the origin, south handles only grow height
|
||||
if (handle.includes('n')) {
|
||||
const newHeight = Math.max(MIN_HEIGHT_MM, initial.height - deltaMmY);
|
||||
updates.y = roundMm(initial.y + (initial.height - newHeight));
|
||||
updates.height = roundMm(newHeight);
|
||||
} else if (handle.includes('s')) {
|
||||
updates.height = roundMm(Math.max(MIN_HEIGHT_MM, initial.height + deltaMmY));
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
onResize(updates);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setDragData(null);
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragData, onResize, zoom]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{handles.map(handle => (
|
||||
<div
|
||||
key={handle}
|
||||
className="a4-resize-handle"
|
||||
style={{ ...getHandlePosition(handle), cursor: CURSORS[handle] }}
|
||||
onMouseDown={(e) => handleMouseDown(e, handle)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4ResizeHandles;
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import A4ResizeHandles from './A4ResizeHandles';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { mmToPx, ptToPx } from '../units';
|
||||
import { getFontStack } from '../fonts';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Static text element for the A4 designer.
|
||||
*
|
||||
* Position/size are mm floats converted to px at render time; font size is pt
|
||||
* rendered as pt * (96/72) * zoom px. Nothing here writes a pixel value back.
|
||||
*/
|
||||
function A4TextField({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(element.content);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete: () => {
|
||||
if (!isEditing) onDelete();
|
||||
},
|
||||
toolMode
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelected) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
const handleDoubleClick = (e) => {
|
||||
if (toolMode === 'select') {
|
||||
e.stopPropagation();
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
setEditContent(element.content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
if (editContent !== element.content) {
|
||||
onUpdate({ content: editContent });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Stop the canvas-level shortcut handler seeing keys typed into this input
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
setEditContent(element.content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (isEditing) return;
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${mmToPx(element.width, zoom)}px`,
|
||||
height: `${mmToPx(element.height, zoom)}px`,
|
||||
fontFamily: getFontStack(element.fontFamily),
|
||||
fontSize: `${ptToPx(element.fontSize, zoom)}px`,
|
||||
fontWeight: element.bold ? 700 : 400,
|
||||
fontStyle: element.italic ? 'italic' : 'normal',
|
||||
textAlign: element.align || 'left',
|
||||
color: element.color || '#000000'
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-text-field',
|
||||
element.className || '',
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="a4-text-field-input"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="a4-text-field-content">{element.content}</span>
|
||||
)}
|
||||
{isSingleSelection && !isEditing && (
|
||||
<A4ResizeHandles
|
||||
element={element}
|
||||
onResize={onUpdate}
|
||||
zoom={zoom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4TextField;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import A4ResizeHandles, { VERTICAL_HANDLES } from './A4ResizeHandles';
|
||||
import { useElementDrag } from '../../hooks/useElementDrag';
|
||||
import { useElementSelection } from '../../hooks/useElementSelection';
|
||||
import { mmToPx } from '../units';
|
||||
import '../a4Elements.css';
|
||||
|
||||
/**
|
||||
* Vertical rule for the A4 designer. The transpose of A4HLine — see its header
|
||||
* for why this is a filled box with no junction detection.
|
||||
*/
|
||||
function A4VLine({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
zoom,
|
||||
toolMode
|
||||
}) {
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
onSelect();
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
const style = {
|
||||
left: `${mmToPx(element.x, zoom)}px`,
|
||||
top: `${mmToPx(element.y, zoom)}px`,
|
||||
width: `${Math.max(1, mmToPx(element.thickness || 0, zoom))}px`,
|
||||
height: `${mmToPx(element.height, zoom)}px`,
|
||||
background: element.color || '#000000'
|
||||
};
|
||||
|
||||
const className = [
|
||||
'a4-vline',
|
||||
isSelected ? 'selected' : '',
|
||||
isDragging ? 'dragging' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{isSingleSelection && (
|
||||
<A4ResizeHandles
|
||||
element={element}
|
||||
onResize={onUpdate}
|
||||
zoom={zoom}
|
||||
handles={VERTICAL_HANDLES}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4VLine;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Fonts offered by the A4 designer.
|
||||
*
|
||||
* Only Windows-bundled families, all of which cover Cyrillic. Chrome prints with
|
||||
* the fonts installed on the station, so no embedding or substitution is needed.
|
||||
*
|
||||
* The SAME stack string is used on screen and (Phase 4) in print CSS, so WYSIWYG
|
||||
* holds by construction — one font stack, one rendering engine.
|
||||
*/
|
||||
|
||||
export const FONT_STACKS = {
|
||||
'Arial': 'Arial, Helvetica, sans-serif',
|
||||
'Times New Roman': '"Times New Roman", Times, serif',
|
||||
'Courier New': '"Courier New", Courier, monospace',
|
||||
'Calibri': 'Calibri, Arial, sans-serif',
|
||||
'Segoe UI': '"Segoe UI", Tahoma, Arial, sans-serif',
|
||||
'Tahoma': 'Tahoma, Verdana, Arial, sans-serif',
|
||||
'Verdana': 'Verdana, Tahoma, Arial, sans-serif'
|
||||
};
|
||||
|
||||
export const FONT_FAMILIES = Object.keys(FONT_STACKS);
|
||||
|
||||
/** CSS font-family stack for a stored family name, falling back to Arial. */
|
||||
export const getFontStack = (family) => FONT_STACKS[family] || FONT_STACKS['Arial'];
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Value formatting for the A4 designer.
|
||||
*
|
||||
* Copied verbatim from `DBTextField.jsx:11-33` — where the same function is
|
||||
* already duplicated byte-for-byte in `ExpressionField.jsx`. Lifting it here
|
||||
* consolidates an existing duplicate rather than creating a new one, and gives
|
||||
* the pure `layout()` path a formatter that does not import React.
|
||||
*/
|
||||
|
||||
export function formatValue(raw, dataType) {
|
||||
if (raw === '' || raw === null || raw === undefined) return '';
|
||||
switch (dataType) {
|
||||
case 'number': {
|
||||
const n = Number(raw);
|
||||
return isNaN(n) ? '' : String(n);
|
||||
}
|
||||
case 'date': {
|
||||
const d = new Date(raw);
|
||||
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
||||
}
|
||||
case 'time': {
|
||||
const d = new Date(raw);
|
||||
return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
case 'datetime': {
|
||||
const d = new Date(raw);
|
||||
return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
default:
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export default formatValue;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Geometry for the A4 designer — millimetres, EXCLUSIVE bounds.
|
||||
*
|
||||
* NOTE the deliberate difference from the matrix editor: `models/Element.jsx`
|
||||
* uses inclusive bounds (`x + width - 1`), which is right for character cells
|
||||
* and wrong for millimetres. A 40mm-wide box at x=10 occupies [10, 50) and its
|
||||
* right edge — what Phase 3's alignment guides align to — is 50, not 49.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Exclusive bounds in mm: { left, top, right, bottom, centerX, middleY }.
|
||||
*
|
||||
* Lines carry only the dimension they run along (`width` for hline, `height`
|
||||
* for vline); their other extent is `thickness`. Without that substitution an
|
||||
* hline would have bottom === top, so it would be a zero-height box: invisible
|
||||
* to the rubber-band hit test and unalignable by Phase 3's guides.
|
||||
*/
|
||||
export function getA4Bounds(el) {
|
||||
const isHLine = el.type === 'a4-hline';
|
||||
const isVLine = el.type === 'a4-vline';
|
||||
const thickness = el.thickness || 0;
|
||||
|
||||
const spanX = isVLine ? thickness : (el.width || 0);
|
||||
const spanY = isHLine ? thickness : (el.height || 0);
|
||||
|
||||
const left = el.x;
|
||||
const top = el.y;
|
||||
const right = el.x + spanX;
|
||||
const bottom = el.y + spanY;
|
||||
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
centerX: (left + right) / 2,
|
||||
middleY: (top + bottom) / 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rubber-band hit test. `rect` is { minX, maxX, minY, maxY } in mm.
|
||||
* Touching edges count as intersecting, matching the exclusive convention.
|
||||
*/
|
||||
export function a4IntersectsRect(el, rect) {
|
||||
const b = getA4Bounds(el);
|
||||
return !(
|
||||
b.right < rect.minX ||
|
||||
b.left > rect.maxX ||
|
||||
b.bottom < rect.minY ||
|
||||
b.top > rect.maxY
|
||||
);
|
||||
}
|
||||
|
||||
/** Clamp an element's top-left so its box stays inside the page. */
|
||||
export function clampToPage(x, y, width, height, pageW, pageH) {
|
||||
return {
|
||||
x: Math.max(0, Math.min(pageW - width, x)),
|
||||
y: Math.max(0, Math.min(pageH - height, y))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Smart alignment guides for the A4 designer — pure functions, no React, no DOM.
|
||||
*
|
||||
* The feature the customer actually asked for: "helper lines to show if a field
|
||||
* matches other fields' positions". Two halves:
|
||||
*
|
||||
* buildGuideCandidates() — every position worth aligning to, built ONCE per
|
||||
* drag and held in a ref by A4Canvas.
|
||||
* resolveSnap() — per mousemove: the nudged x/y plus the lines to draw.
|
||||
*
|
||||
* Both work in millimetres, on the EXCLUSIVE bounds from geometry.js. A 40mm box
|
||||
* at x=10 has its right edge at 50, so aligning "right edge to right edge" means
|
||||
* comparing 50 to 50 — with inclusive bounds the two boxes would sit 0.1mm apart
|
||||
* and the guide would lie.
|
||||
*/
|
||||
|
||||
import { PX_PER_MM, getPageSize } from './units';
|
||||
import { getA4Bounds } from './geometry';
|
||||
|
||||
/**
|
||||
* Snap threshold in SCREEN pixels, deliberately not millimetres.
|
||||
*
|
||||
* resolveSnap divides this by (PX_PER_MM * zoom), so the magnet's grab distance
|
||||
* is a constant number of pixels under the cursor at every zoom level — which is
|
||||
* what a user means by "it feels the same". A fixed mm threshold would instead
|
||||
* feel sticky when zoomed in (6mm of screen travel at 150%) and useless when
|
||||
* zoomed out. At 100% this works out to ~1.6mm.
|
||||
*/
|
||||
export const SNAP_PX = 6;
|
||||
|
||||
/** Threshold in mm for the current zoom. Exported so tests can assert the
|
||||
* inverse-zoom relationship directly rather than inferring it from snapping. */
|
||||
export const snapToleranceMm = (zoom) => SNAP_PX / (PX_PER_MM * zoom);
|
||||
|
||||
/**
|
||||
* Every position worth aligning to, split by axis.
|
||||
*
|
||||
* `V` holds vertical lines (constant x), `H` horizontal lines (constant y) —
|
||||
* named for the line drawn, not the coordinate compared, because that is what
|
||||
* the overlay renders.
|
||||
*
|
||||
* Bands are skipped as sources: a band always spans the full content width, so
|
||||
* its left/right edges ARE the page margins, which are already emitted below.
|
||||
* Emitting them again would produce duplicate candidates at identical positions
|
||||
* and a doubled-up guide line. Band CHILDREN still generate candidates normally
|
||||
* — they are ordinary elements that happen to live inside a band.
|
||||
*
|
||||
* @param {Array} elements all elements in the report
|
||||
* @param {Array} excludeIds ids being dragged (they cannot align to themselves)
|
||||
* @param {Object} pageSetup for page size and margins
|
||||
* @returns {{V: Array, H: Array}} candidates as { pos, kind }
|
||||
*/
|
||||
export function buildGuideCandidates(elements, excludeIds, pageSetup) {
|
||||
const excluded = new Set(excludeIds || []);
|
||||
const V = [];
|
||||
const H = [];
|
||||
|
||||
(elements || []).forEach(el => {
|
||||
if (excluded.has(el.id)) return;
|
||||
if (el.type === 'a4-band') return;
|
||||
|
||||
const b = getA4Bounds(el);
|
||||
V.push(
|
||||
{ pos: b.left, kind: 'element' },
|
||||
{ pos: b.centerX, kind: 'element-center' },
|
||||
{ pos: b.right, kind: 'element' }
|
||||
);
|
||||
H.push(
|
||||
{ pos: b.top, kind: 'element' },
|
||||
{ pos: b.middleY, kind: 'element-center' },
|
||||
{ pos: b.bottom, kind: 'element' }
|
||||
);
|
||||
});
|
||||
|
||||
const size = getPageSize(pageSetup);
|
||||
const m = (pageSetup && pageSetup.margins) || {};
|
||||
|
||||
V.push(
|
||||
{ pos: 0, kind: 'page' },
|
||||
{ pos: size.w / 2, kind: 'page-center' },
|
||||
{ pos: size.w, kind: 'page' },
|
||||
{ pos: m.left || 0, kind: 'margin' },
|
||||
{ pos: size.w - (m.right || 0), kind: 'margin' }
|
||||
);
|
||||
H.push(
|
||||
{ pos: 0, kind: 'page' },
|
||||
{ pos: size.h / 2, kind: 'page-center' },
|
||||
{ pos: size.h, kind: 'page' },
|
||||
{ pos: m.top || 0, kind: 'margin' },
|
||||
{ pos: size.h - (m.bottom || 0), kind: 'margin' }
|
||||
);
|
||||
|
||||
return { V, H };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best snap on one axis.
|
||||
*
|
||||
* The dragged rect offers three of its own edges (start / centre / end); every
|
||||
* one is compared against every candidate and the single smallest gap wins. The
|
||||
* correction returned is applied to the rect's ORIGIN, so it is the same value
|
||||
* whichever of the three edges matched — `candidate.pos - myEdgePos` shifts the
|
||||
* whole box by exactly the amount that puts that edge on the line.
|
||||
*
|
||||
* Ties keep the first match found, which puts element candidates ahead of page
|
||||
* ones (they are pushed first) — aligning to a neighbour is the more intentional
|
||||
* act when both are available at the same distance.
|
||||
*/
|
||||
function bestOnAxis(start, end, candidates, tolMm) {
|
||||
const center = (start + end) / 2;
|
||||
const myEdges = [
|
||||
{ pos: start, edge: 'start' },
|
||||
{ pos: center, edge: 'center' },
|
||||
{ pos: end, edge: 'end' }
|
||||
];
|
||||
|
||||
let best = null;
|
||||
|
||||
for (const c of candidates) {
|
||||
for (const mine of myEdges) {
|
||||
const dist = Math.abs(mine.pos - c.pos);
|
||||
if (dist > tolMm) continue;
|
||||
if (best === null || dist < best.dist) {
|
||||
best = { dist, delta: c.pos - mine.pos, pos: c.pos, kind: c.kind };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dragged rect against the candidate set.
|
||||
*
|
||||
* @param {Object} draftRect { x, y, width, height } in mm — the unsnapped
|
||||
* position the drag would otherwise commit
|
||||
* @param {Object} candidates from buildGuideCandidates
|
||||
* @param {number} zoom current canvas zoom; converts SNAP_PX to mm
|
||||
* @returns {{x, y, guides}} snapped origin and the lines to draw. `guides` is
|
||||
* empty when nothing was within reach, so the caller
|
||||
* can clear the overlay without a separate check.
|
||||
*/
|
||||
export function resolveSnap(draftRect, candidates, zoom) {
|
||||
const tolMm = snapToleranceMm(zoom);
|
||||
const { x, y, width = 0, height = 0 } = draftRect;
|
||||
|
||||
const v = bestOnAxis(x, x + width, candidates.V, tolMm);
|
||||
const h = bestOnAxis(y, y + height, candidates.H, tolMm);
|
||||
|
||||
const guides = [];
|
||||
if (v) guides.push({ axis: 'v', pos: v.pos, kind: v.kind });
|
||||
if (h) guides.push({ axis: 'h', pos: h.pos, kind: h.kind });
|
||||
|
||||
return {
|
||||
x: v ? x + v.delta : x,
|
||||
y: h ? y + h.delta : y,
|
||||
guides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of several elements' exclusive bounds, as a { x, y, width, height } rect.
|
||||
*
|
||||
* Multi-select drags snap this single rect and apply the resulting delta to
|
||||
* every member. Snapping each element independently would let them find
|
||||
* different candidates and tear the group apart.
|
||||
*/
|
||||
export function unionRect(boxes) {
|
||||
const left = Math.min(...boxes.map(b => b.x));
|
||||
const top = Math.min(...boxes.map(b => b.y));
|
||||
const right = Math.max(...boxes.map(b => b.x + (b.width || 0)));
|
||||
const bottom = Math.max(...boxes.map(b => b.y + (b.height || 0)));
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* The A4 layout engine.
|
||||
*
|
||||
* A PURE function: no React, no DOM, no context. That is load-bearing, not
|
||||
* stylistic — `printReport.js` calls it from outside any React tree, and a
|
||||
* future Puppeteer service would call it server-side. Keep it that way.
|
||||
*
|
||||
* It is the single source of positioning: both the printed page and the
|
||||
* on-screen preview consume its output, which is what makes "preview page
|
||||
* count equals printed page count" a real guarantee rather than a coincidence.
|
||||
*
|
||||
* PHASE 6 SCOPE — bands, per-row data scoping and multi-page pagination.
|
||||
*/
|
||||
|
||||
import { formatValue } from './formatValue';
|
||||
import { resolveDBTextValue } from '../utils/dataResolver';
|
||||
import { evaluateExpression } from '../utils/expressionEvaluator';
|
||||
import { calculateBandInstances } from './a4BandRenderer';
|
||||
import { getPageSize } from './units';
|
||||
|
||||
/**
|
||||
* Turn an expression's `[objectKey.fieldPath]` reference into a value.
|
||||
*
|
||||
* Ported from `ExpressionField.jsx:79-88`. The split is on the FIRST dot, so
|
||||
* `[vehicle.driver.name]` resolves objectKey='vehicle', fieldPath='driver.name'.
|
||||
*
|
||||
* `scope` is the row-aware resolver below, so the same expression syntax means
|
||||
* "the band's current row" inside a band and "the report data" outside one.
|
||||
*/
|
||||
export function makeFieldResolver(data, rowScope = null) {
|
||||
return (ref) => {
|
||||
const inner = ref.slice(1, ref.length - 1);
|
||||
const dotIndex = inner.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
return resolveInScope(data, rowScope, '', inner);
|
||||
}
|
||||
return resolveInScope(data, rowScope, inner.slice(0, dotIndex), inner.slice(dotIndex + 1));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Field resolution with an optional band row in scope.
|
||||
*
|
||||
* The matrix editor gets the current row from `BandContext` via React. Here it
|
||||
* is a plain argument — simpler, and the only way layout() can stay pure.
|
||||
* `resolveDBTextValue` already supports a row scope through its
|
||||
* (parentBandId, bandContext) parameters, so the row is handed to it in that
|
||||
* shape rather than duplicating its resolution rules.
|
||||
*
|
||||
* Precedence: the band row wins. A binding that the row does not satisfy falls
|
||||
* back to the top-level report data, so a detail row can still print e.g.
|
||||
* `[vehicle.number]` from the surrounding document.
|
||||
*/
|
||||
function resolveInScope(data, rowScope, objectKey, fieldPath) {
|
||||
if (rowScope) {
|
||||
const fromRow = resolveDBTextValue(
|
||||
data, objectKey, fieldPath, ROW_SCOPE_ID, { currentBandData: { [ROW_SCOPE_ID]: rowScope } }
|
||||
);
|
||||
if (fromRow !== '') return fromRow;
|
||||
}
|
||||
return resolveDBTextValue(data, objectKey, fieldPath);
|
||||
}
|
||||
|
||||
/** Internal key for the synthetic single-entry band context above. */
|
||||
const ROW_SCOPE_ID = '__a4_row__';
|
||||
|
||||
/**
|
||||
* Text for one element. Data binding is resolved HERE, not in the renderer, so
|
||||
* PrintDocument receives finished strings and stays dumb — and so a future
|
||||
* server-side Puppeteer run needs no browser data layer at all.
|
||||
*
|
||||
* The geometry-only types (rect, hline, vline) deliberately fall through to '';
|
||||
* they are positioned like everything else but carry no text.
|
||||
*/
|
||||
function resolveText(el, data, rowScope = null) {
|
||||
switch (el.type) {
|
||||
case 'a4-text':
|
||||
return el.content ?? '';
|
||||
|
||||
case 'a4-dbtext': {
|
||||
const raw = resolveInScope(data, rowScope, el.objectKey, el.fieldPath);
|
||||
return formatValue(raw, el.dataType || 'general');
|
||||
}
|
||||
|
||||
case 'a4-expr': {
|
||||
const raw = evaluateExpression(el.expression, makeFieldResolver(data, rowScope));
|
||||
return formatValue(raw, el.dataType || 'general');
|
||||
}
|
||||
|
||||
default:
|
||||
// Geometry-only types, and unknown types: an unknown type renders empty
|
||||
// rather than throwing, so a half-migrated report still prints the
|
||||
// elements it can.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split laid-out band instances across pages.
|
||||
*
|
||||
* The model is the standard banded report (FastReport/Crystal): EVERYTHING
|
||||
* keeps the position it was drawn at, and the only thing that moves anything is
|
||||
* a band GROWING as it repeats per data row. Growth pushes whatever sits below
|
||||
* it — later bands and static elements alike — down by the same delta, so the
|
||||
* gaps the user drew survive.
|
||||
*
|
||||
* - Instances are ATOMIC. A band that does not fit starts a new page rather
|
||||
* than being sliced, because half a table row on each side of a page break is
|
||||
* never what anyone wants on a weighing ticket.
|
||||
* - `header` is a COLUMN header, not a page header. It prints where it was
|
||||
* drawn, and repeats at the top of a CONTINUATION page — a page reached
|
||||
* because the flow overran, so the rows it captions carry on beneath it.
|
||||
* - `footer` bands sit at `contentBottom - height` on every page.
|
||||
* - `summary` emits once, after all other instances.
|
||||
*
|
||||
* @param {Array} instances from calculateBandInstances, in flow order
|
||||
* @param {number} contentTop mm
|
||||
* @param {number} contentBottom mm
|
||||
* @param {Function} emit (pageIndex, instance, y) => void
|
||||
* @returns {number} the number of pages used (>= 1)
|
||||
*/
|
||||
export function paginate(instances, contentTop, contentBottom, emit) {
|
||||
const headers = instances.filter(i => i.bandType === 'header');
|
||||
const footers = instances.filter(i => i.bandType === 'footer');
|
||||
const summaries = instances.filter(i => i.bandType === 'summary');
|
||||
// Everything that streams down the page, headers included: a header prints in
|
||||
// its designed place first, and only REPEATS on a continuation page.
|
||||
const flow = instances.filter(
|
||||
i => i.bandType !== 'footer' && i.bandType !== 'summary'
|
||||
);
|
||||
|
||||
const headerHeight = headers.reduce((sum, h) => sum + h.height, 0);
|
||||
const footerHeight = footers.reduce((sum, f) => sum + f.height, 0);
|
||||
|
||||
let pageIndex = 0;
|
||||
// Tracks the running bottom of placed content. It starts at contentTop so the
|
||||
// first instance can sit at its design Y, however far down that is.
|
||||
let cursorY = contentTop;
|
||||
|
||||
const flowBottom = () => contentBottom - footerHeight;
|
||||
|
||||
/** Repeat the column headers at the top of a continuation page. */
|
||||
const startContinuationPage = (index) => {
|
||||
let y = contentTop;
|
||||
headers.forEach(h => {
|
||||
emit(index, h, y);
|
||||
y += h.height;
|
||||
});
|
||||
return y;
|
||||
};
|
||||
|
||||
const place = (instance) => {
|
||||
// Design position, unless growth above has already pushed past it. max()
|
||||
// is the whole model in one line: nothing moves up, and anything below a
|
||||
// grown band is displaced by exactly that growth.
|
||||
const designY = typeof instance.y === 'number' ? instance.y : cursorY;
|
||||
let y = Math.max(designY, cursorY);
|
||||
|
||||
// A band taller than the usable area can never fit anywhere; place it and
|
||||
// let it clip rather than paging forever looking for room.
|
||||
const usable = contentBottom - contentTop - headerHeight - footerHeight;
|
||||
const fitsAnywhere = instance.height <= usable;
|
||||
|
||||
if (y + instance.height > flowBottom() && fitsAnywhere) {
|
||||
pageIndex += 1;
|
||||
y = startContinuationPage(pageIndex);
|
||||
}
|
||||
|
||||
emit(pageIndex, instance, y);
|
||||
cursorY = y + instance.height;
|
||||
};
|
||||
|
||||
flow.forEach(place);
|
||||
summaries.forEach(place);
|
||||
|
||||
const pageCount = pageIndex + 1;
|
||||
|
||||
// Footers last: positioned from the BOTTOM, so they need the final page
|
||||
// count, and they repeat on every page.
|
||||
for (let p = 0; p < pageCount; p += 1) {
|
||||
let y = contentBottom - footerHeight;
|
||||
footers.forEach(f => {
|
||||
emit(p, f, y);
|
||||
y += f.height;
|
||||
});
|
||||
}
|
||||
|
||||
return pageCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} elements report elements, positions in mm floats
|
||||
* @param {Object} data resolved report data
|
||||
* @param {Object} pageSetup { page, orientation, margins, defaultFont, defaultFontSize }
|
||||
* @returns {{ pages: Array<Array<{ el, x, y, text }>> }}
|
||||
*/
|
||||
export function layout(elements, data, pageSetup) {
|
||||
const list = Array.isArray(elements) ? elements : [];
|
||||
const bands = list.filter(el => el && el.type === 'a4-band');
|
||||
|
||||
const pageSize = getPageSize(pageSetup);
|
||||
const margins = pageSetup?.margins || {};
|
||||
const contentTop = margins.top || 0;
|
||||
const contentBottom = pageSize.h - (margins.bottom || 0);
|
||||
|
||||
// Ids of every element owned by a band — they are positioned by their band's
|
||||
// instance, never at their own top-level y.
|
||||
const childIds = new Set();
|
||||
bands.forEach(b => (b.children || []).forEach(id => childIds.add(id)));
|
||||
|
||||
const statics = list.filter(
|
||||
el => el && el.type !== 'a4-band' && !childIds.has(el.id)
|
||||
);
|
||||
|
||||
const pages = [[]];
|
||||
const ensurePage = (index) => {
|
||||
while (pages.length <= index) pages.push([]);
|
||||
return pages[index];
|
||||
};
|
||||
|
||||
// How far the flow was displaced past a given design Y, and onto which page.
|
||||
// Built as bands are placed, then applied to the statics below them: a static
|
||||
// drawn under a band keeps its drawn gap when that band grows with its rows.
|
||||
// Each entry is { designEnd, actualEnd, pageIndex } for one placed instance.
|
||||
const displacements = [];
|
||||
|
||||
if (bands.length > 0) {
|
||||
const instances = calculateBandInstances(bands, data, list);
|
||||
|
||||
paginate(instances, contentTop, contentBottom, (pageIndex, instance, y) => {
|
||||
const target = ensurePage(pageIndex);
|
||||
// Keyed on the band's DESIGN bottom (originalBandY + height), not on the
|
||||
// instance's own y: calculateBandInstances has already stacked the repeats,
|
||||
// so instance.y is where row N landed, while every row of one band shares
|
||||
// a single design position. A static is drawn below the band as drawn.
|
||||
if (typeof instance.originalBandY === 'number') {
|
||||
displacements.push({
|
||||
designEnd: instance.originalBandY + instance.height,
|
||||
actualEnd: y + instance.height,
|
||||
pageIndex
|
||||
});
|
||||
}
|
||||
// A child's stored x and y are BOTH relative to its band — the canvas
|
||||
// renders children inside the band div, so CSS adds the band's origin.
|
||||
// layout() has to add it explicitly or a child prints measured from the
|
||||
// paper edge instead of from the band's left margin.
|
||||
const bandX = instance.bandX || 0;
|
||||
|
||||
instance.childElements.forEach(child => {
|
||||
target.push({
|
||||
// A child is emitted once per instance, so its key must be unique per
|
||||
// instance — PrintDocument keys on el.id, and React would otherwise
|
||||
// see duplicate keys across 60 detail rows.
|
||||
el: child,
|
||||
x: bandX + child.x,
|
||||
// The child's stored y is RELATIVE to its band, exactly as the matrix
|
||||
// editor stores it.
|
||||
y: y + child.y,
|
||||
text: resolveText(child, data, instance.data),
|
||||
instanceKey: `${instance.bandId}-${instance.instanceIndex}-${pageIndex}`
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a static, pushed down by any band growth above it.
|
||||
*
|
||||
* The governing band is the LAST one whose designed bottom is at or above the
|
||||
* static: that is the thing it was drawn beneath. The static then keeps the
|
||||
* exact gap it was drawn with, measured from that band's ACTUAL bottom.
|
||||
*
|
||||
* A static above every band has no governing band and so does not move —
|
||||
* a title or letterhead stays where it was put.
|
||||
*/
|
||||
const placeStatic = (el) => {
|
||||
let governing = null;
|
||||
for (const d of displacements) {
|
||||
if (d.designEnd > el.y) continue;
|
||||
// Deepest design position wins; on a TIE take the one that ended up
|
||||
// furthest down. Every instance of one repeating band shares a designEnd,
|
||||
// so a tie is the normal case — keeping the first would measure the gap
|
||||
// from row 1 and let the static sit on top of rows 2..n.
|
||||
if (!governing ||
|
||||
d.designEnd > governing.designEnd ||
|
||||
(d.designEnd === governing.designEnd && d.actualEnd > governing.actualEnd)) {
|
||||
governing = d;
|
||||
}
|
||||
}
|
||||
|
||||
if (!governing) {
|
||||
pages[0].push({ el, x: el.x, y: el.y, text: resolveText(el, data) });
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = el.y - governing.designEnd;
|
||||
let y = governing.actualEnd + gap;
|
||||
let pageIndex = governing.pageIndex;
|
||||
|
||||
// Pushed past the bottom margin: carry on at the top of the next page
|
||||
// rather than vanishing off the sheet.
|
||||
if (y + (el.height || 0) > contentBottom) {
|
||||
pageIndex += 1;
|
||||
y = contentTop;
|
||||
}
|
||||
|
||||
ensurePage(pageIndex).push({ el, x: el.x, y, text: resolveText(el, data) });
|
||||
};
|
||||
|
||||
statics.forEach(placeStatic);
|
||||
|
||||
return { pages };
|
||||
}
|
||||
|
||||
export { formatValue };
|
||||
export default layout;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { getFontStack } from '../fonts';
|
||||
import './print.css';
|
||||
|
||||
/**
|
||||
* Renders layout() output as absolutely-positioned millimetre HTML.
|
||||
*
|
||||
* DELIBERATELY DUMB. All positioning and all data resolution happened in
|
||||
* layout(); this maps finished {el, x, y, text} records onto CSS mm and does
|
||||
* nothing else — no hooks, no context, no state, no CSS-in-JS runtime. That is
|
||||
* what lets a future Puppeteer service consume this exact markup by POSTing the
|
||||
* serialised HTML, instead of needing a redesign (see the plan's §6 "socket").
|
||||
*
|
||||
* Font size is emitted in CSS `pt` with no conversion at all: the element
|
||||
* stores pt, CSS has real pt units, and the print path is physical. The screen
|
||||
* path in A4TextField.jsx is the one that has to convert (pt -> px * zoom).
|
||||
*
|
||||
* The same FONT_STACKS strings are used on screen and here, so WYSIWYG holds by
|
||||
* construction — one font stack, one rendering engine.
|
||||
*/
|
||||
/**
|
||||
* Inline style for one placed element.
|
||||
*
|
||||
* Every branch emits TRUE millimetres. The screen renderers floor hairlines at
|
||||
* 1px so they stay visible when zoomed out; nothing of the sort happens here,
|
||||
* because the printed thickness is the thickness the user measures.
|
||||
*/
|
||||
export function styleFor(el, x, y) {
|
||||
const base = { left: `${x}mm`, top: `${y}mm` };
|
||||
|
||||
switch (el.type) {
|
||||
case 'a4-rect':
|
||||
return {
|
||||
...base,
|
||||
width: `${el.width}mm`,
|
||||
height: `${el.height}mm`,
|
||||
// box-sizing: border-box (print.css) keeps the border INSIDE the
|
||||
// declared mm, so the rect's outer footprint is what was designed.
|
||||
border: el.borderWidth > 0
|
||||
? `${el.borderWidth}mm solid ${el.borderColor || '#000000'}`
|
||||
: 'none',
|
||||
background: el.fillColor || 'transparent'
|
||||
};
|
||||
|
||||
case 'a4-hline':
|
||||
return {
|
||||
...base,
|
||||
width: `${el.width}mm`,
|
||||
height: `${el.thickness}mm`,
|
||||
background: el.color || '#000000'
|
||||
};
|
||||
|
||||
case 'a4-vline':
|
||||
return {
|
||||
...base,
|
||||
width: `${el.thickness}mm`,
|
||||
height: `${el.height}mm`,
|
||||
background: el.color || '#000000'
|
||||
};
|
||||
|
||||
default:
|
||||
// Text types: a4-text, a4-dbtext, a4-expr. They differ only in where
|
||||
// their string came from, which layout() already settled.
|
||||
return {
|
||||
...base,
|
||||
width: `${el.width}mm`,
|
||||
height: `${el.height}mm`,
|
||||
fontFamily: getFontStack(el.fontFamily),
|
||||
fontSize: `${el.fontSize}pt`,
|
||||
fontWeight: el.bold ? 700 : 400,
|
||||
fontStyle: el.italic ? 'italic' : 'normal',
|
||||
textAlign: el.align || 'left',
|
||||
color: el.color || '#000000'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function PrintDocument({ pages, pageSetup }) {
|
||||
const isLandscape = pageSetup?.orientation === 'landscape';
|
||||
const pageClass = `a4-print-page${isLandscape ? ' a4-print-page--landscape' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="print-root">
|
||||
{(pages || []).map((els, i) => (
|
||||
<div className={pageClass} key={i}>
|
||||
{els.map(({ el, x, y, text, instanceKey }) => (
|
||||
<div
|
||||
className={[
|
||||
'a4-print-el',
|
||||
`a4-print-el--${el.type}`,
|
||||
el.className || ''
|
||||
].filter(Boolean).join(' ')}
|
||||
// A band child appears once per data row, so el.id alone is NOT
|
||||
// unique on the page — layout() supplies instanceKey for those.
|
||||
key={instanceKey ? `${instanceKey}-${el.id}` : el.id}
|
||||
style={styleFor(el, x, y)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrintDocument;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Print styles for the A4 designer.
|
||||
*
|
||||
* Millimetre accuracy comes from CSS physical units — Chrome maps `mm` to real
|
||||
* millimetres at 100% scale. This is the whole reason there is no PDF library:
|
||||
* the browser prints with the fonts installed on the station, and "Save as PDF"
|
||||
* is the operator choosing that destination in the same dialog.
|
||||
*
|
||||
* TWO NAMING NOTES, both deliberate:
|
||||
*
|
||||
* 1. The page class is `.a4-print-page`, NOT `.a4-page`. A4Canvas.css already
|
||||
* styles `.a4-page` for the on-screen editor canvas (background, shadow),
|
||||
* and both stylesheets are loaded while the editor is open — sharing the
|
||||
* name would make them fight.
|
||||
*
|
||||
* 2. `.print-root` must sit on the element that is a DIRECT CHILD OF <body>,
|
||||
* because the hiding rule below is `body > *:not(.print-root)`. printReport.js
|
||||
* therefore puts the class on the container it appends to document.body, and
|
||||
* PrintDocument repeats it on its own outer div for the standalone case.
|
||||
* Get this wrong and you print either a blank page or the entire app.
|
||||
*/
|
||||
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Landscape: `@page { size }` cannot be set from an inline style or a class, so
|
||||
printReport.js injects a one-line `@page { size: A4 landscape; margin: 0 }`
|
||||
stylesheet that overrides the rule above. The page BOX still needs swapped
|
||||
dimensions, which is what .a4-print-page--landscape below does. */
|
||||
|
||||
.a4-print-page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.a4-print-page:last-child {
|
||||
page-break-after: auto;
|
||||
break-after: auto;
|
||||
}
|
||||
|
||||
.a4-print-page--landscape {
|
||||
width: 297mm;
|
||||
height: 210mm;
|
||||
}
|
||||
|
||||
.a4-print-el {
|
||||
position: absolute;
|
||||
/* index.css sets `* { box-sizing: border-box }` globally; with no padding or
|
||||
border on these boxes that is a no-op, but state it so a future border
|
||||
(Phase 5's a4-rect) does not silently change the printed width. */
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Print exactly the designed colours; without this Chrome may drop
|
||||
backgrounds and lighten text in some print paths. */
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* Geometry-only types carry no text, so they must not inherit a line-height or
|
||||
font metric that could add a fraction of a millimetre to the painted box.
|
||||
The height of an hline IS its measured thickness. */
|
||||
.a4-print-el--a4-rect,
|
||||
.a4-print-el--a4-hline,
|
||||
.a4-print-el--a4-vline {
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* NOTE: there is deliberately no `.a4-calibration-mark` rule here any more.
|
||||
The calibration marks are now REAL a4-hline / a4-vline elements that paint
|
||||
their own background from `color`, so a class-level background would only
|
||||
be able to contradict the element's own colour. print-color-adjust above is
|
||||
what keeps them solid black on paper. */
|
||||
|
||||
/* Hidden on screen — the print root is mounted into the live DOM, so it must
|
||||
not appear over the editor. */
|
||||
.print-root {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body > *:not(.print-root) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-root {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { flushSync } from 'react-dom';
|
||||
import PrintDocument from './PrintDocument';
|
||||
import { layout } from '../layout';
|
||||
|
||||
/**
|
||||
* Non-React entry point for printing an A4 report.
|
||||
*
|
||||
* Callable from anywhere — Main.jsx will call it in Phase 7 with no editor
|
||||
* mounted. That works only because layout() is pure, so nothing here needs a
|
||||
* React context, a provider, or the component tree.
|
||||
*
|
||||
* "Save as PDF" is THE SAME CALL: the operator picks that destination in
|
||||
* Chrome's own print dialog. There is no second code path and no PDF library —
|
||||
* which is what lets the output use the fonts installed on the station.
|
||||
*
|
||||
* MOUNTING: the container is appended directly to <body> and carries the
|
||||
* `print-root` class ITSELF. print.css hides everything else with
|
||||
* `body > *:not(.print-root)`, which only matches direct children of body — so
|
||||
* the class has to be on the appended container, not merely on PrintDocument's
|
||||
* inner div. The app's own root (<div id="root">) is a sibling and gets hidden.
|
||||
*/
|
||||
|
||||
/** Injected only for landscape: `@page { size }` cannot come from a class. */
|
||||
const LANDSCAPE_STYLE_ID = 'a4-print-landscape-page-rule';
|
||||
|
||||
function applyLandscapePageRule() {
|
||||
if (document.getElementById(LANDSCAPE_STYLE_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = LANDSCAPE_STYLE_ID;
|
||||
style.textContent = '@page { size: A4 landscape; margin: 0; }';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function removeLandscapePageRule() {
|
||||
document.getElementById(LANDSCAPE_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} report { elements, pageSetup | page_setup }
|
||||
* @param {Object} data resolved report data (used from Phase 5 onward)
|
||||
* @returns {Promise<void>} resolves once the print container has been removed
|
||||
*/
|
||||
export async function printA4Report(report, data) {
|
||||
// Accept both the editor's in-memory shape (pageSetup) and the API shape
|
||||
// (page_setup), so the Print button works on an unsaved report and Main.jsx
|
||||
// can pass a freshly fetched row unchanged.
|
||||
const pageSetup = report?.pageSetup || report?.page_setup || {};
|
||||
const { pages } = layout(report?.elements, data, pageSetup);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'print-root';
|
||||
document.body.appendChild(container);
|
||||
|
||||
if (pageSetup.orientation === 'landscape') applyLandscapePageRule();
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
// window.print() reads the DOM synchronously, but React 18 commits its render
|
||||
// asynchronously — so without flushSync the dialog can open on an EMPTY
|
||||
// container and print a blank page. flushSync forces the commit to finish
|
||||
// before print() is called.
|
||||
//
|
||||
// Deliberately not requestAnimationFrame: rAF does not fire in a background
|
||||
// or hidden tab, which would leave a print click hanging forever instead of
|
||||
// printing. flushSync has no such dependency on the frame loop.
|
||||
flushSync(() => {
|
||||
root.render(<PrintDocument pages={pages} pageSetup={pageSetup} />);
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
let done = false;
|
||||
// Declared before cleanup() so that a synchronous afterprint — Chrome fires
|
||||
// it from inside window.print() — does not hit the temporal dead zone.
|
||||
let fallbackTimer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
window.removeEventListener('afterprint', cleanup);
|
||||
clearTimeout(fallbackTimer);
|
||||
removeLandscapePageRule();
|
||||
// Unmount asynchronously: React forbids unmounting a root from inside a
|
||||
// lifecycle/event it is already rendering within.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
root.unmount();
|
||||
} catch (err) {
|
||||
// The container may already have been detached (a host page tearing
|
||||
// down, a test harness cleaning up). Removing it is what actually
|
||||
// matters, so never let an unmount error skip that.
|
||||
console.error('Print teardown failed to unmount cleanly:', err);
|
||||
}
|
||||
container.remove();
|
||||
resolve();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
window.addEventListener('afterprint', cleanup);
|
||||
|
||||
// Not every browser fires afterprint (and some never fire it if the dialog
|
||||
// is dismissed). Without this fallback, containers accumulate in the DOM on
|
||||
// every print — one full report's markup each time.
|
||||
fallbackTimer = setTimeout(cleanup, 60000);
|
||||
|
||||
try {
|
||||
window.print();
|
||||
} catch (err) {
|
||||
// A blocked or unavailable print dialog must still tear the container
|
||||
// down, or the editor is left with hidden markup stuck in <body>.
|
||||
console.error('Print failed:', err);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default printA4Report;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Sample vehicle data for designing bindings when no real vehicle was passed.
|
||||
*
|
||||
* DUPLICATED, NOT EXTRACTED. The plan (§3) preferred moving this out of
|
||||
* `ReportEditor.jsx:71-116` into a shared `utils/sampleData.js` so the two
|
||||
* editors could not drift. `ReportEditor.jsx` is on the protected list for this
|
||||
* phase, and extracting means editing it, so the plan's own fallback applies:
|
||||
* duplicate instead. The matrix copy remains the original; if that file is ever
|
||||
* unfrozen, delete this and import the shared one.
|
||||
*
|
||||
* Kept byte-for-byte equivalent to the matrix version — do not "improve" it, or
|
||||
* a report designed against A4 sample data will bind differently from the same
|
||||
* report designed in the matrix editor.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a report-data object from nomenclature definitions/entries.
|
||||
*
|
||||
* @param {Object} definitions code → nomenclature definition (from NomenclatureDataContext)
|
||||
* @param {Object} entries code → array of entries for lookup nomenclatures
|
||||
* @returns {Object} `{ vehicle: {...} }`, the shape DataContext expects
|
||||
*/
|
||||
export function buildSampleVehicleData(definitions, entries) {
|
||||
const extra = {};
|
||||
for (const def of Object.values(definitions || {})) {
|
||||
if (def.kind === 'lookup') {
|
||||
const activeEntries = ((entries || {})[def.code] || []).filter(e => e.is_active);
|
||||
const firstEntry = activeEntries[0];
|
||||
if (firstEntry?.data && Object.keys(firstEntry.data).length > 0) {
|
||||
extra[def.name] = { ...firstEntry.data };
|
||||
} else if (def.fields?.length > 0) {
|
||||
const sample = {};
|
||||
for (const field of def.fields) {
|
||||
if (field.field_type === 'number') sample[field.key] = 0;
|
||||
else if (field.field_type === 'bool') sample[field.key] = false;
|
||||
else sample[field.key] = 'Sample';
|
||||
}
|
||||
extra[def.name] = sample;
|
||||
} else {
|
||||
extra[def.name] = {};
|
||||
}
|
||||
} else {
|
||||
extra[def.name] = 'Sample Text';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vehicle: {
|
||||
vehicle_number: 'АА1234ВВ',
|
||||
trailer1_number: 'ПВ5678ВВ',
|
||||
trailer2_number: null,
|
||||
driver_pid: '123456789',
|
||||
tare: 8500,
|
||||
tare_date: '2026-02-22T08:00:00Z',
|
||||
tare_user_name: 'operator',
|
||||
gross: 28500,
|
||||
gross_date: '2026-02-22T10:30:00Z',
|
||||
gross_user_name: 'operator',
|
||||
net: 20000,
|
||||
net_date: '2026-02-22T10:30:00Z',
|
||||
net_user_name: 'operator',
|
||||
doc_number: '2026-001',
|
||||
extra
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default buildSampleVehicleData;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Unit conversions for the A4 designer.
|
||||
*
|
||||
* Storage is ALWAYS millimetre floats — never pixels, never zoom-affected values.
|
||||
* Pixels exist only at render time; `zoom` never reaches the stored report JSON.
|
||||
*
|
||||
* Font size is stored in pt (what users expect). On screen it renders as
|
||||
* pt * (96/72) * zoom px; on the print path CSS `pt` is used directly.
|
||||
*/
|
||||
|
||||
export const PX_PER_MM = 96 / 25.4; // 3.7795 — CSS px per mm at 100% zoom
|
||||
export const PX_PER_PT = 96 / 72; // 1.3333 — CSS px per pt at 100% zoom
|
||||
|
||||
export const mmToPx = (mm, zoom = 1) => mm * PX_PER_MM * zoom;
|
||||
export const pxToMm = (px, zoom = 1) => px / (PX_PER_MM * zoom);
|
||||
|
||||
/** Screen pixel size for a pt font at the current zoom. */
|
||||
export const ptToPx = (pt, zoom = 1) => pt * PX_PER_PT * zoom;
|
||||
|
||||
/** 0.1 mm storage precision — keeps 12.700000000000001 out of the JSON. */
|
||||
export const roundMm = (mm) => Math.round(mm * 10) / 10;
|
||||
|
||||
export const PAGE = {
|
||||
A4: {
|
||||
portrait: { w: 210, h: 297 },
|
||||
landscape: { w: 297, h: 210 }
|
||||
}
|
||||
};
|
||||
|
||||
/** Page dimensions in mm for a page_setup object. */
|
||||
export function getPageSize(pageSetup) {
|
||||
const page = PAGE[pageSetup?.page] || PAGE.A4;
|
||||
return page[pageSetup?.orientation === 'landscape' ? 'landscape' : 'portrait'];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for handling element dragging with grid snapping
|
||||
@@ -13,15 +13,30 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragData, setDragData] = useState(null);
|
||||
|
||||
// onDrag and dragData are read through refs so the effect below can depend on
|
||||
// `isDragging` ALONE. Depending on them directly re-ran the effect on every
|
||||
// render during a drag, and each re-run removed the mouseup listener before
|
||||
// adding a new one. A mouseup landing in that gap was lost and the element
|
||||
// kept following the mouse until the next click. Snapping made it routine:
|
||||
// a held guide line stops x/y changing, so the only state still changing is
|
||||
// activeGuides, and that render lands between the mousemove and the mouseup.
|
||||
const onDragRef = useRef(onDrag);
|
||||
onDragRef.current = onDrag;
|
||||
const dragDataRef = useRef(dragData);
|
||||
dragDataRef.current = dragData;
|
||||
|
||||
const handleMouseDown = (e, elementId) => {
|
||||
// Only handle dragging in select mode
|
||||
if (toolMode !== 'select') return;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(true);
|
||||
const data = onDragStart(elementId, e.clientX, e.clientY);
|
||||
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
|
||||
// Seed the ref too: the listeners below subscribe on this same render pass,
|
||||
// so a mousemove can arrive before the state write has been applied.
|
||||
dragDataRef.current = { ...data, startMouseX: e.clientX, startMouseY: e.clientY };
|
||||
setIsDragging(true);
|
||||
setDragData(dragDataRef.current);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,16 +46,18 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
|
||||
document.body.classList.add('dragging-active');
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (dragData) {
|
||||
const deltaX = e.clientX - dragData.startMouseX;
|
||||
const deltaY = e.clientY - dragData.startMouseY;
|
||||
onDrag(dragData, deltaX, deltaY);
|
||||
const data = dragDataRef.current;
|
||||
if (data) {
|
||||
const deltaX = e.clientX - data.startMouseX;
|
||||
const deltaY = e.clientY - data.startMouseY;
|
||||
onDragRef.current(data, deltaX, deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setDragData(null);
|
||||
dragDataRef.current = null;
|
||||
document.body.classList.remove('dragging-active');
|
||||
};
|
||||
|
||||
@@ -52,7 +69,7 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.classList.remove('dragging-active');
|
||||
};
|
||||
}, [isDragging, dragData, onDrag]);
|
||||
}, [isDragging]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseReading, formatHex } from '../../services/scaleProtocols';
|
||||
import api from '../../services/api';
|
||||
import './ScaleMonitor.css';
|
||||
|
||||
const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
|
||||
const SERIAL_URL = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
|
||||
|
||||
const EMPTY_BUILDER = {
|
||||
name: '',
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '../services/api';
|
||||
|
||||
const LicenseContext = createContext(null);
|
||||
|
||||
const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
|
||||
const SERIAL_URL = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
|
||||
const HEARTBEAT_MS = 30000; // server releases a slot after 120s, so 4 missed beats
|
||||
|
||||
export const useLicense = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
const NomenclatureContext = createContext();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ function useSerialData() {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const serialUrl = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
|
||||
const serialUrl = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
|
||||
const eventSource = new EventSource(`${serialUrl}/events`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
// CRA's Babel loader accepted JSX inside .js files. Production sources have
|
||||
// been renamed to .jsx, but the a4 test files are still .js containing JSX,
|
||||
// so the transform is widened to parse .js as JSX too. This works on the
|
||||
// dev/test path; the bundled build path derives the language from the file
|
||||
// extension, which is why the production file had to be renamed.
|
||||
plugins: [react({ include: /\.(js|jsx)$/ })],
|
||||
oxc: {
|
||||
include: /\.(m?ts|[jt]sx|js)$/,
|
||||
exclude: /node_modules/,
|
||||
lang: 'jsx',
|
||||
},
|
||||
server: {
|
||||
// Must stay 5172 — Django's CORS_ALLOWED_ORIGINS allowlists this origin.
|
||||
port: 5172,
|
||||
strictPort: true,
|
||||
// Replicates CRA's "proxy": "http://localhost:8002". The app calls the API
|
||||
// with absolute URLs (VITE_API_URL), so this only matters as a fallback
|
||||
// for a relative /api request.
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8002',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// Kept as CRA's 'build' rather than Vite's default 'dist' so the existing
|
||||
// Dockerfile (COPY --from=builder /app/build) keeps working.
|
||||
outDir: 'build',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.{js,jsx}'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user