Add freeform headline box editing and text alignment
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user