From 5fa95263d6efea1c83c105cbb6599ce378644542 Mon Sep 17 00:00:00 2001 From: Marcus Kempe Date: Fri, 25 Sep 2026 18:55:05 +0200 Subject: [PATCH] Add inverted white-out, lock OOB borders, and fix number fields Ink-saver print maps need a hole-in-the-frame white-out, ISOM 709 borders should stay purple at 0.4 mm, and line-width inputs must stay editable while clearing digits. Co-authored-by: Cursor --- ...bugfix-map-editor-number-input-clearing.md | 22 ++ docs/course-maps.md | 15 +- docs/features.md | 3 +- e2e/course-maps.spec.ts | 42 +++ .../shared/src/__tests__/course-maps.test.ts | 61 ++++ .../shared/src/course-maps/objects-svg.ts | 78 ++++- packages/shared/src/course-maps/schema.ts | 13 +- .../web/src/components/MapLayoutEditor.tsx | 273 +++++++++++++----- packages/web/src/i18n/locales/en/maps.json | 3 + packages/web/src/i18n/locales/sv/maps.json | 3 + .../lib/__tests__/map-layout-editor.test.ts | 66 +++++ packages/web/src/lib/map-layout-editor.ts | 53 ++++ packages/web/src/pages/MapsPage.tsx | 22 +- 13 files changed, 565 insertions(+), 89 deletions(-) create mode 100644 docs/bugfix-map-editor-number-input-clearing.md diff --git a/docs/bugfix-map-editor-number-input-clearing.md b/docs/bugfix-map-editor-number-input-clearing.md new file mode 100644 index 0000000..fde89d1 --- /dev/null +++ b/docs/bugfix-map-editor-number-input-clearing.md @@ -0,0 +1,22 @@ +# Bugfix: Map editor number inputs could not be cleared + +## Symptom + +In the map layout editor property panel, the line-width (and font-size) +`` fields snapped back as soon as the user deleted a +digit. Clearing the field to type a new value was impossible. + +## Cause + +`onChange` wrote `Number(event.target.value)` straight into object state. +An empty string becomes `0`, which React then pushed back into the +controlled `value`, so the field never stayed blank. + +## Fix + +`NumberField` keeps a string draft while the input is focused and only +commits through `parseLiveNumberDraft` when the draft is a finite number +inside `[min, max]`. On blur (or Enter) an invalid/empty draft falls back +to the last committed value. The same pattern was already used for print +scale and description cell size via `parseClampedNumberDraft`; the live +variant exists so partial edits like `"0."` do not commit prematurely. diff --git a/docs/course-maps.md b/docs/course-maps.md index bdf4b60..9a85de4 100644 --- a/docs/course-maps.md +++ b/docs/course-maps.md @@ -250,10 +250,17 @@ keep a single title row over code-sorted control rows. Choose an object tool and click its intended page position to create it; `Escape` cancels an armed tool. Rectangles and polygons share a **Fill** -property: none, solid colour, white-out, or ISOM 709 out-of-bounds purple -cross-hatch (0.2 mm lines / 1.2 mm gap at the base map scale, enlarged by -`overprintScale`). An optional border toggle controls stroke. Paths support -inserting and removing vertices. Alt-drag +property: none, solid colour, white-out, white-out outside (ink saver), or +ISOM 709 out-of-bounds purple cross-hatch (0.2 mm lines / 1.2 mm gap at the +base map scale, enlarged by `overprintScale`). An optional border toggle +controls stroke. Out-of-bounds borders default to the ISOM 709 bounding line +(0.4 mm, Feb 2024 revision) and always use the course purple — the colour +picker is locked so a later purple change recolours them. **White-out +outside** draws an even-odd ring against the map frame (everything inside +the frame except the shape is opaque white), so print ink is only spent on +the area of interest; click the white ring to select it, click inside the +shape hole to pass through to objects underneath. Paths support inserting +and removing vertices. Alt-drag a selected vertex to create symmetric cubic Bezier handles. Text supports sans-serif, serif, condensed and monospace fonts; clicking a variable inserts it at the current text cursor. Objects can be fixed to paper or anchored to diff --git a/docs/features.md b/docs/features.md index cba39ea..8701e84 100644 --- a/docs/features.md +++ b/docs/features.md @@ -79,7 +79,8 @@ panels (Tools, Page, Graphics, Objects, Properties) keep the canvas usable on mobile; the Objects card lists and deletes anything, including perfectly stacked objects, and warns when items sit outside the printable area. The editor supports click-to-place text, rectangles, polygons and paths with a -shared fill mode (none / solid / white-out / ISOM 709 out-of-bounds hatch), +shared fill mode (none / solid / white-out / white-out outside for ink +saving / ISOM 709 out-of-bounds hatch with purple-locked 0.4 mm border), editable Bezier paths, and uploaded SVG/PNG graphics (free resize, Ctrl/Cmd proportional, Shift crop) from a per-event or club library. Course overprint follows ISOM: circles, numbers and lines enlarge with the map when the print diff --git a/e2e/course-maps.spec.ts b/e2e/course-maps.spec.ts index d4f52ae..f575a3b 100644 --- a/e2e/course-maps.spec.ts +++ b/e2e/course-maps.spec.ts @@ -235,6 +235,15 @@ test("creates templates, lays out several maps and exports PDFs", async ({ await expect(page.getByTestId("map-object-fill-mode")).toHaveValue( "outOfBounds", ); + await expect(page.getByTestId("map-object-border")).toBeChecked(); + await expect(page.getByTestId("map-object-stroke-follows-purple")).toBeVisible(); + await expect(page.getByTestId("map-object-stroke-color")).toHaveCount(0); + await expect(page.getByTestId("map-object-stroke-width")).toHaveValue("0.4"); + await page.getByTestId("map-object-stroke-width").fill(""); + await expect(page.getByTestId("map-object-stroke-width")).toHaveValue(""); + await page.getByTestId("map-object-stroke-width").fill("0.6"); + await expect(page.getByTestId("map-object-stroke-width")).toHaveValue("0.6"); + const rectangle = page.locator("[data-object-id]").last(); const beforeResize = await rectangle.boundingBox(); const resizeHandle = page.getByTestId("map-resize-se"); @@ -263,6 +272,39 @@ test("creates templates, lays out several maps and exports PDFs", async ({ const constrainedBox = await rectangle.boundingBox(); expect(constrainedBox!.x).toBeGreaterThanOrEqual(printableBox!.x - 1); + await page.getByTestId("map-object-fill-mode").selectOption("whiteoutInverted"); + await expect(page.getByTestId("map-object-fill-mode")).toHaveValue( + "whiteoutInverted", + ); + const invertedPath = page.locator( + 'path[data-object-id][fill-rule="evenodd"]', + ); + await expect(invertedPath).toHaveCount(1); + const invertedBox = await invertedPath.boundingBox(); + expect(invertedBox).not.toBeNull(); + // Switch selection via the object list (empty-canvas click is covered by + // the frame-sized white-out ring). + await page.getByTestId("map-panel-objects-toggle").click(); + await page.getByTestId("map-object-list").locator("button").first().click(); + await expect(page.getByTestId("map-object-text")).toBeVisible(); + // White ring (frame corner opposite the constrained shape) re-selects. + await page.mouse.click( + invertedBox!.x + invertedBox!.width - 12, + invertedBox!.y + invertedBox!.height - 12, + ); + await expect(page.getByTestId("map-object-fill-mode")).toHaveValue( + "whiteoutInverted", + ); + // Hole falls through: click inside the shape does not select the inverted + // white-out (fill-mode control stays absent after selecting text first). + await page.getByTestId("map-object-list").locator("button").first().click(); + await expect(page.getByTestId("map-object-text")).toBeVisible(); + await page.mouse.click( + constrainedBox!.x + constrainedBox!.width / 2, + constrainedBox!.y + constrainedBox!.height / 2, + ); + await expect(page.getByTestId("map-object-fill-mode")).toHaveCount(0); + await page.getByTestId("map-print-scale").fill("8000"); await expect(page.getByTestId("map-layout-save-status")).toHaveText("Saved", { timeout: 5_000 }); await page.getByTestId("map-editor-close").click(); diff --git a/packages/shared/src/__tests__/course-maps.test.ts b/packages/shared/src/__tests__/course-maps.test.ts index 16cbc80..acf5525 100644 --- a/packages/shared/src/__tests__/course-maps.test.ts +++ b/packages/shared/src/__tests__/course-maps.test.ts @@ -7,6 +7,7 @@ import { defaultMapFrame, expandMapText, getPaperDimensions, + isWhiteoutObject, mapToPage, mapWindowForFrame, pageToMap, @@ -52,6 +53,26 @@ describe("course map schemas and geometry", () => { ).toThrow(); }); + it("parses whiteoutInverted and treats it as a white-out", () => { + const inverted = courseMapObjectSchema.parse({ + id: "inv", + kind: "path", + anchor: "page", + points: [ + { x: 10, y: 10 }, + { x: 50, y: 10 }, + { x: 30, y: 40 }, + ], + fillMode: "whiteoutInverted", + closed: true, + }); + expect(inverted).toMatchObject({ + kind: "path", + fillMode: "whiteoutInverted", + }); + expect(isWhiteoutObject(inverted as CourseMapObject)).toBe(true); + }); + it("normalizes legacy whiteout kinds into fillMode whiteout", () => { expect( courseMapObjectSchema.parse({ @@ -440,6 +461,30 @@ describe("course map SVG generators", () => { height: 15, fillMode: "outOfBounds", }, + { + id: "oob-border", + kind: "rectangle", + anchor: "page", + x: 70, + y: 40, + width: 20, + height: 15, + fillMode: "outOfBounds", + stroke: "#ff0000", + strokeWidthMm: 0.4, + }, + { + id: "inverted", + kind: "rectangle", + anchor: "page", + x: 20, + y: 20, + width: 30, + height: 25, + fillMode: "whiteoutInverted", + stroke: "#000000", + strokeWidthMm: 0.35, + }, ], frame: document.mapFrame, window: { minX: 0, minY: 0, width: 100, height: 100 }, @@ -455,6 +500,22 @@ describe("course map SVG generators", () => { expect(svg).toContain('id="oob-oob"'); expect(svg).toContain('stroke-width="0.4"'); // 0.2 mm * overprintScale 2 expect(svg).not.toContain("mix-blend-mode"); + // OOB border ignores stored stroke colour and uses purple. + expect(svg).toContain('data-object-id="oob-border"'); + expect(svg).toMatch( + /data-object-id="oob-border"[^>]*stroke="#a626ff"/, + ); + expect(svg).not.toMatch( + /data-object-id="oob-border"[^>]*stroke="#ff0000"/, + ); + // OOB without border has stroke="none". + expect(svg).toMatch(/data-object-id="oob"[^>]*stroke="none"/); + // Inverted white-out is an even-odd ring against the map frame. + expect(svg).toContain('data-object-id="inverted"'); + expect(svg).toContain('fill-rule="evenodd"'); + expect(svg).toContain( + `M ${document.mapFrame.x} ${document.mapFrame.y} L ${document.mapFrame.x + document.mapFrame.width} ${document.mapFrame.y}`, + ); }); }); diff --git a/packages/shared/src/course-maps/objects-svg.ts b/packages/shared/src/course-maps/objects-svg.ts index c20c33d..8863458 100644 --- a/packages/shared/src/course-maps/objects-svg.ts +++ b/packages/shared/src/course-maps/objects-svg.ts @@ -50,6 +50,8 @@ export interface RenderMapObjectsOptions { /** ISOM 2017-2 symbol 709 (2022 revision) at 1:15 000. */ export const OUT_OF_BOUNDS_LINE_MM = 0.2; export const OUT_OF_BOUNDS_GAP_MM = 1.2; +/** ISOM 709 bounding line width (Feb 2024 revision). */ +export const OUT_OF_BOUNDS_BORDER_MM = 0.4; const FONT_STACKS = { sans: "Liberation Sans, Arial, sans-serif", @@ -142,6 +144,7 @@ function resolveFill( case "solid": return fill ?? "none"; case "whiteout": + case "whiteoutInverted": return "#ffffff"; case "outOfBounds": return patternId ? `url(#${patternId})` : "none"; @@ -161,6 +164,42 @@ function strokeAttrs( return `stroke="${stroke}" stroke-width="${strokeWidthMm}"`; } +/** + * Resolve stroke for a rect/path. Out-of-bounds borders always use the + * course purple (ISOM 709), ignoring any stored stroke colour. + */ +export function resolveObjectStroke( + fillMode: MapFillMode | undefined, + stroke: string | undefined, + strokeWidthMm: number | undefined, + purple: string, +): { stroke: string | undefined; strokeWidthMm: number | undefined } { + if (!stroke || strokeWidthMm === undefined) { + return { stroke: undefined, strokeWidthMm: undefined }; + } + if (fillMode === "outOfBounds") { + return { stroke: purple, strokeWidthMm }; + } + return { stroke, strokeWidthMm }; +} + +function frameRingPath(frame: MapRect): string { + const { x, y, width, height } = frame; + return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height} L ${x} ${y + height} Z`; +} + +function rectPathData(x: number, y: number, width: number, height: number): string { + return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height} L ${x} ${y + height} Z`; +} + +function isClosedFill(fillMode: MapFillMode): boolean { + return ( + fillMode === "whiteout" || + fillMode === "whiteoutInverted" || + fillMode === "outOfBounds" + ); +} + /** * ISOM 709 purple cross-hatch pattern. Dimensions are at the base map * scale and multiplied by `overprintScale` so they enlarge with the map. @@ -239,11 +278,23 @@ function renderObject( defs.push(outOfBoundsPatternDef(patternId, purple, overprintScale)); } const fill = resolveFill(fillMode, object.fill, patternId); - const closed = - object.closed || - fillMode === "whiteout" || - fillMode === "outOfBounds"; - return ``; + const closed = object.closed || isClosedFill(fillMode); + const shapePath = pathData(points, closed); + const resolved = resolveObjectStroke( + fillMode, + object.stroke, + object.strokeWidthMm, + purple, + ); + if (fillMode === "whiteoutInverted") { + const ring = `${frameRingPath(options.frame)} ${shapePath}`; + const border = + resolved.stroke !== undefined + ? `` + : ""; + return `${border}`; + } + return ``; } case "rectangle": { const p = @@ -268,7 +319,22 @@ function renderObject( defs.push(outOfBoundsPatternDef(patternId, purple, overprintScale)); } const fill = resolveFill(fillMode, object.fill, patternId); - return ``; + const resolved = resolveObjectStroke( + fillMode, + object.stroke, + object.strokeWidthMm, + purple, + ); + if (fillMode === "whiteoutInverted") { + const shapePath = rectPathData(p.x, p.y, size.width, size.height); + const ring = `${frameRingPath(options.frame)} ${shapePath}`; + const border = + resolved.stroke !== undefined + ? `` + : ""; + return `${border}`; + } + return ``; } case "image": { const p = diff --git a/packages/shared/src/course-maps/schema.ts b/packages/shared/src/course-maps/schema.ts index 3eb3a32..d8030cf 100644 --- a/packages/shared/src/course-maps/schema.ts +++ b/packages/shared/src/course-maps/schema.ts @@ -81,12 +81,15 @@ export const mapLineObjectSchema = z.object({ * - `none` — stroke only (or invisible if no stroke) * - `solid` — solid colour from `fill` * - `whiteout` — opaque white, drawn under the course overlay + * - `whiteoutInverted` — opaque white outside the shape (even-odd ring + * against the map frame), for ink-saving print areas * - `outOfBounds` — ISOM 709 purple cross-hatch */ export const mapFillModeSchema = z.enum([ "none", "solid", "whiteout", + "whiteoutInverted", "outOfBounds", ]); export type MapFillMode = z.infer; @@ -170,7 +173,15 @@ export type CourseMapObject = z.infer; export function isWhiteoutObject(object: CourseMapObject): boolean { return ( (object.kind === "rectangle" || object.kind === "path") && - object.fillMode === "whiteout" + (object.fillMode === "whiteout" || object.fillMode === "whiteoutInverted") + ); +} + +/** True when the white-out fills everything outside the shape (ink saver). */ +export function isInvertedWhiteoutObject(object: CourseMapObject): boolean { + return ( + (object.kind === "rectangle" || object.kind === "path") && + object.fillMode === "whiteoutInverted" ); } diff --git a/packages/web/src/components/MapLayoutEditor.tsx b/packages/web/src/components/MapLayoutEditor.tsx index 68cbf0c..9de2f40 100644 --- a/packages/web/src/components/MapLayoutEditor.tsx +++ b/packages/web/src/components/MapLayoutEditor.tsx @@ -20,12 +20,14 @@ import { isWhiteoutObject, mapToPage, outOfBoundsPatternDef, + OUT_OF_BOUNDS_BORDER_MM, pageToMap, pathData, printablePageRect, rectInside, renderCourseOverlaySvg, renderDescriptionBlockSvg, + resolveObjectStroke, windowRotationDeg, type CourseMapDocument, type CourseMapObject, @@ -54,12 +56,14 @@ import { createObjectAt, displayedPreviewPlacement, editorPreviewDpi, + fillModePatch, initialEditorViewport, insertPolygonVertex, panEditorViewport, pinchEditorViewport, pageDeltaToObject, parseClampedNumberDraft, + parseLiveNumberDraft, removePolygonVertex, resizeMapObject, translateMapObject, @@ -345,6 +349,59 @@ const FONT_STACKS: Record = { condensed: "Liberation Sans Narrow, Arial Narrow, sans-serif", }; +/** + * Number input that keeps a string draft while focused so the user can + * clear the field and type a new value without `Number("")` snapping back. + */ +function NumberField({ + value, + min, + max, + step, + onCommit, + "data-testid": testId, + className, +}: { + value: number; + min: number; + max: number; + step?: number; + onCommit: (next: number) => void; + "data-testid"?: string; + className?: string; +}) { + const [draft, setDraft] = useState(null); + const display = draft ?? String(value); + return ( + setDraft(String(value))} + onChange={(event) => { + const next = event.target.value; + setDraft(next); + const parsed = parseLiveNumberDraft(next, min, max); + if (parsed !== null) onCommit(parsed); + }} + onBlur={() => { + if (draft !== null) { + const parsed = parseLiveNumberDraft(draft, min, max); + if (parsed !== null) onCommit(parsed); + } + setDraft(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className={className} + /> + ); +} + function renderObject( object: CourseMapObject, frame: CourseMapDocument["mapFrame"], @@ -353,12 +410,19 @@ function renderObject( graphicHref?: (graphicId: number) => string, purple = "#a626ff", ): ReactNode { + const inverted = + (object.kind === "rectangle" || object.kind === "path") && + object.fillMode === "whiteoutInverted"; const common = { "data-object-id": object.id, "data-map-editor-target": onPointerDown ? "true" : undefined, onPointerDown, className: onPointerDown ? "cursor-move" : undefined, - pointerEvents: onPointerDown ? ("bounding-box" as const) : undefined, + pointerEvents: onPointerDown + ? inverted + ? ("fill" as const) + : ("bounding-box" as const) + : undefined, style: onPointerDown ? ({ touchAction: "none", userSelect: "none" } as const) : undefined, @@ -450,7 +514,7 @@ function renderObject( const fillMode = object.fillMode ?? "none"; const patternId = fillMode === "outOfBounds" ? `oob-${object.id}` : null; const fill = - fillMode === "whiteout" + fillMode === "whiteout" || fillMode === "whiteoutInverted" ? "#ffffff" : fillMode === "solid" ? object.fill ?? "none" @@ -460,26 +524,53 @@ function renderObject( const closed = object.closed || fillMode === "whiteout" || + fillMode === "whiteoutInverted" || fillMode === "outOfBounds"; + const shapePath = pathData(vertices, closed); + const stroke = resolveObjectStroke( + fillMode, + object.stroke, + object.strokeWidthMm, + purple, + ); + if (fillMode === "whiteoutInverted") { + const ring = `M ${frame.x} ${frame.y} L ${frame.x + frame.width} ${frame.y} L ${frame.x + frame.width} ${frame.y + frame.height} L ${frame.x} ${frame.y + frame.height} Z ${shapePath}`; + return ( + + + {stroke.stroke !== undefined && ( + + )} + + ); + } return ( {patternId && ( )} @@ -490,23 +581,49 @@ function renderObject( const fillMode = object.fillMode ?? "none"; const patternId = fillMode === "outOfBounds" ? `oob-${object.id}` : null; const fill = - fillMode === "whiteout" + fillMode === "whiteout" || fillMode === "whiteoutInverted" ? "#ffffff" : fillMode === "solid" ? object.fill ?? "none" : fillMode === "outOfBounds" ? `url(#${patternId})` : "none"; + const stroke = resolveObjectStroke( + fillMode, + object.stroke, + object.strokeWidthMm, + purple, + ); + if (fillMode === "whiteoutInverted") { + const shapePath = `M ${bounds.x} ${bounds.y} L ${bounds.x + bounds.width} ${bounds.y} L ${bounds.x + bounds.width} ${bounds.y + bounds.height} L ${bounds.x} ${bounds.y + bounds.height} Z`; + const ring = `M ${frame.x} ${frame.y} L ${frame.x + frame.width} ${frame.y} L ${frame.x + frame.width} ${frame.y + frame.height} L ${frame.x} ${frame.y + frame.height} Z ${shapePath}`; + return ( + + + {stroke.stroke !== undefined && ( + + )} + + ); + } return ( {patternId && ( )} @@ -517,8 +634,8 @@ function renderObject( width={bounds.width} height={bounds.height} fill={fill} - stroke={object.stroke ?? "none"} - strokeWidth={object.strokeWidthMm} + stroke={stroke.stroke ?? "none"} + strokeWidth={stroke.strokeWidthMm} style={common.style} /> @@ -1616,17 +1733,21 @@ export function MapLayoutEditor({ case "path": return object.fillMode === "whiteout" ? t("objectKindWhiteout") - : object.fillMode === "outOfBounds" - ? t("objectKindOutOfBounds") - : object.closed - ? t("objectKindPolygon") - : t("objectKindPath"); + : object.fillMode === "whiteoutInverted" + ? t("objectKindWhiteoutInverted") + : object.fillMode === "outOfBounds" + ? t("objectKindOutOfBounds") + : object.closed + ? t("objectKindPolygon") + : t("objectKindPath"); case "rectangle": return object.fillMode === "whiteout" ? t("objectKindWhiteout") - : object.fillMode === "outOfBounds" - ? t("objectKindOutOfBounds") - : t("objectKindRectangle"); + : object.fillMode === "whiteoutInverted" + ? t("objectKindWhiteoutInverted") + : object.fillMode === "outOfBounds" + ? t("objectKindOutOfBounds") + : t("objectKindRectangle"); case "image": return t("objectKindImage"); } @@ -2285,16 +2406,15 @@ export function MapLayoutEditor({