diff --git a/apps/web/src/app/dashboard/events/[id]/sign/sign-preview.tsx b/apps/web/src/app/dashboard/events/[id]/sign/sign-preview.tsx new file mode 100644 index 0000000..1effe4e --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/sign/sign-preview.tsx @@ -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) => void; +}) { + const [editing, setEditing] = useState(false); + const canvas = useRef(null); + const drag = useRef<{ id: number; clientX: number; clientY: number; box: ReturnType; 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, 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) { + 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, 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, 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) => begin(event, handle), onPointerMove: move, + onPointerUp: (event: PointerEvent) => end(event), onPointerCancel: (event: PointerEvent) => end(event, true), + onLostPointerCapture: () => { drag.current = null; }, onKeyDown: (event: KeyboardEvent) => 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 ?
+ + +
: null} + {editing && movable ? <> +

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.

+ + {(["x", "y", "width", "height"] as const).map(property => + {({ x: "X", y: "Y", width: "Width", height: "Height" })[property]} (%) + { 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); + }} /> + )} + Horizontal alignment + + + Vertical alignment + + + + : null} +
+ {`Printable + {editing && movable ?
+ + {handles.map(handle => )} +
: null} +
+ ; +} diff --git a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx index 3c431ad..d172834 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; 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 { SignDesignControls } from "./design-controls"; +import { SignPreview } from "./sign-preview"; function serializedDesign(design: SignDesign) { const { fontData, interFontData, geologicaFontData, ...saved } = design; @@ -219,8 +220,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUr
Live print preview{paper.width} × {paper.height}
- {`Printable + setDesign(d => ({ ...d, ...patch }))} />
; diff --git a/apps/web/src/lib/guest-sign.test.ts b/apps/web/src/lib/guest-sign.test.ts index cc20010..30f91ba 100644 --- a/apps/web/src/lib/guest-sign.test.ts +++ b/apps/web/src/lib/guest-sign.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import sharp from "sharp"; import jsQR from "jsqr"; 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 () => { 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 = /]*\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(/ { + 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", () => { const url = "https://ma.hadlock.tech/e/demo"; for (const layout of ["compact", "typography"] as const) { diff --git a/apps/web/src/lib/guest-sign.ts b/apps/web/src/lib/guest-sign.ts index 4cad117..fe54f21 100644 --- a/apps/web/src/lib/guest-sign.ts +++ b/apps/web/src/lib/guest-sign.ts @@ -7,6 +7,40 @@ export type SignDesign = SavedSign & { }; 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, 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) { const size = qr.size + 8; return `Manyangles guest album QR code`; @@ -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(""); const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420; // 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; const wrap = () => proportional ? headlineLinesForWidth(value, width, size) : signLines(value, Math.max(1, Math.floor(width / (size * .65)))); 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 // 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; - return wrapped.map((line, index) => `${escapeXml(line)}`).join(""); + const spare = availableHeight - wrapped.length * size * 1.25; + 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) => `${escapeXml(line)}`).join(""); }; if (design.layout) { const compact = design.layout === "compact"; @@ -134,7 +171,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) { const textX = landscape ? 100 : 85; const textWidth = landscape ? 530 : 680; const codeX = landscape ? 760 : (w - qrSize) / 2; - const headlineWidth = landscape ? codeX - textX - 40 : textWidth; + const headlineBox = signHeadlineBox(design); const codeY = landscape ? 210 : 475; const footerY = landscape ? 655 : 955; return ` @@ -142,7 +179,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) { - ${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)} ${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)} diff --git a/packages/contracts/src/sign.test.ts b/packages/contracts/src/sign.test.ts index edfff01..cfb1bfd 100644 --- a/packages/contracts/src/sign.test.ts +++ b/packages/contracts/src/sign.test.ts @@ -1,6 +1,19 @@ import { expect, test } from "bun:test"; 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", () => { const base = { title: "Event", headline: "Share", message: "Welcome", paper: "letter", ink: "indigo" }; expect(savedSignSchema.parse(base).headlineScale).toBeUndefined(); diff --git a/packages/contracts/src/sign.ts b/packages/contracts/src/sign.ts index c90e3dc..1ac7050 100644 --- a/packages/contracts/src/sign.ts +++ b/packages/contracts/src/sign.ts @@ -40,6 +40,12 @@ export const savedSignSchema = z.object({ decoration: z.enum(["arch", "frame", "minimal"]).optional(), showBrand: z.boolean().optional(), backgroundImage: image, logoImage: image, 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) => { if (design.paper !== "custom") return; const divisor = design.unit === "mm" ? 25.4 : 1;