diff --git a/packages/web/src/common/__tests__/format-timestamp.spec.ts b/packages/web/src/common/__tests__/format-timestamp.spec.ts new file mode 100644 index 0000000..d801f42 --- /dev/null +++ b/packages/web/src/common/__tests__/format-timestamp.spec.ts @@ -0,0 +1,12 @@ +import { formatTimestampForFilename } from "~/common/format-timestamp"; + +describe("format-timestamp", () => { + it("formats as YYYYMMDD-HHmmss with zero padding", () => { + expect(formatTimestampForFilename(new Date(2026, 0, 5, 3, 7, 9))).toBe( + "20260105-030709" + ); + expect(formatTimestampForFilename(new Date(2026, 11, 31, 23, 59, 59))).toBe( + "20261231-235959" + ); + }); +}); diff --git a/packages/web/src/common/__tests__/image-split.spec.ts b/packages/web/src/common/__tests__/image-split.spec.ts new file mode 100644 index 0000000..b332079 --- /dev/null +++ b/packages/web/src/common/__tests__/image-split.spec.ts @@ -0,0 +1,145 @@ +import { + clampRect, + clampSplitCount, + fitRectToAspect, + nearestCorner, + resizeRectFromCorner, + resizeRectFromCornerLocked, + splitRectIntoColumns +} from "~/common/image-split"; + +describe("image-split", () => { + const bounds = { width: 100, height: 200 }; + + it("clampRect keeps a rect fully inside the bounds", () => { + expect(clampRect({ x: -10, y: -10, width: 50, height: 50 }, bounds)).toEqual({ + x: 0, + y: 0, + width: 50, + height: 50 + }); + expect( + clampRect({ x: 80, y: 190, width: 50, height: 50 }, bounds) + ).toEqual({ x: 50, y: 150, width: 50, height: 50 }); + expect( + clampRect({ x: 0, y: 0, width: 1000, height: 1000 }, bounds) + ).toEqual({ x: 0, y: 0, width: 100, height: 200 }); + }); + + it("resizeRectFromCorner grows/shrinks from the dragged corner", () => { + const rect = { x: 20, y: 20, width: 30, height: 30 }; + expect(resizeRectFromCorner(rect, "se", 10, 10, bounds)).toEqual({ + x: 20, + y: 20, + width: 40, + height: 40 + }); + expect(resizeRectFromCorner(rect, "nw", -10, -10, bounds)).toEqual({ + x: 10, + y: 10, + width: 40, + height: 40 + }); + }); + + it("resizeRectFromCorner respects minSize", () => { + const rect = { x: 20, y: 20, width: 30, height: 30 }; + const next = resizeRectFromCorner(rect, "se", -100, -100, bounds, 20); + expect(next.width).toBe(20); + expect(next.height).toBe(20); + }); + + it("resizeRectFromCornerLocked keeps the target aspect and follows the larger drag axis", () => { + const rect = { x: 20, y: 20, width: 30, height: 30 }; + expect(resizeRectFromCornerLocked(rect, "se", 20, 5, bounds, 1)).toEqual({ + x: 20, + y: 20, + width: 50, + height: 50 + }); + }); + + it("resizeRectFromCornerLocked clamps to bounds while keeping the aspect", () => { + const rect = { x: 20, y: 20, width: 30, height: 30 }; + expect( + resizeRectFromCornerLocked(rect, "se", 90, 90, bounds, 1) + ).toEqual({ x: 20, y: 20, width: 80, height: 80 }); + + const narrowBounds = { width: 200, height: 100 }; + const rect2 = { x: 0, y: 0, width: 50, height: 50 }; + expect( + resizeRectFromCornerLocked(rect2, "se", 200, 10, narrowBounds, 1) + ).toEqual({ x: 0, y: 0, width: 100, height: 100 }); + }); + + it("fitRectToAspect inscribes the target aspect centered in the current rect", () => { + expect( + fitRectToAspect({ x: 10, y: 10, width: 80, height: 40 }, 1, { + width: 200, + height: 200 + }) + ).toEqual({ x: 30, y: 10, width: 40, height: 40 }); + expect( + fitRectToAspect({ x: 0, y: 0, width: 30, height: 80 }, 1, { + width: 200, + height: 200 + }) + ).toEqual({ x: 0, y: 25, width: 30, height: 30 }); + }); + + it("nearestCorner returns whichever corner is closest to the point, regardless of handle size", () => { + const rect = { x: 10, y: 10, width: 40, height: 20 }; + expect(nearestCorner(rect, { x: 0, y: 0 })).toBe("nw"); + expect(nearestCorner(rect, { x: 60, y: 0 })).toBe("ne"); + expect(nearestCorner(rect, { x: 0, y: 40 })).toBe("sw"); + expect(nearestCorner(rect, { x: 60, y: 40 })).toBe("se"); + // ハンドルの見た目のサイズを超えて離れた点でも、一番近い角に丸められる + expect(nearestCorner(rect, { x: 35, y: 25 })).toBe("se"); + }); + + it("clampSplitCount keeps the value within [2, 8] and rounds to an integer", () => { + expect(clampSplitCount(3)).toBe(3); + expect(clampSplitCount(1)).toBe(2); + expect(clampSplitCount(0)).toBe(2); + expect(clampSplitCount(-5)).toBe(2); + expect(clampSplitCount(9)).toBe(8); + expect(clampSplitCount(100)).toBe(8); + expect(clampSplitCount(3.6)).toBe(4); + }); + + it("splitRectIntoColumns divides width evenly and gives the remainder to the last column", () => { + expect( + splitRectIntoColumns({ x: 0, y: 0, width: 90, height: 60 }, 3) + ).toEqual([ + { x: 0, y: 0, width: 30, height: 60 }, + { x: 30, y: 0, width: 30, height: 60 }, + { x: 60, y: 0, width: 30, height: 60 } + ]); + expect( + splitRectIntoColumns({ x: 5, y: 0, width: 100, height: 60 }, 3) + ).toEqual([ + { x: 5, y: 0, width: 33, height: 60 }, + { x: 38, y: 0, width: 33, height: 60 }, + { x: 71, y: 0, width: 34, height: 60 } + ]); + }); + + it("splitRectIntoColumns works for any column count in the supported 2-6 range", () => { + expect( + splitRectIntoColumns({ x: 0, y: 0, width: 100, height: 60 }, 2) + ).toEqual([ + { x: 0, y: 0, width: 50, height: 60 }, + { x: 50, y: 0, width: 50, height: 60 } + ]); + expect( + splitRectIntoColumns({ x: 0, y: 0, width: 100, height: 60 }, 6) + ).toEqual([ + { x: 0, y: 0, width: 16, height: 60 }, + { x: 16, y: 0, width: 16, height: 60 }, + { x: 32, y: 0, width: 16, height: 60 }, + { x: 48, y: 0, width: 16, height: 60 }, + { x: 64, y: 0, width: 16, height: 60 }, + { x: 80, y: 0, width: 20, height: 60 } + ]); + }); +}); diff --git a/packages/web/src/common/format-timestamp.ts b/packages/web/src/common/format-timestamp.ts new file mode 100644 index 0000000..ddb2821 --- /dev/null +++ b/packages/web/src/common/format-timestamp.ts @@ -0,0 +1,14 @@ +const pad2 = (n: number) => String(n).padStart(2, "0"); + +// ファイル名に使える形式(コロンなどを含まない)で "YYYYMMDD-HHmmss" に整形する +export const formatTimestampForFilename = (date: Date): string => { + const datePart = [ + date.getFullYear(), + pad2(date.getMonth() + 1), + pad2(date.getDate()) + ].join(""); + const timePart = [date.getHours(), date.getMinutes(), date.getSeconds()] + .map(pad2) + .join(""); + return `${datePart}-${timePart}`; +}; diff --git a/packages/web/src/common/image-split.ts b/packages/web/src/common/image-split.ts new file mode 100644 index 0000000..4174182 --- /dev/null +++ b/packages/web/src/common/image-split.ts @@ -0,0 +1,139 @@ +export type Rect = { x: number; y: number; width: number; height: number }; + +export type Size = { width: number; height: number }; + +export type CropCorner = "nw" | "ne" | "sw" | "se"; + +const clampNum = (n: number, min: number, max: number) => + Math.min(Math.max(n, min), max); + +export const MIN_SPLIT_COUNT = 2; +export const MAX_SPLIT_COUNT = 8; + +export const clampSplitCount = (value: number): number => + clampNum(Math.round(value), MIN_SPLIT_COUNT, MAX_SPLIT_COUNT); + +export const clampRect = (rect: Rect, bounds: Size): Rect => { + const width = clampNum(rect.width, 1, bounds.width); + const height = clampNum(rect.height, 1, bounds.height); + const x = clampNum(rect.x, 0, bounds.width - width); + const y = clampNum(rect.y, 0, bounds.height - height); + return { x, y, width, height }; +}; + +// ドラッグ中のハンドルと対角のコーナーを固定点として、動かした側のコーナーを +// bounds と最小サイズでクランプしてから矩形を再構築する +export const resizeRectFromCorner = ( + rect: Rect, + corner: CropCorner, + dx: number, + dy: number, + bounds: Size, + minSize: number = 20 +): Rect => { + const isNorth = corner[0] === "n"; + const isWest = corner[1] === "w"; + const anchorX = isWest ? rect.x + rect.width : rect.x; + const anchorY = isNorth ? rect.y + rect.height : rect.y; + const draggedX = isWest ? rect.x : rect.x + rect.width; + const draggedY = isNorth ? rect.y : rect.y + rect.height; + const nextDraggedX = isWest + ? clampNum(draggedX + dx, 0, anchorX - minSize) + : clampNum(draggedX + dx, anchorX + minSize, bounds.width); + const nextDraggedY = isNorth + ? clampNum(draggedY + dy, 0, anchorY - minSize) + : clampNum(draggedY + dy, anchorY + minSize, bounds.height); + return { + x: Math.min(anchorX, nextDraggedX), + y: Math.min(anchorY, nextDraggedY), + width: Math.abs(anchorX - nextDraggedX), + height: Math.abs(anchorY - nextDraggedY) + }; +}; + +// 対角のコーナーを固定点として、指定した aspect(width / height)を保ったまま +// ドラッグ量が大きい方の軸を基準にリサイズする +export const resizeRectFromCornerLocked = ( + rect: Rect, + corner: CropCorner, + dx: number, + dy: number, + bounds: Size, + aspect: number, + minSize: number = 20 +): Rect => { + const isNorth = corner[0] === "n"; + const isWest = corner[1] === "w"; + const anchorX = isWest ? rect.x + rect.width : rect.x; + const anchorY = isNorth ? rect.y + rect.height : rect.y; + const spaceX = isWest ? anchorX : bounds.width - anchorX; + const spaceY = isNorth ? anchorY : bounds.height - anchorY; + const maxWidth = Math.min(spaceX, spaceY * aspect); + + const desiredWidth = isWest ? rect.width - dx : rect.width + dx; + const desiredHeight = isNorth ? rect.height - dy : rect.height + dy; + const driveByWidth = + Math.abs(desiredWidth / rect.width - 1) >= + Math.abs(desiredHeight / rect.height - 1); + const candidateWidth = driveByWidth ? desiredWidth : desiredHeight * aspect; + + const width = clampNum(candidateWidth, Math.min(minSize, maxWidth), maxWidth); + const height = width / aspect; + return { + x: isWest ? anchorX - width : anchorX, + y: isNorth ? anchorY - height : anchorY, + width, + height + }; +}; + +// 現在の矩形の中心を保ったまま、その内側に収まる最大の aspect 矩形を返す +export const fitRectToAspect = ( + rect: Rect, + aspect: number, + bounds: Size +): Rect => { + const isWiderThanAspect = rect.width / rect.height > aspect; + const width = isWiderThanAspect ? rect.height * aspect : rect.width; + const height = isWiderThanAspect ? rect.height : rect.width / aspect; + return clampRect( + { + x: rect.x + (rect.width - width) / 2, + y: rect.y + (rect.height - height) / 2, + width, + height + }, + bounds + ); +}; + +// 矩形の四隅のうち、指定した点にもっとも近いものを返す。 +// ハンドルの見た目のサイズに関わらず、タップした位置から一番近い角が +// 必ず操作対象になるようにするために使う +export const nearestCorner = ( + rect: Rect, + point: { x: number; y: number } +): CropCorner => { + const corners: { corner: CropCorner; x: number; y: number }[] = [ + { corner: "nw", x: rect.x, y: rect.y }, + { corner: "ne", x: rect.x + rect.width, y: rect.y }, + { corner: "sw", x: rect.x, y: rect.y + rect.height }, + { corner: "se", x: rect.x + rect.width, y: rect.y + rect.height } + ]; + return corners.reduce((nearest, candidate) => { + const distanceTo = (c: { x: number; y: number }) => + (c.x - point.x) ** 2 + (c.y - point.y) ** 2; + return distanceTo(candidate) < distanceTo(nearest) ? candidate : nearest; + }).corner; +}; + +export const splitRectIntoColumns = (rect: Rect, columns: number): Rect[] => { + const baseWidth = Math.floor(rect.width / columns); + return Array.from({ length: columns }, (_, i) => ({ + x: rect.x + baseWidth * i, + y: rect.y, + width: + i === columns - 1 ? rect.width - baseWidth * (columns - 1) : baseWidth, + height: rect.height + })); +}; diff --git a/packages/web/src/component/ImageSplitResultScene.tsx b/packages/web/src/component/ImageSplitResultScene.tsx new file mode 100644 index 0000000..80c8831 --- /dev/null +++ b/packages/web/src/component/ImageSplitResultScene.tsx @@ -0,0 +1,112 @@ +import styled from "@emotion/styled"; +import { useMemo } from "react"; +import { em, percent, px } from "~/common/css-util"; +import MockActionButton from "~/component/MockActionButton"; + +export type SplitImage = { url: string; blob: Blob }; + +export const OUTPUT_MIME_TYPE = "image/jpeg"; +export const OUTPUT_EXTENSION = "jpg"; +export const OUTPUT_QUALITY = 0.92; + +const ResultGrid = styled.div({ + display: "flex", + gap: px(4) +}); + +const ResultCell = styled.div({ + flex: "1 1 0", + minWidth: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: em(0.5) +}); + +const ShareRow = styled.div({ + display: "flex", + justifyContent: "center" +}); + +const BackRow = styled.div({ + display: "flex", + justifyContent: "center" +}); + +const buildFileName = (index: number, timestamp: string) => + [`split-${index + 1}`, timestamp].filter(Boolean).join("-") + + `.${OUTPUT_EXTENSION}`; + +const ImageSplitResultScene = ({ + columnImages, + timestamp, + onBack +}: { + columnImages: SplitImage[]; + timestamp: string; + onBack: () => void; +}) => { + const shareFiles = useMemo( + () => + columnImages.map( + (image, i) => + new File([image.blob], buildFileName(i, timestamp), { + type: OUTPUT_MIME_TYPE + }) + ), + [columnImages, timestamp] + ); + + // Web Share API (files) は未対応ブラウザも多いため、対応している場合のみ + // 「まとめて共有」を出し、非対応時は個別のダウンロードのみにフォールバックする + const canShareAll = + typeof navigator !== "undefined" && + !!navigator.canShare?.({ files: shareFiles }); + + const handleShareAll = () => { + navigator + .share({ files: shareFiles }) + .catch(() => { + // ユーザーによるキャンセルなどは無視する + }); + }; + + return ( + <> + + {columnImages.map((image, i) => ( + + {`分割画像 + + 画像{i + 1}を保存 + + + ))} + + {canShareAll ? ( + + + まとめて共有 + + + ) : null} + + + もどる + + + + ); +}; + +export default ImageSplitResultScene; diff --git a/packages/web/src/component/ImageSplitterScene.tsx b/packages/web/src/component/ImageSplitterScene.tsx new file mode 100644 index 0000000..38e141d --- /dev/null +++ b/packages/web/src/component/ImageSplitterScene.tsx @@ -0,0 +1,267 @@ +import styled from "@emotion/styled"; +import { + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState +} from "react"; +import { + type CropCorner, + type Rect, + type Size, + clampSplitCount, + fitRectToAspect, + nearestCorner, + resizeRectFromCorner, + resizeRectFromCornerLocked, + splitRectIntoColumns +} from "~/common/image-split"; +import { em, px } from "~/common/css-util"; +import { formatTimestampForFilename } from "~/common/format-timestamp"; +import ImageSplitResultScene, { + type SplitImage, + OUTPUT_MIME_TYPE, + OUTPUT_QUALITY +} from "~/component/ImageSplitResultScene"; +import ImageTrimScene from "~/component/ImageTrimScene"; + +const DEFAULT_SPLIT_COUNT = 3; + +// toDataURL は画像バイト列を base64 文字列化して React state / DOM に +// そのまま保持することになり、写真サイズだと数MB〜のメモリを圧迫する。 +// toBlob + object URL ならバイナリのまま保持でき、参照する URL 文字列も短い。 +// Blob 自体も保持しておくことで、まとめて共有(Web Share API)の際に +// File を作り直せるようにする +const canvasToSplitImage = ( + canvas: HTMLCanvasElement +): Promise => + new Promise(resolve => { + canvas.toBlob( + blob => { + resolve(blob ? { url: URL.createObjectURL(blob), blob } : null); + }, + OUTPUT_MIME_TYPE, + OUTPUT_QUALITY + ); + }); + +const Wrapper = styled.div({ + margin: "auto", + maxWidth: px(720), + padding: em(1), + display: "flex", + flexDirection: "column", + gap: em(1) +}); + +const TitleLine = styled.div({ + fontWeight: "bold", + fontSize: em(1.2) +}); + +const ImageSplitterScene = () => { + const [imageUrl, setImageUrl] = useState(""); + const [naturalSize, setNaturalSize] = useState(null); + const [cropRect, setCropRect] = useState(null); + const [columnImages, setColumnImages] = useState(null); + const [activeCorner, setActiveCorner] = useState(null); + const [aspectLocked, setAspectLocked] = useState(false); + const [isSplitting, setIsSplitting] = useState(false); + const [splitCount, setSplitCount] = useState(DEFAULT_SPLIT_COUNT); + const [splitTimestamp, setSplitTimestamp] = useState(""); + + const imgRef = useRef(null); + const lastPointerRef = useRef<{ x: number; y: number } | null>(null); + + useEffect( + () => () => { + if (imageUrl) { + URL.revokeObjectURL(imageUrl); + } + }, + [imageUrl] + ); + + useEffect( + () => () => { + columnImages?.forEach(image => URL.revokeObjectURL(image.url)); + }, + [columnImages] + ); + + useEffect(() => { + if (!activeCorner || !naturalSize) { + return undefined; + } + const handlePointerMove = (e: PointerEvent) => { + const last = lastPointerRef.current; + const img = imgRef.current; + if (!last || !img || !img.clientWidth) { + return; + } + const scale = naturalSize.width / img.clientWidth; + const dx = (e.clientX - last.x) * scale; + const dy = (e.clientY - last.y) * scale; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + setCropRect(rect => { + if (!rect) { + return rect; + } + return aspectLocked + ? resizeRectFromCornerLocked( + rect, + activeCorner, + dx, + dy, + naturalSize, + naturalSize.width / naturalSize.height + ) + : resizeRectFromCorner(rect, activeCorner, dx, dy, naturalSize); + }); + }; + const handlePointerUp = () => { + lastPointerRef.current = null; + setActiveCorner(null); + }; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [activeCorner, aspectLocked, naturalSize]); + + const handleFileChange = (files: File[]) => { + const nextFile = files[0]; + if (!nextFile) { + return; + } + setNaturalSize(null); + setCropRect(null); + setColumnImages(null); + setImageUrl(URL.createObjectURL(nextFile)); + }; + + const handleImageLoad = (size: Size) => { + setNaturalSize(size); + setCropRect({ x: 0, y: 0, ...size }); + setColumnImages(null); + }; + + const handleStagePointerDown = (e: ReactPointerEvent) => { + const img = imgRef.current; + if (!img || !cropRect || !naturalSize) { + return; + } + const imgRect = img.getBoundingClientRect(); + const scale = naturalSize.width / imgRect.width; + const point = { + x: (e.clientX - imgRect.left) * scale, + y: (e.clientY - imgRect.top) * scale + }; + e.preventDefault(); + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + setActiveCorner(nearestCorner(cropRect, point)); + }; + + const handleResetCrop = () => { + if (!naturalSize) { + return; + } + setCropRect({ x: 0, y: 0, ...naturalSize }); + setColumnImages(null); + }; + + const handleAspectLockedChange = (next: boolean) => { + setAspectLocked(next); + if (next && naturalSize) { + setCropRect(rect => + rect + ? fitRectToAspect( + rect, + naturalSize.width / naturalSize.height, + naturalSize + ) + : rect + ); + setColumnImages(null); + } + }; + + const handleSplitCountChange = (next: number) => { + setSplitCount(clampSplitCount(next)); + }; + + const handleSplit = async () => { + const img = imgRef.current; + if (!img || !cropRect || isSplitting) { + return; + } + setIsSplitting(true); + try { + const images = await Promise.all( + splitRectIntoColumns(cropRect, splitCount).map(col => { + const canvas = document.createElement("canvas"); + canvas.width = col.width; + canvas.height = col.height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return Promise.resolve(null); + } + ctx.drawImage( + img, + col.x, + col.y, + col.width, + col.height, + 0, + 0, + col.width, + col.height + ); + return canvasToSplitImage(canvas); + }) + ); + setSplitTimestamp(formatTimestampForFilename(new Date())); + setColumnImages(images.filter((image): image is SplitImage => !!image)); + } finally { + setIsSplitting(false); + } + }; + + const handleBack = () => { + setColumnImages(null); + }; + + return ( + + 画像分割ツール + {columnImages ? ( + + ) : ( + + )} + + ); +}; + +export default ImageSplitterScene; diff --git a/packages/web/src/component/ImageTrimScene.tsx b/packages/web/src/component/ImageTrimScene.tsx new file mode 100644 index 0000000..c0ffce7 --- /dev/null +++ b/packages/web/src/component/ImageTrimScene.tsx @@ -0,0 +1,240 @@ +import styled from "@emotion/styled"; +import { type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { + type CropCorner, + type Rect, + type Size, + MAX_SPLIT_COUNT, + MIN_SPLIT_COUNT +} from "~/common/image-split"; +import { + alphaColor, + em, + percent, + PRIMITIVE_COLOR, + px +} from "~/common/css-util"; +import MockActionButton from "~/component/MockActionButton"; +import { + MockCheckboxFormInput, + MockRangeFormRow +} from "~/component/mock-form-ui"; + +const HANDLE_SIZE = 16; + +const pctNum = (value: number, total: number) => (value / total) * 100; + +// left/top で寄せた辺は -50%、right/bottom で寄せた辺は +50% 側に +// translate しないと、ハンドルの中心が角の点からずれてしまう +const CORNER_STYLE: Record< + CropCorner, + { top?: 0; bottom?: 0; left?: 0; right?: 0; transform: string } +> = { + nw: { top: 0, left: 0, transform: "translate(-50%, -50%)" }, + ne: { top: 0, right: 0, transform: "translate(50%, -50%)" }, + sw: { bottom: 0, left: 0, transform: "translate(-50%, 50%)" }, + se: { bottom: 0, right: 0, transform: "translate(50%, 50%)" } +}; + +const Stage = styled.div({ + position: "relative", + lineHeight: 0, + userSelect: "none", + touchAction: "none", + cursor: "crosshair" +}); + +const DimBand = styled.div({ + position: "absolute", + background: alphaColor(PRIMITIVE_COLOR.BLACK, 0.5), + pointerEvents: "none" +}); + +const GuideLine = styled.div({ + position: "absolute", + top: 0, + bottom: 0, + width: px(2), + transform: "translateX(-50%)", + background: alphaColor(PRIMITIVE_COLOR.WHITE, 0.8), + pointerEvents: "none" +}); + +// outline はボックスサイズに影響しないため、四隅のハンドルを +// border の分だけずらさずに正確に角へ重ねられる。 +// ドラッグ操作は Stage 側でまとめて処理するため pointer-events は無効にする +const CropArea = styled.div({ + position: "absolute", + boxSizing: "border-box", + outline: `${px(2)} dashed ${PRIMITIVE_COLOR.WHITE}`, + pointerEvents: "none" +}); + +// 見た目のサイズはこのまま小さく保ち、代わりに Stage 側で +// タップ位置から一番近い角を割り出して操作対象にする(当たり判定は +// 見た目に縛られない)ため、ハンドル自体は装飾のみで pointer-events を持たない +const CropHandle = styled.div<{ corner: CropCorner }>(({ corner }) => ({ + position: "absolute", + ...CORNER_STYLE[corner], + width: px(HANDLE_SIZE), + height: px(HANDLE_SIZE), + boxSizing: "border-box", + background: PRIMITIVE_COLOR.WHITE, + border: `${px(1)} solid ${PRIMITIVE_COLOR.BLACK}` +})); + +const Toolbar = styled.div({ + display: "flex", + gap: em(1) +}); + +const ImageTrimScene = ({ + imageUrl, + naturalSize, + cropRect, + aspectLocked, + isSplitting, + splitCount, + imageRef, + onFileChange, + onStagePointerDown, + onImageLoad, + onResetCrop, + onAspectLockedChange, + onSplitCountChange, + onSplit +}: { + imageUrl: string; + naturalSize: Size | null; + cropRect: Rect | null; + aspectLocked: boolean; + isSplitting: boolean; + splitCount: number; + imageRef: RefObject; + onFileChange: (files: File[]) => void; + onStagePointerDown: (e: ReactPointerEvent) => void; + onImageLoad: (size: Size) => void; + onResetCrop: () => void; + onAspectLockedChange: (next: boolean) => void; + onSplitCountChange: (next: number) => void; + onSplit: () => void; +}) => ( + <> +

+ 画像を選んで四隅付近をドラッグすると、一番近い角から切り抜き範囲を調整できます。「分割する」を押すと指定した数に均等分割した画像をそれぞれダウンロードできます。 +

+
+ + 画像を選択 + +
+ {imageUrl ? ( + + 編集対象の画像 { + const { naturalWidth, naturalHeight } = e.currentTarget; + onImageLoad({ width: naturalWidth, height: naturalHeight }); + }} + /> + {cropRect && naturalSize + ? (() => { + const left = pctNum(cropRect.x, naturalSize.width); + const top = pctNum(cropRect.y, naturalSize.height); + const width = pctNum(cropRect.width, naturalSize.width); + const height = pctNum(cropRect.height, naturalSize.height); + const right = left + width; + const bottom = top + height; + return ( + <> + + + + + + {Array.from({ length: splitCount - 1 }, (_, i) => ( + + ))} + {(["nw", "ne", "sw", "se"] as const).map(corner => ( + + ))} + + + ); + })() + : null} + + ) : null} + {imageUrl ? ( + + 元画像の縦横比を固定する + + ) : null} + {imageUrl ? ( + + ) : null} + {imageUrl ? ( + + + 切り抜きをリセット + + + {isSplitting ? "分割中…" : "分割する"} + + + ) : null} + +); + +export default ImageTrimScene; diff --git a/packages/web/src/feature/page-path.ts b/packages/web/src/feature/page-path.ts index 0530ab9..8f2e7f7 100644 --- a/packages/web/src/feature/page-path.ts +++ b/packages/web/src/feature/page-path.ts @@ -5,3 +5,4 @@ const PAGE_ROOT = new PageEntry(BASE_URL); export const PAGE_TOP = PAGE_ROOT; export const PAGE_ABOUT = PAGE_ROOT.child("about"); +export const PAGE_IMAGE_SPLITTER = PAGE_ROOT.child("image-splitter"); diff --git a/packages/web/src/pages/image-splitter/TODO.md b/packages/web/src/pages/image-splitter/TODO.md new file mode 100644 index 0000000..225465f --- /dev/null +++ b/packages/web/src/pages/image-splitter/TODO.md @@ -0,0 +1,7 @@ +# 画像分割ツール 今後のタスク + +- [x] 分割数指定を range にする +- [x] ダウンロード時のファイル名が都度変わるようにする(分割完了時のタイムスタンプをつけるなど) +- [ ] トリミング範囲の移動もやっぱりできるようにする +- [ ] デザインをあてる +- [ ] aspect-man との統合 diff --git a/packages/web/src/pages/image-splitter/index.tsx b/packages/web/src/pages/image-splitter/index.tsx new file mode 100644 index 0000000..56034c3 --- /dev/null +++ b/packages/web/src/pages/image-splitter/index.tsx @@ -0,0 +1,17 @@ +import { makeSubPageMetadata } from "~/feature/defaultMetadata"; +import { PAGE_IMAGE_SPLITTER } from "~/feature/page-path"; +import ImageSplitterScene from "~/component/ImageSplitterScene"; +import PageMeta from "~/component/PageMeta"; + +const metadata = makeSubPageMetadata({ + page: PAGE_IMAGE_SPLITTER, + subPageTitle: "画像分割ツール" +}); + +const PageImageSplitter = () => ( + + + +); + +export default PageImageSplitter;