Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/web/src/common/__tests__/format-timestamp.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
145 changes: 145 additions & 0 deletions packages/web/src/common/__tests__/image-split.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }
]);
});
});
14 changes: 14 additions & 0 deletions packages/web/src/common/format-timestamp.ts
Original file line number Diff line number Diff line change
@@ -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}`;
};
139 changes: 139 additions & 0 deletions packages/web/src/common/image-split.ts
Original file line number Diff line number Diff line change
@@ -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
}));
};
Loading