diff --git a/docker/frontend/public/assets/Fraunces-Regular.ttf b/docker/frontend/public/assets/Fraunces-Regular.ttf new file mode 100644 index 0000000..52f9605 Binary files /dev/null and b/docker/frontend/public/assets/Fraunces-Regular.ttf differ diff --git a/docker/frontend/public/assets/Inter-Regular.ttf b/docker/frontend/public/assets/Inter-Regular.ttf new file mode 100644 index 0000000..1bca8bf Binary files /dev/null and b/docker/frontend/public/assets/Inter-Regular.ttf differ diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index a390af9..89928f3 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { ClearPhotoModal } from './ui/ClearPhotoModal'; import { ExportModal } from './ui/ExportModal'; import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; -import { loadEngine } from './engine/assets'; +import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps'; import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session'; @@ -151,8 +151,8 @@ const SLIDER_SWATCH: Partial string>> = { // The option strips, one open at a time (phone's GroupKey set minus the groups // the web engine has no knob for). type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm' - | 'wmColor' | 'wmSize' | 'gpsColor' | 'gpsName' | 'gpsTime'; -const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime']; + | 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont'; +const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont']; const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); // A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). const PARAM_GROUP: Partial> = { temperature: 'temp', straighten: 'rotate' }; @@ -178,6 +178,13 @@ const WM_SIZES = [ { v: '1.5', d: '1.5x' }, { v: '2', d: '2x' }, ]; +// FONT's list: the faces the bundle carries (see engine/assets.ts). The phone +// lists the device's own families through the platform font manager; a browser +// has no font service, so the honest list is what we ship. +const WM_FONTS = [ + { v: 'DEFAULT', d: 'DEFAULT' }, + ...MARK_FACES.map((f) => ({ v: f.family, d: f.family.toUpperCase() })), +]; interface LoadedSource { bytes: Uint8Array; @@ -190,13 +197,13 @@ interface LoadedSource { // multiplier of the base face — 3.2% of the stamp width. The drag box on the // photo reads and writes exactly these three numbers, so what the box encloses // is what the file gets. -type MarkStyle = { color: string; size: number; x: number; y: number }; -const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92 }; +type MarkStyle = { color: string; size: number; x: number; y: number; font: string | null }; +const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92, font: null }; // GPS carries the same three geometry numbers plus its two switches, and its // box on the photo reads and writes them the same way the custom mark's does. -type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number }; -const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7 }; +type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number; font: string | null }; +const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7, font: null }; // Where each mark's first row sits under y, and the icon column GPS prints // before its text — the engine's own em values, mirrored from the box that @@ -1859,6 +1866,24 @@ export function Workspace() { setMarkStyle((prev) => ({ ...prev, size: Number(v) })); }, }, + // The two FONT chips: the custom mark's own face, and GPS's (the phone + // gives GPS a FONT strip too — it used to print in the bundled face only). + wmFont: { + label: 'TEXT FONT', off: 'DEFAULT', value: markStyle.font ?? 'DEFAULT', + options: WM_FONTS, + onPick: (v) => { + remember(); + setMarkStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v })); + }, + }, + gpsFont: { + label: 'FONT', off: 'DEFAULT', value: gpsStyle.font ?? 'DEFAULT', + options: WM_FONTS, + onPick: (v) => { + remember(); + setGpsStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v })); + }, + }, gpsColor: { label: 'COLOR', off: '#f59e0b', value: gpsStyle.color, options: WM_COLORS, @@ -1919,7 +1944,12 @@ export function Workspace() { // no GPS is no longer a dead end — the switch stays live wherever the browser // can be asked for the device's own position (see locateMe). const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation; - const wmChips = (): ChipDef[] => [ + // Each watermark type is a collapse of its own: its chip is the header, and + // the header's panel (col 2) carries that mark's own controls under it — its + // colour, its face, its switches, its fields. Before, the two marks shared one + // column, so both sets of controls sat open at once. The mark being OFF is + // what closes the section, the same rule the phone's panel draws. + const gpsChips = (): ChipDef[] => [ { key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', @@ -1935,7 +1965,9 @@ export function Workspace() { setUseGeotag(!useGeotag); }, }, - ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []), + ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsFont'), groupChip('gpsName'), groupChip('gpsTime')] : []), + ]; + const customChips = (): ChipDef[] => [ { key: 'custom-wm', label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', @@ -1945,7 +1977,7 @@ export function Workspace() { setMarkOn(!markOn); }, }, - ...(markOn ? [groupChip('wmColor'), groupChip('wmSize')] : []), + ...(markOn ? [groupChip('wmColor'), groupChip('wmSize'), groupChip('wmFont')] : []), ]; // Every tab carries RESET, pinned outside the scrolling chips. @@ -1962,6 +1994,7 @@ export function Workspace() { || markOn || markText !== '' || markStyle.color !== DEFAULT_MARK_STYLE.color || markStyle.size !== DEFAULT_MARK_STYLE.size || markStyle.x !== DEFAULT_MARK_STYLE.x || markStyle.y !== DEFAULT_MARK_STYLE.y + || markStyle.font !== DEFAULT_MARK_STYLE.font || useGeotag || (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]); }, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]); @@ -2421,78 +2454,84 @@ export function Workspace() { - {/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */} + {/* column 2 — FRAME's WATERMARK chip opens the two collapses, one per + watermark type: the header chip, then that mark's own controls */} {wmOpen ? (
- setOpenGroup(null) }, - ...wmChips(), - ]} - /> - {markOn ? ( - setMarkText(e.target.value)} - /> - ) : null} - {useGeotag ? ( - <> + setOpenGroup(null) }]} /> + + {/* GPS WATERMARK — its colour, its face, its two rows, its place */} +
+ + {useGeotag ? ( + <> + setPlace(e.target.value)} + /> +

+ {gps + ? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual') + : t('wm.noExif')} +

+ {!gps ? ( + <> +
+ +
+
+ setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} + onBlur={() => { + const lat = Number(gpsManual.lat); + const lng = Number(gpsManual.lng); + if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { + setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() }); + setUseGeotag(true); + } + }} + /> + setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} + /> +
+ + ) : null} + + ) : null} +
+ + {/* CUSTOM WATERMARK — its text, its colour, its size, its face */} +
+ + {markOn ? ( setPlace(e.target.value)} + value={markText} + placeholder="TYPE WATERMARK TEXT" + maxLength={80} + onChange={(e) => setMarkText(e.target.value)} /> -

- {gps - ? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual') - : t('wm.noExif')} -

- {!gps ? ( - <> -
- -
-
- setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} - onBlur={() => { - const lat = Number(gpsManual.lat); - const lng = Number(gpsManual.lng); - if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { - setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() }); - setUseGeotag(true); - } - }} - /> - setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} - /> -
- - ) : null} - - ) : null} + ) : null} +
) : null} @@ -2623,6 +2662,7 @@ export function Workspace() { baselineEm: CUSTOM_BASELINE_EM, gapEm: 0, lines: [markText], + font: markStyle.font, onMove: setWatermarkPos, }, ] @@ -2637,6 +2677,7 @@ export function Workspace() { baselineEm: GPS_BASELINE_EM, gapEm: GPS_GAP_EM, lines: gpsLines, + font: gpsStyle.font, onMove: setGpsPos, }, ] diff --git a/docker/frontend/src/engine/assets.ts b/docker/frontend/src/engine/assets.ts index 5a8a50d..ce45ac3 100644 --- a/docker/frontend/src/engine/assets.ts +++ b/docker/frontend/src/engine/assets.ts @@ -1,11 +1,23 @@ import { initSkia, hasGpu } from './skiaShim'; import type { RenderAssets } from './exportEngine'; -// One CanvasKit instance per page, one copy of the three render assets. The wasm -// file is served from /wasm/ (scripts/copy-wasm.mjs puts it there at build time); -// the fonts and the wall-frame artwork are in public/assets. +// One CanvasKit instance per page, one copy of the render assets. The wasm file +// is served from /wasm/ (scripts/copy-wasm.mjs puts it there at build time); the +// fonts and the wall-frame artwork are in public/assets. let assets: RenderAssets | null = null; +// The faces the watermarks' FONT option offers beside DEFAULT. There is no OS +// font service in a browser, so the list is exactly what the bundle carries — +// and each family is ONE file, because drawText has no glyph fallback: a name +// mapped to only the latin subset would print a Vietnamese place name as tofu. +// The two here are the site's own self-hosted faces (styles/fonts.css, SIL OFL), +// with their latin / latin-ext / vietnamese woff2 subsets decompressed, pinned +// to weight 400 @ opsz 14 and merged into one TTF per family (fontTools). +export const MARK_FACES: { family: string; file: string }[] = [ + { family: 'Inter', file: '/assets/Inter-Regular.ttf' }, + { family: 'Fraunces', file: '/assets/Fraunces-Regular.ttf' }, +]; + async function buf(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`); @@ -22,12 +34,13 @@ export function probeGpu(): boolean { export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> { await initSkia({ locateFile: (file) => `/wasm/${file}` }); if (!assets) { - const [textFont, emojiFont, wallframePng] = await Promise.all([ + const [textFont, emojiFont, wallframePng, markFonts] = await Promise.all([ buf('/assets/Cousine-Regular.ttf'), buf('/assets/NotoEmoji-GPS.ttf'), buf('/assets/wallframe.png'), + Promise.all(MARK_FACES.map(async (face) => ({ family: face.family, bytes: await buf(face.file) }))), ]); - assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng) }; + assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng), markFonts }; } return { assets, gpu: probeGpu() }; } diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 37078e0..c740ca8 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -51,6 +51,8 @@ export interface RenderAssets { textFont: ArrayBuffer; // assets/Cousine-Regular.ttf emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf wallframe: Uint8Array; // wallframe.png + // The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES). + markFonts: { family: string; bytes: ArrayBuffer }[]; } export interface RenderOptions { @@ -63,7 +65,7 @@ export interface RenderOptions { watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null; liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null; watermarkRotation?: number; - gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null; + gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean; font?: string | null } | null; wallframeLandscape?: boolean; frameWindowZoom?: { s: number; u: number; v: number } | null; photoRotation?: 0 | 90 | 180 | 270; @@ -235,16 +237,61 @@ function loadFaces(assets: RenderAssets) { const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont); if (!typeface || !iconTypeface) return null; faces = { typeface, iconTypeface }; - // Named-family lookup for the custom watermark's FONT option: register the - // bundled face so at least that one name resolves on web. + // Named-family lookup for the watermarks' FONT option: register the bundled + // face under the name it is known by, then the faces the FONT strip offers. try { Skia.FontMgr.System().registerFace(typeface, 'Cousine'); + for (const face of assets.markFonts) { + const named = Skia.Typeface.MakeFreeTypeFaceFromData(face.bytes); + if (named) Skia.FontMgr.System().registerFace(named, face.family); + } } catch { /* provider optional */ } return faces; } +// The face a mark carrying a named family draws with, or null for the bundled +// one. The stage's drag boxes ask the same question, so they measure with the +// same face the file will print. +function namedFace(font: string | null | undefined) { + return font ? Skia.FontMgr.System().matchFamilyStyle(font) : null; +} + +// The face's own numbers, for the boxes the stage draws over a mark: the width +// of the widest line plus the face's ascent/descent, all in pixels at `px`. +// Returns null while the engine is still loading, when no family was picked, or +// when the name is not one the bundle ships — the caller then falls back to the +// monospace em its constants know (ImageStage's WM_ADVANCE_EM and friends). +export function measureMark( + font: string | null | undefined, + px: number, + lines: string[] +): { width: number; ascent: number; descent: number } | null { + if (!Skia || !font || !lines.length) return null; + const face = namedFace(font); + if (!face) return null; + const sized = Skia.Font(face, px); + // Unhinted advances: hinted ones come back rounded per glyph, and a dozen of + // them drift a few pixels off the line they are supposed to bound. + sized.setLinearMetrics?.(true); + try { + const metrics = sized.getMetrics(); + let width = 0; + for (const line of lines) { + const advances = sized.getGlyphWidths(line) as unknown as ArrayLike; + let sum = 0; + for (let i = 0; i < advances.length; i++) sum += advances[i]; + width = Math.max(width, sum); + } + return { width, ascent: -metrics.ascent, descent: metrics.descent }; + } catch { + return null; + } finally { + sized.delete?.(); + } +} + // Both walls are one artwork on one code path: `wallframe` hangs the PNG turned // 90° CW (the portrait opening), `wallframe-landscape` lays it down as it is. const isWallframe = (id: FrameId) => id === 'wallframe' || id === 'wallframe-landscape'; @@ -691,7 +738,7 @@ export async function renderPhoto(input: RenderInput): Promise Math.max(0, Math.min(1, v)); const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1)); - const gpsFont = own(Skia.Font(typeface, gpsSize)); + const gpsFont = own(Skia.Font(namedFace(gpsPos?.font) ?? typeface, gpsSize)); const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize)); const gpsGap = Math.round(gpsSize * 1.6); const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW; @@ -717,7 +764,7 @@ export async function renderPhoto(input: RenderInput): Promise> 1); + this._getGlyphWidthBounds(idPtr, ids.length, outPtr, 0, paint ?? null); + return Float32Array.from( + canvasKit.HEAPF32.subarray(outPtr >> 2, (outPtr >> 2) + ids.length) + ); + } finally { + canvasKit._free(idPtr); + canvasKit._free(outPtr); + } + }; // frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke). const setStyle = canvasKit.Paint.prototype.setStyle; canvasKit.Paint.prototype.setStyle = function (style: any) { @@ -190,24 +214,24 @@ export async function initSkia(options: InitSkiaOptions = {}): Promise { return Skia; } -// Named-family lookup for the custom watermark's FONT option. Only faces the -// bundle ships can be matched on web; anything else falls back to the caller's -// default typeface — the honest behaviour when there is no OS font service. +// Named-family lookup for the watermarks' FONT option. Only faces the bundle +// ships can be matched on web; anything else falls back to the caller's own +// typeface — the honest behaviour when there is no OS font service. +// +// The registry is ours, not CanvasKit's TypefaceFontProvider: that one answers +// `null` for every style shape this wasm binding accepts (probed with +// `{weight:400,width:5,slant:0}`, with the enum objects, and with `.value` +// unwrapped — all null), so a name registered with it never resolves. let fontMgr: any = null; -function makeFontMgr(canvasKit: any) { - const provider = canvasKit.TypefaceFontProvider.Make(); +function makeFontMgr(_canvasKit: any) { + const faces: Record = Object.create(null); return { registerFace(typeface: any, familyName: string) { - provider.registerFont(typeface, familyName); + if (typeface && familyName) faces[familyName.trim().toLowerCase()] = typeface; }, matchFamilyStyle(family: string) { - if (!family) return null; - try { - return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null; - } catch { - return null; - } + return (family && faces[family.trim().toLowerCase()]) || null; }, }; } diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 4d22d80..fabc220 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -109,6 +109,11 @@ ("GPS WATERMARK OFF", "CUSTOM WATERMARK OFF" + PRO), which wrap in the default column width. */ .wm-extra { border-right-color: var(--accent-line); width: 200px; } +/* The watermark panel is two collapses, one per mark: each header chip stands + on its own controls (colour, face, switches, fields), and a rule keeps the + two apart while both marks are on. */ +.wm-sec { display: flex; flex-direction: column; gap: 6px; } +.wm-sec + .wm-sec { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); } .chip { border: 1px solid var(--border); background: var(--bg-elev); diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 8f3a8e3..5a48bd5 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { useI18n } from '../i18n/I18nProvider'; import { MIN_CROP_FRAC, type CropRect } from '../../shared/types'; import { samplePixel } from '../engine/imageOps'; +import { measureMark } from '../engine/exportEngine'; import { Histogram } from './Histogram'; // One wheel notch, and how far the photo may be pulled open. @@ -11,13 +12,14 @@ const ZOOM_MAX = 8; const ZOOM_SETTLE_MS = 160; // The watermarks' own face — the engine draws both marks with the bundled -// Cousine face (the custom mark's FONT option is never set on web), and +// Cousine face unless the panel picked a family from the FONT strip, and // renderPhoto's numbers are what the boxes below read back: the face is 3.2% of // the stamp width, its advance is 0.6001em and its ascent/descent // 0.8325/0.3003em. The custom mark's baseline sits 0.35em under y; GPS draws // its rows 1.2em apart from a baseline 1.1em under y, with an icon column 1.6em // wide before the text. A box built from those same constants hugs exactly the -// text the file will carry. +// text the file will carry — and a mark carrying a named family is measured +// instead (measureMark), because those constants are Cousine's alone. const WM_FONT_FRAC = 0.032; const WM_ADVANCE_EM = 0.60009765625; const WM_ASCENT_EM = 0.83251953125; @@ -43,11 +45,33 @@ export type WmMark = { // The icon column GPS prints before its text, in em (0 for the custom mark). gapEm: number; // The rows as they will be drawn; the face is monospace, so the longest line - // in characters is the width and the count is the height. + // in characters is the width and the count is the height — unless a family + // from the FONT strip is set, when the face itself is measured. lines: string[]; + // The family the panel picked for this mark, null for the bundled face. + font: string | null; onMove: (p: { x?: number; y?: number; size?: number }) => void; }; +// One mark's box, from the numbers the engine draws it with. The width of the +// text is the face's own when the mark carries a family (measureMark), and the +// monospace em above otherwise; the ascent/descent come back with it, because +// the box's top is a baseline's height under y and another family's ascent is +// not Cousine's. Keyed by the mark itself so the drag handlers can reuse it. +const wmBoxOf = (mark: WmMark, fontPx: number) => { + const measured = mark.font ? measureMark(mark.font, fontPx, mark.lines) : null; + const ascent = measured?.ascent ?? WM_ASCENT_EM * fontPx; + const descent = measured?.descent ?? WM_DESCENT_EM * fontPx; + const text = measured?.width ?? Math.max(0, ...mark.lines.map((l) => l.length)) * WM_ADVANCE_EM * fontPx; + return { + ascent, + // In em, so the drag maths can move y by the height a growing face adds. + ascentEm: fontPx > 0 ? ascent / fontPx : WM_ASCENT_EM, + width: Math.max(fontPx, mark.gapEm * fontPx + text), + height: ascent + descent + (mark.lines.length - 1) * WM_ROW_STEP_EM * fontPx, + }; +}; + // The centre column: drop target, preview canvas, and the PEEK comparison. // Dragging a photo anywhere on the page works, not just on the frame — the // whole stage is the drop zone. @@ -218,7 +242,21 @@ export function ImageStage({ } measure(); window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); + // Opening a chip adds an options column and narrows the stage, with no + // window resize to hear about it — so the boxes stayed measured against the + // wider photo until some later render happened to re-run this effect. Watch + // the two things they are measured off instead. + const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); + if (ro) { + const wrap = wrapRef.current; + const img = imgRef.current; + if (wrap) ro.observe(wrap); + if (img) ro.observe(img); + } + return () => { + ro?.disconnect(); + window.removeEventListener('resize', measure); + }; }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]); const grabPanel = (e: React.PointerEvent) => { @@ -509,14 +547,14 @@ export function ImageStage({ if (!box) return []; return marks.map((mark) => { const fontPx = wmFontPx(box.width, mark.size); - const longest = Math.max(0, ...mark.lines.map((l) => l.length)); + const { ascent, width, height } = wmBoxOf(mark, fontPx); return { mark, fontPx, left: mark.x * box.width, - top: mark.y * box.height + (mark.baselineEm - WM_ASCENT_EM) * fontPx, - width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx), - height: (WM_ASCENT_EM + WM_DESCENT_EM + (mark.lines.length - 1) * WM_ROW_STEP_EM) * fontPx, + top: mark.y * box.height + mark.baselineEm * fontPx - ascent, + width, + height, }; }); }, [marks, box]); @@ -534,6 +572,9 @@ export function ImageStage({ left: number; width: number; fontPx: number; + // The face's ascent in em, so a resize keeps the box's top where it is + // whichever family the mark draws in. + ascentEm: number; } | null>(null); const beginWmDrag = (mark: WmMark, mode: 'move' | 'size') => (e: React.PointerEvent) => { @@ -544,7 +585,7 @@ export function ImageStage({ if (!b || !(b.width > 0 && b.height > 0)) return; e.currentTarget.setPointerCapture(e.pointerId); const fontPx = wmFontPx(b.width, mark.size); - const longest = Math.max(0, ...mark.lines.map((l) => l.length)); + const { width, ascentEm } = wmBoxOf(mark, fontPx); wmDragRef.current = { mode, mark, @@ -554,8 +595,9 @@ export function ImageStage({ px: e.clientX, py: e.clientY, left: b.left + mark.x * b.width, - width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx), + width, fontPx, + ascentEm, }; }; @@ -582,7 +624,7 @@ export function ImageStage({ const fontPx = wmFontPx(b.width, size); d.mark.onMove({ size, - y: cl(d.y + ((WM_ASCENT_EM - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1), + y: cl(d.y + ((d.ascentEm - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1), }); };