Add freeform headline box editing and text alignment

This commit is contained in:
2026-09-10 14:23:08 -04:00
parent ed160f8b93
commit 2db2ab6638
6 changed files with 243 additions and 8 deletions
@@ -0,0 +1,116 @@
"use client";
import { useRef, useState, type PointerEvent, type KeyboardEvent } from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { adjustSignHeadlineBox, signHeadlineBox, signPaper, type SignBoxHandle, type SignDesign } from "@/lib/guest-sign";
const handles = [
{ id: "nw", label: "top left", x: 0, y: 0, cursor: "nwse-resize" },
{ id: "n", label: "top", x: 50, y: 0, cursor: "ns-resize" },
{ id: "ne", label: "top right", x: 100, y: 0, cursor: "nesw-resize" },
{ id: "e", label: "right", x: 100, y: 50, cursor: "ew-resize" },
{ id: "se", label: "bottom right", x: 100, y: 100, cursor: "nwse-resize" },
{ id: "s", label: "bottom", x: 50, y: 100, cursor: "ns-resize" },
{ id: "sw", label: "bottom left", x: 0, y: 100, cursor: "nesw-resize" },
{ id: "w", label: "left", x: 0, y: 50, cursor: "ew-resize" },
] as const;
export function SignPreview({ design, svg, disabled, onChange }: {
design: SignDesign; svg: string; disabled: boolean;
onChange: (patch: Partial<SignDesign>) => void;
}) {
const [editing, setEditing] = useState(false);
const canvas = useRef<HTMLDivElement>(null);
const drag = useRef<{ id: number; clientX: number; clientY: number; box: ReturnType<typeof signHeadlineBox>; handle: SignBoxHandle; pixelsPerUnit: number; original: SignDesign["headlinePosition"] } | null>(null);
const paper = signPaper(design), box = signHeadlineBox(design);
const movable = design.layout === "compact" || design.layout === "typography";
function begin(event: PointerEvent<HTMLButtonElement>, handle: SignBoxHandle) {
if (disabled || event.button !== 0 || !event.isPrimary || drag.current) return;
const rect = canvas.current?.getBoundingClientRect();
if (!rect?.width) return;
event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { id: event.pointerId, clientX: event.clientX, clientY: event.clientY, box, handle, pixelsPerUnit: rect.width / 850 * box.scale, original: design.headlinePosition };
}
function move(event: PointerEvent<HTMLButtonElement>) {
const start = drag.current;
if (!start || start.id !== event.pointerId || disabled) return;
onChange({ headlinePosition: adjustSignHeadlineBox(start.box, (event.clientX - start.clientX) / start.pixelsPerUnit, (event.clientY - start.clientY) / start.pixelsPerUnit, start.handle) });
}
function end(event: PointerEvent<HTMLButtonElement>, cancel = false) {
if (drag.current?.id !== event.pointerId) return;
if (cancel) onChange({ headlinePosition: drag.current.original });
else move(event);
drag.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function key(event: KeyboardEvent<HTMLButtonElement>, handle: SignBoxHandle) {
if (event.key === "Escape" && drag.current) {
event.preventDefault(); onChange({ headlinePosition: drag.current.original });
const id = drag.current.id; drag.current = null;
if (event.currentTarget.hasPointerCapture(id)) event.currentTarget.releasePointerCapture(id);
return;
}
const direction = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
if (!direction || drag.current || disabled) return;
event.preventDefault(); const step = event.shiftKey ? 10 : 1;
onChange({ headlinePosition: adjustSignHeadlineBox(box, direction[0]! * step, direction[1]! * step, handle) });
}
const pointerProps = (handle: SignBoxHandle) => ({
disabled, "aria-describedby": "headline-drag-help",
onPointerDown: (event: PointerEvent<HTMLButtonElement>) => begin(event, handle), onPointerMove: move,
onPointerUp: (event: PointerEvent<HTMLButtonElement>) => end(event), onPointerCancel: (event: PointerEvent<HTMLButtonElement>) => end(event, true),
onLostPointerCapture: () => { drag.current = null; }, onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => key(event, handle),
});
const percentages = { x: box.x / box.w * 100, y: box.y / box.h * 100, width: box.width / box.w * 100, height: box.height / box.h * 100 };
return <>
{movable ? <div className="mb-4 flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={disabled} aria-pressed={editing} onClick={() => setEditing(value => !value)}>{editing ? "Done editing box" : "Edit headline box"}</Button>
<Button type="button" variant="outline" size="sm" disabled={disabled || !(design.headlinePosition || design.headlineAlign || design.headlineVerticalAlign)} onClick={() => onChange({ headlinePosition: undefined, headlineAlign: undefined, headlineVerticalAlign: undefined })}>Reset box</Button>
</div> : null}
{editing && movable ? <>
<p id="headline-drag-help" className="mb-4 text-sm text-muted-foreground">Drag inside to move; drag an edge or corner to resize freely. Arrow keys nudge the focused box or handle; Shift makes larger steps. Escape cancels a drag. Text wraps and fits inside the box. Check overlaps, then Save design.</p>
<FieldGroup className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{(["x", "y", "width", "height"] as const).map(property => <Field key={property}>
<FieldLabel htmlFor={`headline-box-${property}`}>{({ x: "X", y: "Y", width: "Width", height: "Height" })[property]} (%)</FieldLabel>
<Input key={percentages[property]} id={`headline-box-${property}`} type="number" min={0} max={100} step="0.1" disabled={disabled} defaultValue={Number(percentages[property].toFixed(2))}
onKeyDown={event => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } }}
onBlur={event => {
const value = event.currentTarget.valueAsNumber;
if (!Number.isFinite(value)) { event.currentTarget.value = percentages[property].toFixed(2); return; }
const next = signHeadlineBox({ ...design, headlinePosition: { ...percentages, [property]: value } });
onChange({ headlinePosition: adjustSignHeadlineBox(next, 0, 0, "move") });
event.currentTarget.value = (next[property] / (property === "x" || property === "width" ? next.w : next.h) * 100).toFixed(2);
}} />
</Field>)}
<Field className="sm:col-span-2"><FieldLabel htmlFor="headline-horizontal">Horizontal alignment</FieldLabel>
<Select disabled={disabled} value={design.headlineAlign ?? "left"} onValueChange={value => onChange({ headlineAlign: value as SignDesign["headlineAlign"] })}>
<SelectTrigger id="headline-horizontal" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>
<SelectItem value="left">Left</SelectItem><SelectItem value="center">Center</SelectItem><SelectItem value="right">Right</SelectItem>
</SelectGroup></SelectContent>
</Select>
</Field>
<Field className="sm:col-span-2"><FieldLabel htmlFor="headline-vertical">Vertical alignment</FieldLabel>
<Select disabled={disabled} value={design.headlineVerticalAlign ?? "bottom"} onValueChange={value => onChange({ headlineVerticalAlign: value as SignDesign["headlineVerticalAlign"] })}>
<SelectTrigger id="headline-vertical" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>
<SelectItem value="top">Top</SelectItem><SelectItem value="middle">Middle</SelectItem><SelectItem value="bottom">Bottom</SelectItem>
</SelectGroup></SelectContent>
</Select>
</Field>
</FieldGroup>
</> : null}
<div ref={canvas} className="relative mx-auto" style={{ width: `min(100%, ${75 * 850 / paper.viewHeight}dvh)` }}>
<img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} alt={`Printable guest sign for ${design.title}, with a QR code and upload instructions`} width={850} height={paper.viewHeight} draggable={false} className="block h-auto w-full select-none drop-shadow-lg" />
{editing && movable ? <div className="absolute" style={{ left: `${(box.offsetX + box.x * box.scale) / 850 * 100}%`, top: `${(box.offsetY + box.y * box.scale) / paper.viewHeight * 100}%`, width: `${box.width * box.scale / 850 * 100}%`, height: `${box.height * box.scale / paper.viewHeight * 100}%` }}>
<button type="button" aria-label="Move headline bounding box" {...pointerProps("move")} className="absolute inset-0 touch-none cursor-move border-2 border-dashed border-primary bg-transparent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
<span className="absolute -top-6 left-3 rounded bg-primary px-2 text-xs text-primary-foreground">Headline</span>
</button>
{handles.map(handle => <button key={handle.id} type="button" aria-label={`Resize headline ${handle.label}`} {...pointerProps(handle.id)}
className="absolute flex size-7 -translate-x-1/2 -translate-y-1/2 touch-none items-center justify-center rounded focus-visible:outline-2 focus-visible:outline-ring"
style={{ left: `${handle.x}%`, top: `${handle.y}%`, cursor: handle.cursor }}><span className="size-3 rounded-sm border-2 border-primary bg-background" /></button>)}
</div> : null}
</div>
</>;
}
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { guestQrSvg, guestSignSvg, signPrintHtml, signPrintMetrics, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign"; import { guestQrSvg, guestSignSvg, signPrintHtml, signPrintMetrics, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign";
import { SignDesignControls } from "./design-controls"; import { SignDesignControls } from "./design-controls";
import { SignPreview } from "./sign-preview";
function serializedDesign(design: SignDesign) { function serializedDesign(design: SignDesign) {
const { fontData, interFontData, geologicaFontData, ...saved } = design; const { fontData, interFontData, geologicaFontData, ...saved } = design;
@@ -219,8 +220,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
</Card> </Card>
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24"> <div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">
<div className="mb-4 flex justify-between text-xs text-muted-foreground"><span>Live print preview</span><span>{paper.width} × {paper.height}</span></div> <div className="mb-4 flex justify-between text-xs text-muted-foreground"><span>Live print preview</span><span>{paper.width} × {paper.height}</span></div>
<img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} alt={`Printable guest sign for ${design.title}, with a QR code and upload instructions`} <SignPreview design={sizeError ? { ...design, paper: "letter" } : design} svg={svg} disabled={loadingDesign || loadError || saving || assetBusy || !fontReady || Boolean(sizeError)} onChange={patch => setDesign(d => ({ ...d, ...patch }))} />
width={850} height={paper.viewHeight} className="mx-auto block h-auto max-h-[75dvh] w-full object-contain drop-shadow-lg" />
</div> </div>
</div> </div>
</div>; </div>;
+64 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import sharp from "sharp"; import sharp from "sharp";
import jsQR from "jsqr"; import jsQR from "jsqr";
import { signQr } from "../server/sign-qr"; import { signQr } from "../server/sign-qr";
import { SIGN_PAPERS, guestQrSvg, guestSignSvg, signLines, signPrintHtml, signPrintMetrics, signPaper, type SignDesign } from "./guest-sign"; import { SIGN_PAPERS, guestQrSvg, guestSignSvg, signLines, signPrintHtml, signPrintMetrics, signPaper, signHeadlineBox, adjustSignHeadlineBox, type SignDesign } from "./guest-sign";
test("standalone QR export scans to the guest link and keeps its quiet zone", async () => { test("standalone QR export scans to the guest link and keeps its quiet zone", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding"; const url = "https://ma.hadlock.tech/e/test-wedding";
@@ -46,6 +46,69 @@ test("text controls change each text independently without resizing the QR", ()
}); });
const headlineGroupPattern = /<g\b[^>]*\bdata-sign-headline(?:="[^"]*")?[^>]*>[\s\S]*?<\/g>/; const headlineGroupPattern = /<g\b[^>]*\bdata-sign-headline(?:="[^"]*")?[^>]*>[\s\S]*?<\/g>/;
test("freeform resizing preserves opposite edges and clamps all handles", () => {
for (const paper of ["card6x4", "card4x6", "square"] as const) {
const base: SignDesign = { ...design, paper, layout: "compact" }, box = signHeadlineBox(base);
for (const handle of ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const) {
const resized = signHeadlineBox({ ...base, headlinePosition: adjustSignHeadlineBox(box, 10, 15, handle) });
if (handle.includes("w")) expect(resized.x + resized.width).toBeCloseTo(box.x + box.width);
else expect(resized.x).toBeCloseTo(box.x);
if (handle.includes("n")) expect(resized.y + resized.height).toBeCloseTo(box.y + box.height);
else expect(resized.y).toBeCloseTo(box.y);
for (const delta of [-10000, 10000]) {
const edge = signHeadlineBox({ ...base, headlinePosition: adjustSignHeadlineBox(box, delta, delta, handle) });
expect(edge.x).toBeGreaterThanOrEqual(40 - 1e-8);
expect(edge.y).toBeGreaterThanOrEqual(40 - 1e-8);
expect(edge.width).toBeGreaterThanOrEqual(100 - 1e-8);
expect(edge.height).toBeGreaterThanOrEqual(80 - 1e-8);
expect(edge.x + edge.width).toBeLessThanOrEqual(edge.w - 40 + 1e-8);
expect(edge.y + edge.height).toBeLessThanOrEqual(edge.h - 40 + 1e-8);
}
}
const wider = signHeadlineBox({ ...base, headlinePosition: adjustSignHeadlineBox(box, 20, 0, "e") });
expect(wider.width).toBeCloseTo(box.width + 20);
expect(wider.height).toBeCloseTo(box.height);
}
});
test("all nine text alignment combinations are applied inside the saved box", () => {
const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url);
const base: SignDesign = { ...design, layout: "compact", paper: "card6x4", headline: "Our wedding", headlinePosition: { x: 10, y: 12, width: 45, height: 35 } };
const box = signHeadlineBox(base), original = guestSignSvg(base, url, qr);
for (const headlineAlign of ["left", "center", "right"] as const) {
for (const headlineVerticalAlign of ["top", "middle", "bottom"] as const) {
const svg = guestSignSvg({ ...base, headlineAlign, headlineVerticalAlign }, url, qr);
const heading = svg.match(headlineGroupPattern)![0], geometry = headlineGeometry(svg);
const x = Number(heading.match(/<text x="([^"]+)"/)![1]);
expect(x).toBeCloseTo(box.x + (headlineAlign === "center" ? box.width / 2 : headlineAlign === "right" ? box.width : 0));
if (headlineAlign !== "left") expect(heading).toContain(`text-anchor="${headlineAlign === "right" ? "end" : "middle"}"`);
if (headlineVerticalAlign === "top") expect(geometry.top).toBeCloseTo(box.y);
if (headlineVerticalAlign === "middle") expect((geometry.top + geometry.bottom) / 2).toBeCloseTo(box.y + box.height / 2);
if (headlineVerticalAlign === "bottom") expect(geometry.bottom).toBeCloseTo(box.y + box.height);
expect(svg.replace(headlineGroupPattern, "")).toBe(original.replace(headlineGroupPattern, ""));
}
}
});
test("headline positions move only the headline and clamp within the page", () => {
const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url);
for (const paper of ["card6x4", "card4x6", "square", "trueDigitalLandscape"] as const) {
const base: SignDesign = { ...design, layout: "compact", paper };
const normal = guestSignSvg(base, url, qr), box = signHeadlineBox(base);
const moved = { ...base, headlinePosition: { x: (box.x + 10) / box.w * 100, y: (box.y + 10) / box.h * 100 } };
const output = guestSignSvg(moved, url, qr);
expect(headlineGeometry(output).bottom).toBeCloseTo(headlineGeometry(normal).bottom + 10);
expect(output.replace(headlineGroupPattern, "")).toBe(normal.replace(headlineGroupPattern, ""));
expect(output).not.toContain("Move headline");
for (const coordinate of [0, 100]) {
const bounded = signHeadlineBox({ ...base, headlinePosition: { x: coordinate, y: coordinate } });
expect(bounded.x).toBeGreaterThanOrEqual(40);
expect(bounded.y).toBeGreaterThanOrEqual(40);
expect(bounded.x + bounded.width).toBeLessThanOrEqual(bounded.w - 40);
expect(bounded.y + bounded.height).toBeLessThanOrEqual(bounded.h - 40);
}
expect(guestSignSvg({ ...moved, headlinePosition: undefined }, url, qr)).toBe(normal);
}
});
test("landscape wedding headline uses three wider lines without stretching letters", () => { test("landscape wedding headline uses three wider lines without stretching letters", () => {
const url = "https://ma.hadlock.tech/e/demo"; const url = "https://ma.hadlock.tech/e/demo";
for (const layout of ["compact", "typography"] as const) { for (const layout of ["compact", "typography"] as const) {
+42 -5
View File
@@ -7,6 +7,40 @@ export type SignDesign = SavedSign & {
}; };
export type SignQr = { path: string; size: number }; export type SignQr = { path: string; size: number };
export function signHeadlineBox(design: SignDesign) {
const paper = signPaper(design);
const landscape = paper.viewHeight < 850;
const w = landscape ? 1200 : 850, h = landscape ? 800 : 1100;
const dimension = (value: number | undefined, fallback: number, total: number, min: number) =>
Math.min(total - 80, Math.max(min, value !== undefined && Number.isFinite(value) ? value / 100 * total : fallback));
const width = dimension(design.headlinePosition?.width, landscape ? 620 : 680, w, 100);
const height = dimension(design.headlinePosition?.height, landscape ? 300 : 230, h, 80);
const safe = (value: number | undefined, fallback: number, max: number) =>
Math.min(max, Math.max(40, value !== undefined && Number.isFinite(value) ? value : fallback));
const x = safe(design.headlinePosition ? design.headlinePosition.x / 100 * w : undefined, landscape ? 100 : 85, w - width - 40);
const y = safe(design.headlinePosition ? design.headlinePosition.y / 100 * h : undefined, landscape ? 80 : 55, h - height - 40);
const scale = Math.min(850 / w, paper.viewHeight / h);
return { x, y, width, height, w, h, scale, offsetX: (850 - w * scale) / 2, offsetY: (paper.viewHeight - h * scale) / 2 };
}
export type SignBoxHandle = "move" | "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
export function adjustSignHeadlineBox(box: ReturnType<typeof signHeadlineBox>, dx: number, dy: number, handle: SignBoxHandle) {
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
let { x, y, width, height } = box;
if (handle === "move") {
x = clamp(x + dx, 40, box.w - width - 40);
y = clamp(y + dy, 40, box.h - height - 40);
} else {
const right = x + width, bottom = y + height;
if (handle.includes("w")) { x = clamp(x + dx, 40, right - 100); width = right - x; }
if (handle.includes("e")) width = clamp(width + dx, 100, box.w - x - 40);
if (handle.includes("n")) { y = clamp(y + dy, 40, bottom - 80); height = bottom - y; }
if (handle.includes("s")) height = clamp(height + dy, 80, box.h - y - 40);
}
return { x: x / box.w * 100, y: y / box.h * 100, width: width / box.w * 100, height: height / box.h * 100 };
}
export function guestQrSvg(qr: SignQr) { export function guestQrSvg(qr: SignQr) {
const size = qr.size + 8; const size = qr.size + 8;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size * 16}" height="${size * 16}" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><title>Manyangles guest album QR code</title><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${size * 16}" height="${size * 16}" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><title>Manyangles guest album QR code</title><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>`;
@@ -98,7 +132,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
.map(([family, data]) => `@font-face{font-family:${family};src:url('${data}') format('woff2');font-weight:100 900;font-style:normal}`).join(""); .map(([family, data]) => `@font-face{font-family:${family};src:url('${data}') format('woff2');font-weight:100 900;font-style:normal}`).join("");
const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420; const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420;
// Keep text inside its own region; changing type never moves or shrinks the QR. // Keep text inside its own region; changing type never moves or shrinks the QR.
const sizedText = (value: string, x: number, y: number, base: number, percent: number, width: number, height: number, weight = 400, centered = false, upwardRegion?: { top: number; bottom: number }, proportional = false) => { const sizedText = (value: string, x: number, y: number, base: number, percent: number, width: number, height: number, weight = 400, centered = false, upwardRegion?: { top: number; bottom: number; horizontal?: SignDesign["headlineAlign"]; vertical?: SignDesign["headlineVerticalAlign"] }, proportional = false) => {
let size = base * Math.min(150, Math.max(75, Number.isFinite(percent) ? percent : 100)) / 100; let size = base * Math.min(150, Math.max(75, Number.isFinite(percent) ? percent : 100)) / 100;
const wrap = () => proportional ? headlineLinesForWidth(value, width, size) : signLines(value, Math.max(1, Math.floor(width / (size * .65)))); const wrap = () => proportional ? headlineLinesForWidth(value, width, size) : signLines(value, Math.max(1, Math.floor(width / (size * .65))));
let wrapped = wrap(); let wrapped = wrap();
@@ -109,8 +143,11 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
} }
// Anchor the bottom (including descenders), so more/larger lines consume the // Anchor the bottom (including descenders), so more/larger lines consume the
// unused space above before fitting down at the print-safe top margin. // unused space above before fitting down at the print-safe top margin.
const baseline = upwardRegion ? upwardRegion.bottom - size * .25 - (wrapped.length - 1) * size * 1.25 : y; const spare = availableHeight - wrapped.length * size * 1.25;
return wrapped.map((line, index) => `<text x="${x}" y="${baseline + index * size * 1.25}"${centered ? ' text-anchor="middle"' : ""} font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join(""); const baseline = upwardRegion ? upwardRegion.top + size + spare * (upwardRegion.vertical === "top" ? 0 : upwardRegion.vertical === "middle" ? .5 : 1) : y;
const textX = x + (upwardRegion?.horizontal === "center" ? width / 2 : upwardRegion?.horizontal === "right" ? width : 0);
const anchor = upwardRegion?.horizontal === "right" ? ' text-anchor="end"' : centered || upwardRegion?.horizontal === "center" ? ' text-anchor="middle"' : "";
return wrapped.map((line, index) => `<text x="${textX}" y="${baseline + index * size * 1.25}"${anchor} font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
}; };
if (design.layout) { if (design.layout) {
const compact = design.layout === "compact"; const compact = design.layout === "compact";
@@ -134,7 +171,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
const textX = landscape ? 100 : 85; const textX = landscape ? 100 : 85;
const textWidth = landscape ? 530 : 680; const textWidth = landscape ? 530 : 680;
const codeX = landscape ? 760 : (w - qrSize) / 2; const codeX = landscape ? 760 : (w - qrSize) / 2;
const headlineWidth = landscape ? codeX - textX - 40 : textWidth; const headlineBox = signHeadlineBox(design);
const codeY = landscape ? 210 : 475; const codeY = landscape ? 210 : 475;
const footerY = landscape ? 655 : 955; const footerY = landscape ? 655 : 955;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}"> return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
@@ -142,7 +179,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
<rect width="850" height="${paper.viewHeight}" fill="${background}"/> <rect width="850" height="${paper.viewHeight}" fill="${background}"/>
<g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}"> <g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}">
<g data-sign-headline="true" font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8"> <g data-sign-headline="true" font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${sizedText(design.headline, textX, 0, 76, design.headlineScale ?? 100, headlineWidth, 0, 600, false, { top: landscape ? 80 : 55, bottom: landscape ? 380 : 285 }, landscape)} ${sizedText(design.headline, headlineBox.x, 0, 76, design.headlineScale ?? 100, headlineBox.width, 0, 600, false, { top: headlineBox.y, bottom: headlineBox.y + headlineBox.height, horizontal: design.headlineAlign, vertical: design.headlineVerticalAlign }, landscape)}
</g> </g>
${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)} ${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)}
<svg x="${codeX}" y="${codeY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg> <svg x="${codeX}" y="${codeY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
+13
View File
@@ -1,6 +1,19 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { savedSignSchema } from "./sign"; import { savedSignSchema } from "./sign";
test("dragged headline positions persist and reject invalid coordinates", () => {
const base = { title: "Wedding", headline: "Share", message: "Welcome", paper: "card6x4", ink: "black" };
expect(savedSignSchema.parse(base).headlinePosition).toBeUndefined();
const moved = { ...base, headlinePosition: { x: 12.5, y: 14, width: 42, height: 28 }, headlineAlign: "center", headlineVerticalAlign: "middle" };
expect(savedSignSchema.parse(JSON.parse(JSON.stringify(moved)))).toEqual(moved);
for (const headlinePosition of [{ x: -1, y: 0 }, { x: 0, y: 101 }, { x: NaN, y: 2 }, { x: 1, y: Infinity }, { x: "5", y: 0 }, { x: 0, y: 0, z: 2 }]) {
expect(savedSignSchema.safeParse({ ...base, headlinePosition }).success).toBe(false);
}
for (const patch of [{ headlineAlign: "justify" }, { headlineVerticalAlign: "center" }, { headlinePosition: { x: 0, y: 0, width: 0 } }, { headlinePosition: { x: 0, y: 0, height: 101 } }]) {
expect(savedSignSchema.safeParse({ ...base, ...patch }).success).toBe(false);
}
});
test("text sizing survives saved design round trips and rejects invalid scales", () => { test("text sizing survives saved design round trips and rejects invalid scales", () => {
const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" }; const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" };
expect(savedSignSchema.parse(base).headlineScale).toBeUndefined(); expect(savedSignSchema.parse(base).headlineScale).toBeUndefined();
+6
View File
@@ -40,6 +40,12 @@ export const savedSignSchema = z.object({
decoration: z.enum(["arch", "frame", "minimal"]).optional(), showBrand: z.boolean().optional(), decoration: z.enum(["arch", "frame", "minimal"]).optional(), showBrand: z.boolean().optional(),
backgroundImage: image, logoImage: image, backgroundImage: image, logoImage: image,
showUrl: z.boolean().optional(), showUrl: z.boolean().optional(),
headlinePosition: z.object({
x: z.number().finite().min(0).max(100), y: z.number().finite().min(0).max(100),
width: z.number().finite().positive().max(100).optional(), height: z.number().finite().positive().max(100).optional(),
}).strict().optional(),
headlineAlign: z.enum(["left", "center", "right"]).optional(),
headlineVerticalAlign: z.enum(["top", "middle", "bottom"]).optional(),
}).strict().superRefine((design, ctx) => { }).strict().superRefine((design, ctx) => {
if (design.paper !== "custom") return; if (design.paper !== "custom") return;
const divisor = design.unit === "mm" ? 25.4 : 1; const divisor = design.unit === "mm" ? 25.4 : 1;