From 102fd966c499bcd704bac16aeade90a08312b5f1 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Tue, 16 Jun 2026 02:09:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(sender):=20=E9=80=8F=E3=81=8B=E3=81=97?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=82=92=E6=8B=A1?= =?UTF-8?q?=E5=BC=B5=20(=E3=82=B5=E3=82=A4=E3=82=BA=E5=8F=8D=E6=AF=94?= =?UTF-8?q?=E4=BE=8B=E3=82=BA=E3=83=BC=E3=83=A0=20/=20=E7=94=BB=E5=83=8F?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=20/=20=E3=83=AC=E3=82=A4=E3=82=A2=E3=82=A6?= =?UTF-8?q?=E3=83=88=E4=BF=AE=E6=AD=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 送信アップロード画面の透かし設定ダイアログのプレビューを改善する。 - ズーム倍率を固定 2.5 倍から動的算出に変更。cover (16:9 枠の余白を解消する 下限) と、透かしサイズ比に反比例した倍率の大きい方を採り上限 6 倍でクランプ。 小さい透かしほど大きく拡大して見やすくする。あわせて object-contain の レターボックスを考慮した transform-origin 補正で縦長画像の位置ずれを解消 - プレビュー対象を複数候補から選べるセレクタを追加。初期はズーム OFF (全体表示) で自動表示し、ズームボタンに最初の 3 秒だけ「ズームできます」を表示。候補の 実体 File は選択時に IndexedDB から都度取り出す (メモリ常駐を回避) - HEIC も候補に含める (heic-to フォールバックで decode、サムネ無しは プレースホルダ表示)。サムネ生成完了が候補に反映されるよう memo キーを修正 - Dialog のスクロール領域に min-h-0 を追加し、コンテンツが多い場合に内部 スクロールが効くよう修正。ヘッダーが枠外に突き出る不具合を解消 - フォントセグメントの行高を固定 (h-9 / leading-none) し、フォント解決時の 高さ変動を防止 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/send/WatermarkDialog.tsx | 188 ++++++++++++++---- frontend/src/components/ui/Dialog.tsx | 2 +- frontend/src/pages/send/UploadPage.tsx | 87 ++++---- frontend/src/stores/sender.ts | 14 ++ 4 files changed, 206 insertions(+), 85 deletions(-) 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) => (