diff --git a/frontend/src/App.css b/frontend/src/App.css index d661175..2c7670b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -246,7 +246,8 @@ body { text-align: left; } -.input-group input { +.input-group input, +.input-group select { background-color: var(--input-bg); border: 1px solid var(--input-border); color: var(--text-primary); @@ -262,7 +263,18 @@ body { -webkit-user-select: text; } -.input-group input:focus { +.input-group select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239aa0a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.7rem center; + background-size: 1em; + padding-right: 2rem; +} + +.input-group input:focus, +.input-group select:focus { border-color: var(--accent-color); background-color: #252525; } @@ -311,4 +323,50 @@ body { opacity: 0; transform: translateY(-10px); } -} \ No newline at end of file +} +/* Segmented Control */ +.segmented-control { + display: flex; + background-color: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 6px; + padding: 2px; +} + +.segmented-control .segment { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: transparent; + border: none; + color: var(--text-secondary); + padding: 0.4rem 0; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + border-radius: 4px; + transition: all 0.2s ease; + font-family: inherit; + --wails-draggable: no-drag; +} + +.segmented-control .segment:hover { + color: var(--text-primary); +} + +.segmented-control .segment.active { + background-color: #3e3e42; + color: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.segmented-control .segment svg { + opacity: 0.8; +} + +.segmented-control.disabled { + opacity: 0.5; + pointer-events: none; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f278baa..d9fbea0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import './App.css'; import { OpenImage, SaveImage } from '../wailsjs/go/main/App'; import { WindowToggleMaximise, Environment } from '../wailsjs/runtime/runtime'; @@ -36,6 +36,12 @@ function App() { const toastTimerRef = useRef(null); const toastRafRef = useRef(null); + const [aspectRatioPreset, setAspectRatioPreset] = useState("4300:3618"); + const [customRatioW, setCustomRatioW] = useState(4300); + const [customRatioH, setCustomRatioH] = useState(3618); + const [orientation, setOrientation] = useState<"landscape" | "portrait">("landscape"); + const [alignment, setAlignment] = useState<"top" | "center">("top"); + const showToast = (message: string) => { if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); @@ -117,6 +123,7 @@ function App() { setSourceMimeType(result.mimeType || ""); setImageObj(img); + setOrientation(img.height > img.width ? "portrait" : "landscape"); setImageLoaded(true); resolve(); }; @@ -135,25 +142,58 @@ function App() { } }; - useEffect(() => { - if (!imageObj || !canvasRef.current) return; - drawCanvas(imageObj); - }, [imageObj, exif]); - const drawCanvas = (img: HTMLImageElement) => { + const drawCanvas = useCallback((img: HTMLImageElement) => { const canvas = canvasRef.current; if (!canvas) return; - // 好みの左右・上の枠の太さ(例:幅の3.5%) - const framePadding = Math.floor(img.width * 0.025); + // 好みの左右・上の枠の最小太さ(例:幅の2.5%) + const minFramePadding = Math.floor(img.width * 0.025); + // 下部のテキスト領域に必要な最小スペース + const minBottomSpace = Math.floor(minFramePadding * 4.5); + + let targetRatio = 4300 / 3618; + if (aspectRatioPreset === "custom") { + if (customRatioW > 0 && customRatioH > 0) { + targetRatio = customRatioW / customRatioH; + } else { + targetRatio = img.width / img.height; + } + } else { + const [w, h] = aspectRatioPreset.split(':').map(Number); + if (w && h) targetRatio = w / h; + } + + // Apply orientation flip + if (orientation === "portrait" && targetRatio > 1) { + targetRatio = 1 / targetRatio; + } else if (orientation === "landscape" && targetRatio < 1) { + targetRatio = 1 / targetRatio; + } + + const minCanvasWidth = img.width + (minFramePadding * 2); + const minCanvasHeight = img.height + minFramePadding + minBottomSpace; - // 全体の仕上がりを 4300 : 3618 の比率に強制する - const targetRatio = 4300 / 3618; + // まず幅を基準に高さを計算 + let finalCanvasWidth = minCanvasWidth; + let finalCanvasHeight = Math.floor(finalCanvasWidth / targetRatio); + + if (finalCanvasHeight < minCanvasHeight) { + // 高さが足りない場合は、最小の高さを基準にして幅を拡張 + finalCanvasHeight = minCanvasHeight; + finalCanvasWidth = Math.floor(finalCanvasHeight * targetRatio); + } - // 完成品の幅を先に決め、ターゲット比率から高さを逆算する // ⚠️ CRITICAL: Must be set BEFORE getContext, otherwise context properties (colorSpace) are reset! - canvas.width = img.width + (framePadding * 2); - canvas.height = Math.floor(canvas.width / targetRatio); + canvas.width = finalCanvasWidth; + canvas.height = finalCanvasHeight; + + // 余分な高さを計算 + const extraHeight = finalCanvasHeight - minCanvasHeight; + + // 画像の配置位置を計算 (左右中央、上固定または上下中央) + const drawX = Math.floor((finalCanvasWidth - img.width) / 2); + const drawY = alignment === "center" ? minFramePadding + Math.floor(extraHeight / 2) : minFramePadding; // Enable P3 wide-gamut mode to prevent high-saturation color loss, with a fallback let ctx: CanvasRenderingContext2D | null = null; @@ -167,21 +207,20 @@ function App() { } if (!ctx) return; - // 完成品の高さから「下の余白(margin)」を逆算 - const margin = canvas.height - img.height - (framePadding * 2); - // Fill background ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw image - ctx.drawImage(img, framePadding, framePadding); + ctx.drawImage(img, drawX, drawY); - // 写真の下端からキャンバスの下端までの「目に見える下の白枠すべて」の高さ - const bottomSpaceHeight = margin + framePadding; + // 画像の下端座標 + const imgBottomY = drawY + img.height; + // 写真の下端からキャンバスの下端までの余白 + const bottomSpaceHeight = canvas.height - imgBottomY; - // 本当の視覚的な中央座標を計算 - const textY = img.height + framePadding + (bottomSpaceHeight / 2); + // テキストの配置Y座標は、画像の下端とキャンバス下端の中央 + const textY = imgBottomY + (bottomSpaceHeight / 2); // テキストのサイズを(marginではなく)画像自体のサイズを基準にする const baseScale = Math.min(img.width, img.height); @@ -214,12 +253,18 @@ function App() { // Draw a subtle line separator (just above the text) ctx.beginPath(); - ctx.moveTo(canvas.width * 0.2, img.height + framePadding); - ctx.lineTo(canvas.width * 0.8, img.height + framePadding); + ctx.moveTo(canvas.width * 0.2, imgBottomY); + ctx.lineTo(canvas.width * 0.8, imgBottomY); ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = Math.max(1, Math.floor(baseScale * 0.0015)); ctx.stroke(); - }; + }, [exif, aspectRatioPreset, customRatioW, customRatioH, orientation, alignment]); + + useEffect(() => { + if (!imageObj || !canvasRef.current) return; + + drawCanvas(imageObj); + }, [imageObj, drawCanvas]); const downloadImage = async () => { if (!canvasRef.current || !imageObj) return; @@ -353,7 +398,89 @@ function App() { {imageLoaded && (