diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index f31148b..148f523 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -36,11 +36,14 @@ export async function createEmulatorUser(): Promise { return { email, password, uid: data.localId, idToken: data.idToken }; } +type EmbedMode = "disabled" | "optional" | "required"; + /** Workers の /auth/register に POST してユーザー登録 (受信者登録) を済ませる */ export async function registerReceiver( user: TestUser, handle: string, displayName = handle, + options?: { exif_embed_mode?: EmbedMode; watermark_mode?: EmbedMode }, ): Promise<{ handle: string; sendKey: string; receiveUrl: string }> { const res = await fetch(`${WORKERS_URL}/auth/register`, { method: "POST", @@ -48,7 +51,7 @@ export async function registerReceiver( "Content-Type": "application/json", Authorization: `Bearer ${user.idToken}`, }, - body: JSON.stringify({ handle, display_name: displayName }), + body: JSON.stringify({ handle, display_name: displayName, ...options }), }); if (!res.ok) { throw new Error(`/auth/register 失敗 (${res.status}): ${await res.text()}`); diff --git a/e2e/tests/send-flow-watermark.spec.ts b/e2e/tests/send-flow-watermark.spec.ts new file mode 100644 index 0000000..c24c8d9 --- /dev/null +++ b/e2e/tests/send-flow-watermark.spec.ts @@ -0,0 +1,122 @@ +// 送信者フローの透かし設定 (S05 WatermarkDialog) — R14 watermark_mode / S02 アップロード画面。 +// 受信者が watermark_mode を optional/required にした場合に、送信者 UI で透かしを有効化し、 +// プレビューダイアログ (候補セレクタ・ズーム) を操作して送信完了できることを確認する。 +import { expect, test } from "@playwright/test"; +import { tinyJpeg } from "../fixtures/images"; +import { createEmulatorUser, registerReceiver } from "../helpers/auth"; + +/** ファイル投入 → 送信者名入力 → 透かし有効化 → 「透かしを編集」でダイアログを開くまでの共通手順 */ +async function openWatermarkDialog( + page: import("@playwright/test").Page, + handle: string, + sendKey: string, + files: { name: string; mimeType: string; buffer: Buffer }[], + { senderName = "@e2e_wm", required = false } = {}, +) { + await page.goto(`/send/${handle}/upload?k=${sendKey}`); + await page.locator("input[type=file]").setInputFiles(files); + await expect(page.getByText(`${files.length}枚選択中`)).toBeVisible(); + + await page.getByLabel("送信者名 / TwitterID").fill(senderName); + + // optional は手動でチェック、required は senderName 入力で自動 ON になる + if (!required) { + await page.locator('label:has-text("透かしを入れる")').getByRole("checkbox").check(); + } + + await page.getByRole("button", { name: "透かしを編集" }).click(); + await expect(page.getByRole("heading", { name: "透かしの設定" })).toBeVisible(); +} + +test("透かし optional: 編集ダイアログを開いて有効化し送信完了できる (目的: 透かしフロー全体の成立)", async ({ + page, +}) => { + const user = await createEmulatorUser(); + const handle = `e2e_wm_opt_${Date.now()}`; + const { sendKey } = await registerReceiver(user, handle, "WM Optional", { + watermark_mode: "optional", + }); + + await openWatermarkDialog(page, handle, sendKey, [tinyJpeg()]); + + // プレビューが読み込めるとズームボタン (透かし箇所をズーム) が出る + await expect(page.getByRole("button", { name: "透かし箇所をズーム" })).toBeVisible(); + + // ダイアログを閉じて送信 → 完了画面 + await page.getByRole("button", { name: "完了" }).click(); + await page.getByRole("button", { name: /送信する/ }).click(); + await expect(page).toHaveURL(new RegExp(`/send/${handle}/done`), { timeout: 30_000 }); + await expect(page.getByRole("heading", { name: /送信しました/ })).toBeVisible(); +}); + +test("複数画像でプレビュー候補セレクタが表示され、別画像に切り替えられる (目的: R14 画像選択 UI)", async ({ + page, +}) => { + const user = await createEmulatorUser(); + const handle = `e2e_wm_multi_${Date.now()}`; + const { sendKey } = await registerReceiver(user, handle, "WM Multi", { + watermark_mode: "optional", + }); + + await openWatermarkDialog(page, handle, sendKey, [tinyJpeg("a.jpg"), tinyJpeg("b.jpg")]); + + // 候補は 2 枚。初期は先頭が選択 (aria-pressed=true) + const first = page.getByRole("button", { name: "a.jpg をプレビュー" }); + const second = page.getByRole("button", { name: "b.jpg をプレビュー" }); + await expect(first).toBeVisible(); + await expect(second).toBeVisible(); + await expect(first).toHaveAttribute("aria-pressed", "true"); + + // 2 枚目に切り替えると選択が移る (プレビューが差し替わる) + await second.click(); + await expect(second).toHaveAttribute("aria-pressed", "true"); + await expect(first).toHaveAttribute("aria-pressed", "false"); +}); + +test("ズームボタンにヒントが出て、トグルでズーム状態が切り替わる (目的: ズーム UX)", async ({ + page, +}) => { + const user = await createEmulatorUser(); + const handle = `e2e_wm_zoom_${Date.now()}`; + const { sendKey } = await registerReceiver(user, handle, "WM Zoom", { + watermark_mode: "optional", + }); + + await openWatermarkDialog(page, handle, sendKey, [tinyJpeg()]); + + // 開いて最初の数秒だけ「ズームできます」ヒントが出て、その後消える + await expect(page.getByText("ズームできます")).toBeVisible(); + await expect(page.getByText("ズームできます")).toBeHidden({ timeout: 5_000 }); + + // ズームボタンを押すと ON (aria-pressed=true) になり、もう一度で OFF に戻る + const zoomBtn = page.getByRole("button", { name: "透かし箇所をズーム" }); + await expect(zoomBtn).toHaveAttribute("aria-pressed", "false"); + await zoomBtn.click(); + const zoomOffBtn = page.getByRole("button", { name: "ズーム解除" }); + await expect(zoomOffBtn).toHaveAttribute("aria-pressed", "true"); + await zoomOffBtn.click(); + await expect(page.getByRole("button", { name: "透かし箇所をズーム" })).toBeVisible(); +}); + +test("透かし required: チェックが強制 ON になり外せず、編集ダイアログを開ける (目的: required 強制)", async ({ + page, +}) => { + const user = await createEmulatorUser(); + const handle = `e2e_wm_req_${Date.now()}`; + const { sendKey } = await registerReceiver(user, handle, "WM Required", { + watermark_mode: "required", + }); + + await page.goto(`/send/${handle}/upload?k=${sendKey}`); + await page.locator("input[type=file]").setInputFiles([tinyJpeg()]); + await page.getByLabel("送信者名 / TwitterID").fill("@e2e_req"); + + // required は送信者名入力で自動的にチェック ON かつ disabled (外せない) + const wmCheckbox = page.locator('label:has-text("透かしを入れる")').getByRole("checkbox"); + await expect(wmCheckbox).toBeChecked(); + await expect(wmCheckbox).toBeDisabled(); + + // 「透かしを編集」からダイアログを開ける + await page.getByRole("button", { name: "透かしを編集" }).click(); + await expect(page.getByRole("heading", { name: "透かしの設定" })).toBeVisible(); +}); diff --git a/frontend/src/components/send/WatermarkDialog.tsx b/frontend/src/components/send/WatermarkDialog.tsx index 4adc183..9409050 100644 --- a/frontend/src/components/send/WatermarkDialog.tsx +++ b/frontend/src/components/send/WatermarkDialog.tsx @@ -7,6 +7,7 @@ import { type WatermarkOptions, type WatermarkPosition, } from "../../lib/image-processing"; +import type { PreviewCandidate } from "../../stores/sender"; import Button from "../ui/Button"; import Dialog from "../ui/Dialog"; @@ -43,7 +44,13 @@ const POSITION_ORIGIN: Record = { }; const PREVIEW_MAX = 800; -const ZOOM_SCALE = 2.5; +/** プレビュー枠のアスペクト比 (aspect-video = 16:9)。cover 倍率と原点補正の基準 */ +const BOX_AR = 16 / 9; +/** ズーム倍率の上限。拡大しすぎてピクセルが粗くならないようクランプ */ +const MAX_ZOOM = 6; +/** 透かしサイズ比に反比例したズームの基準値。この比のとき size 由来ズーム=等倍。 + * これより小さい透かしほど大きく拡大して見やすくする (デフォルト 0.02 なら約3倍)。 */ +const REF_FONT_RATIO = 0.06; type Props = { open: boolean; @@ -53,11 +60,12 @@ type Props = { /** 透かしテキスト(通常は送信者名) */ text: string; /** - * プレビュー候補。先頭から順にレンダリングを試し、成功した最初の 1 枚をプレビューに使う。 - * 全て HEIC でも heic-to でフォールバック変換するので、最終的にどれか 1 枚が表示できれば OK。 - * 実体は IndexedDB から取り出した Blob を File に復元したもの (名前は HEIC 判定に使う)。 + * プレビュー候補。セレクタに並べ、初期は先頭から順に decode を試し最初に成功した + * 1 枚を自動表示する。HEIC は heic-to のフォールバックで decode する。 */ - previewFiles: File[]; + candidates: PreviewCandidate[]; + /** 候補の実体 File を IndexedDB から都度取り出すコールバック */ + getFile: (id: string, name: string, type: string) => Promise; }; export default function WatermarkDialog({ @@ -66,39 +74,70 @@ export default function WatermarkDialog({ options, onChange, text, - previewFiles, + candidates, + getFile, }: Props) { const canvasRef = useRef(null); const bitmapRef = useRef(null); const [previewError, setPreviewError] = useState(null); const [previewReady, setPreviewReady] = useState(false); const [previewLoading, setPreviewLoading] = useState(false); - /** 透かし位置を中心にズームするトグル。開いた直後は実寸感を確認しやすいよう拡大状態 */ - const [zoomed, setZoomed] = useState(true); + /** プレビュー対象の候補 id。null = 初期自動選択 (先頭から最初に decode 成功した候補) */ + const [selectedId, setSelectedId] = useState(null); + /** 読み込んだ画像のアスペクト比。cover 倍率と transform-origin 補正に使う */ + const [imgAR, setImgAR] = useState(null); + /** 透かし位置を中心にズームするトグル。開いた直後はまず全体表示 (OFF) */ + const [zoomed, setZoomed] = useState(false); + /** ズームボタンの「ズームできます」ヒント表示 (open ごとに最初の3秒だけ) */ + const [showZoomHint, setShowZoomHint] = useState(false); + const zoomHintShownRef = useRef(false); + /** 現在 bitmapRef に読み込み済みの候補 id。同一 id の再 decode を避ける */ + const loadedIdRef = useRef(null); - // ダイアログopen時に候補を順番に decode し、最初に成功したものをキャッシュ + // 閉じる時はステートをリセットして次回 open に持ち越さない useEffect(() => { - if (!open) { - setZoomed(true); - } - if (!open || previewFiles.length === 0) { - // 閉じる時 / 候補が空になった時は前回のエラー文言とキャッシュ bitmap も破棄して - // 次回 open 時にステートが混じらないようにする + if (open) return; + setSelectedId(null); + setImgAR(null); + setZoomed(false); + setPreviewReady(false); + setPreviewLoading(false); + setPreviewError(null); + setShowZoomHint(false); + zoomHintShownRef.current = false; + loadedIdRef.current = null; + bitmapRef.current?.close(); + bitmapRef.current = null; + }, [open]); + + // decode: selectedId===null なら候補を先頭から試し最初の成功を採用 (自動表示)。 + // selectedId 指定時はその 1 枚だけ decode (セレクタでの手動切替)。 + useEffect(() => { + if (!open) return; + if (candidates.length === 0) { setPreviewReady(false); setPreviewLoading(false); - setPreviewError(null); - bitmapRef.current?.close(); - bitmapRef.current = null; + setPreviewError("プレビューできる画像がありません"); return; } + // 自動採用で selectedId が確定した直後など、既に読み込み済みの候補なら再 decode しない + if (selectedId !== null && loadedIdRef.current === selectedId) return; + const queue = selectedId === null ? candidates : candidates.filter((c) => c.id === selectedId); + if (queue.length === 0) return; let cancelled = false; setPreviewError(null); setPreviewReady(false); setPreviewLoading(true); (async () => { - for (const file of previewFiles) { + for (const cand of queue) { if (cancelled) return; - const bmp = await tryLoadBitmap(file); + let bmp: ImageBitmap | null = null; + try { + const file = await getFile(cand.id, cand.name, cand.type); + bmp = await tryLoadBitmap(file); + } catch { + bmp = null; + } if (cancelled) { bmp?.close(); return; @@ -106,20 +145,37 @@ export default function WatermarkDialog({ if (bmp) { bitmapRef.current?.close(); bitmapRef.current = bmp; + loadedIdRef.current = cand.id; + setImgAR(bmp.width / bmp.height); setPreviewReady(true); setPreviewLoading(false); + // 自動採用時はセレクタのハイライトを合わせる + if (selectedId === null) setSelectedId(cand.id); return; } } if (!cancelled) { - setPreviewError("プレビューできる画像がありません"); + setPreviewError( + selectedId === null + ? "プレビューできる画像がありません" + : "この画像はプレビューできません", + ); setPreviewLoading(false); } })(); return () => { cancelled = true; }; - }, [open, previewFiles]); + }, [open, selectedId, candidates, getFile]); + + // プレビューが初めて表示できたら、最初の3秒だけズームのヒントを出す (open ごとに1回) + useEffect(() => { + if (!previewReady || zoomHintShownRef.current) return; + zoomHintShownRef.current = true; + setShowZoomHint(true); + const t = setTimeout(() => setShowZoomHint(false), 3000); + return () => clearTimeout(t); + }, [previewReady]); // unmount時のクリーンアップ useEffect(() => { @@ -150,6 +206,21 @@ export default function WatermarkDialog({ drawWatermark(ctx, w, h, text, options); }, [previewReady, options, text]); + // ① ズーム倍率: + // - cover: 固定 16:9 枠の余白 (レターボックス) を解消する下限倍率 (画像比から算出)。 + // - sizeZoom: 透かしサイズ比に反比例した倍率。小さい透かしほど大きく拡大して見やすくする。 + // 両者と等倍の最大を採り、上限でクランプ。overflow-hidden + 原点補正で透かし周辺を拡大する。 + const cover = imgAR ? Math.max(imgAR / BOX_AR, BOX_AR / imgAR) : 1; + const sizeZoom = REF_FONT_RATIO / options.fontSizeRatio; + const zoomScale = Math.min(Math.max(cover, sizeZoom, 1), MAX_ZOOM); + // transform-origin 補正: object-contain のレターボックス分を考慮し、透かし位置を + // 画像コンテンツ矩形へマッピングする (縦長画像で余白の角を指してずれるのを防ぐ)。 + const fracW = imgAR ? (imgAR >= BOX_AR ? 1 : imgAR / BOX_AR) : 1; + const fracH = imgAR ? (imgAR >= BOX_AR ? BOX_AR / imgAR : 1) : 1; + const posOrigin = POSITION_ORIGIN[options.position]; + const originX = (1 - fracW) * 50 + posOrigin.x * fracW; + const originY = (1 - fracH) * 50 + posOrigin.y * fracH; + return (
- {previewFiles.length > 0 && !previewError ? ( + {!previewError ? ( <> @@ -186,26 +255,61 @@ export default function WatermarkDialog({
)} {previewReady && ( - + <> + + {showZoomHint && !zoomed && ( +
+ ズームできます +
+ )} + )} ) : ( -

- {previewError ?? "プレビュー用の画像がありません"} -

+

{previewError}

)}
+ {candidates.length > 1 && ( +
+ {candidates.map((c) => { + const active = c.id === selectedId; + return ( + + ); + })} +
+ )} + {!text && (

送信者名が未入力です。透かしには送信者名が使われます @@ -277,7 +381,7 @@ export default function WatermarkDialog({ return (

{children}
+
{children}
{footer && (
{footer}
)} diff --git a/frontend/src/pages/send/UploadPage.tsx b/frontend/src/pages/send/UploadPage.tsx index 964adcf..ad4195d 100644 --- a/frontend/src/pages/send/UploadPage.tsx +++ b/frontend/src/pages/send/UploadPage.tsx @@ -1,5 +1,13 @@ import { useAtom } from "jotai"; -import { type ChangeEvent, type DragEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + type ChangeEvent, + type DragEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import SenderAtmosphere from "../../components/send/SenderAtmosphere"; import WatermarkDialog from "../../components/send/WatermarkDialog"; @@ -19,7 +27,12 @@ import { } from "../../lib/image-processing"; import { clearAllPhotos, deletePhotos, getPhoto, putPhoto } from "../../lib/photo-store"; import { withKey } from "../../lib/send-url"; -import { type SelectedFile, selectedFilesAtom, uploadFormAtom } from "../../stores/sender"; +import { + type PreviewCandidate, + type SelectedFile, + selectedFilesAtom, + uploadFormAtom, +} from "../../stores/sender"; const CREDIT_FORMAT_OPTIONS: { value: CreditFormat; label: string }[] = [ { value: "shot_by", label: CREDIT_FORMATS.shot_by.label }, @@ -68,15 +81,22 @@ function isAccepted(file: File): boolean { return ACCEPTED_EXTS.test(file.name); } -// 透かしプレビュー候補の最大枚数。実体は IndexedDB から取り出すため、メモリを抑える目的で -// 少数に絞る。WatermarkDialog が先頭から順に decode を試し、最初に成功した 1 枚を使う。 -const PREVIEW_CANDIDATE_LIMIT = 6; - -/** 透かしプレビュー候補を選ぶ。非HEICを優先し、上限枚数で打ち切る */ -function pickPreviewCandidates(files: SelectedFile[]): SelectedFile[] { - const nonHeic = files.filter((f) => !isHeic(f.file)); - const heic = files.filter((f) => isHeic(f.file)); - return [...nonHeic, ...heic].slice(0, PREVIEW_CANDIDATE_LIMIT); +/** + * 透かしプレビュー候補を作る。非HEIC を先頭に並べ替え、ダイアログの初期自動選択が + * decode の軽い非HEIC から始まるようにする。サムネは生成済みの ObjectURL を渡すだけ。 + * 実体 (File) はダイアログ側が選択時に getFile で都度取り出す。 + */ +function buildPreviewCandidates(files: SelectedFile[]): PreviewCandidate[] { + const toCandidate = (f: SelectedFile): PreviewCandidate => ({ + id: f.id, + name: f.file.name, + type: f.file.type, + thumbUrl: f.previewUrl, + isHeic: isHeic(f.file), + }); + const nonHeic = files.filter((f) => !isHeic(f.file)).map(toCandidate); + const heic = files.filter((f) => isHeic(f.file)).map(toCandidate); + return [...nonHeic, ...heic]; } export default function UploadPage() { @@ -274,36 +294,18 @@ export default function UploadPage() { }); }, [hasSenderName, exifMode, watermarkMode, setForm]); - // 透かしダイアログのライブプレビュー用。実体は IndexedDB にあるので、ダイアログを - // 開いている間だけ候補数枚を取り出し、名前を保持するため File に復元して渡す。 - // id 集合のシグネチャで identity を追跡し、別タイルのプレビュー生成完了による - // 再 fetch を避ける。 - const previewFilesKey = files.map((f) => f.id).join("|"); - // biome-ignore lint/correctness/useExhaustiveDependencies: previewFilesKey が files の identity を代表する - const previewCandidates = useMemo(() => pickPreviewCandidates(files), [previewFilesKey]); - const [previewFiles, setPreviewFiles] = useState([]); - useEffect(() => { - if (!watermarkDialogOpen) { - setPreviewFiles([]); - return; - } - let cancelled = false; - void (async () => { - const out: File[] = []; - for (const c of previewCandidates) { - try { - const blob = await getPhoto(c.id); - out.push(new File([blob], c.file.name, { type: c.file.type })); - } catch { - // 取り出せない候補はスキップし、残りの候補でプレビューを試みる - } - } - if (!cancelled) setPreviewFiles(out); - })(); - return () => { - cancelled = true; - }; - }, [watermarkDialogOpen, previewCandidates]); + // 透かしダイアログのライブプレビュー用候補。サムネ ObjectURL を渡すだけで実体は持たない。 + // id とサムネ生成状態(空→URL)の両方をシグネチャに含め、サムネ完了が候補に反映される + // ようにする (含めないと未生成扱いのまま HEIC プレースホルダ等に誤表示される)。 + const previewFilesKey = files.map((f) => `${f.id}:${f.previewUrl ? 1 : 0}`).join("|"); + // biome-ignore lint/correctness/useExhaustiveDependencies: previewFilesKey が files の identity とサムネ状態を代表する + const previewCandidates = useMemo(() => buildPreviewCandidates(files), [previewFilesKey]); + // ダイアログが選択時に実体 File を取り出すためのコールバック。IndexedDB から都度復元する。 + const getPreviewFile = useCallback( + async (id: string, name: string, type: string): Promise => + new File([await getPhoto(id)], name, { type }), + [], + ); const renderDropZone = (compact: boolean) => (