feat: add geometry tools
This commit is contained in:
@@ -1,10 +1,26 @@
|
|||||||
import { ToolError } from "../core/errors";
|
import { ToolError } from "../core/errors";
|
||||||
import {
|
import {
|
||||||
|
rotateFreeImage,
|
||||||
|
skewImage,
|
||||||
|
transformImage,
|
||||||
|
zoomImage,
|
||||||
|
} from "../core/affine";
|
||||||
|
import {
|
||||||
|
centerByAlpha,
|
||||||
changeCanvasSize,
|
changeCanvasSize,
|
||||||
crop,
|
crop,
|
||||||
|
cropToRatio,
|
||||||
expandCanvas,
|
expandCanvas,
|
||||||
|
flip,
|
||||||
|
forceOrientation,
|
||||||
|
padToRatio,
|
||||||
resize,
|
resize,
|
||||||
|
rotate90,
|
||||||
|
symmetricCopy,
|
||||||
|
tile,
|
||||||
|
trimToContent,
|
||||||
type Anchor9,
|
type Anchor9,
|
||||||
|
type FlipAxis,
|
||||||
} from "../core/geometry";
|
} from "../core/geometry";
|
||||||
import { field, toolSchema, type Dimension } from "../registry-schema";
|
import { field, toolSchema, type Dimension } from "../registry-schema";
|
||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
@@ -222,10 +238,349 @@ const cropTool: ToolEntry<CropParams> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface RotateParams {
|
||||||
|
angle: "90" | "180" | "270";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateSchema = toolSchema<RotateParams>({
|
||||||
|
angle: field.select({
|
||||||
|
default: "90",
|
||||||
|
options: [
|
||||||
|
{ value: "90", label: "90° clockwise" },
|
||||||
|
{ value: "180", label: "180°" },
|
||||||
|
{ value: "270", label: "270° clockwise" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateTool: ToolEntry<RotateParams> = {
|
||||||
|
id: "rotate-png",
|
||||||
|
title: "Rotate PNG",
|
||||||
|
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: rotateSchema,
|
||||||
|
run: (img, p) => rotate90(img, Number(p.angle) / 90),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlipParams {
|
||||||
|
axis: FlipAxis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const flipSchema = toolSchema<FlipParams>({
|
||||||
|
axis: field.select({
|
||||||
|
default: "horizontal",
|
||||||
|
options: [
|
||||||
|
{ value: "horizontal", label: "Horizontal (left to right)" },
|
||||||
|
{ value: "vertical", label: "Vertical (top to bottom)" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flipTool: ToolEntry<FlipParams> = {
|
||||||
|
id: "flip-png",
|
||||||
|
title: "Flip PNG",
|
||||||
|
description: "Mirrors horizontally or vertically without quality loss.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: flipSchema,
|
||||||
|
run: (img, p) => flip(img, p.axis),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddPaddingParams {
|
||||||
|
padding: number;
|
||||||
|
transparent: boolean;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addPaddingSchema = toolSchema<AddPaddingParams>(
|
||||||
|
{
|
||||||
|
padding: field.number({ min: 1, max: 2000, step: 1, default: 10 }),
|
||||||
|
transparent: field.checkbox({ default: true }),
|
||||||
|
color: field.color({ default: "#ffffff" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Padding", fields: ["padding"] },
|
||||||
|
{ title: "Fill", fields: ["transparent", "color"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const addPaddingTool: ToolEntry<AddPaddingParams> = {
|
||||||
|
id: "add-padding-png",
|
||||||
|
title: "Add padding to PNG",
|
||||||
|
description:
|
||||||
|
"Expands the canvas on all sides by the chosen number of pixels.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: addPaddingSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
expandCanvas(
|
||||||
|
img,
|
||||||
|
p.padding,
|
||||||
|
p.padding,
|
||||||
|
p.padding,
|
||||||
|
p.padding,
|
||||||
|
p.transparent ? undefined : p.color,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TileParams {
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tileSchema = toolSchema<TileParams>({
|
||||||
|
columns: field.number({ min: 1, max: 50, step: 1, default: 2 }),
|
||||||
|
rows: field.number({ min: 1, max: 50, step: 1, default: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileTool: ToolEntry<TileParams> = {
|
||||||
|
id: "tile-png",
|
||||||
|
title: "Tile PNG",
|
||||||
|
description: "Repeats the image in a grid of the chosen columns and rows.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: tileSchema,
|
||||||
|
run: (img, p) => tile(img, p.columns, p.rows),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EmptyParams {}
|
||||||
|
|
||||||
|
export const centerByAlphaSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const centerByAlphaTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "center-by-alpha-png",
|
||||||
|
title: "Center PNG by content",
|
||||||
|
description:
|
||||||
|
"Finds the opaque part of the image and centers it on the original canvas.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: centerByAlphaSchema,
|
||||||
|
run: (img) => centerByAlpha(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SkewParams {
|
||||||
|
degX: number;
|
||||||
|
degY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skewSchema = toolSchema<SkewParams>({
|
||||||
|
degX: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
|
||||||
|
degY: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const skewTool: ToolEntry<SkewParams> = {
|
||||||
|
id: "skew-png",
|
||||||
|
title: "Skew PNG",
|
||||||
|
description:
|
||||||
|
"Shifts content horizontally and vertically — a perspective effect.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: skewSchema,
|
||||||
|
run: (img, p) => skewImage(img, p.degX, p.degY),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RotateFreeParams {
|
||||||
|
angle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateFreeSchema = toolSchema<RotateFreeParams>({
|
||||||
|
angle: field.slider({ min: -180, max: 180, step: 1, default: 15 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateFreeTool: ToolEntry<RotateFreeParams> = {
|
||||||
|
id: "rotate-free-png",
|
||||||
|
title: "Rotate by custom angle",
|
||||||
|
description:
|
||||||
|
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: rotateFreeSchema,
|
||||||
|
run: (img, p) => rotateFreeImage(img, p.angle),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ZoomParams {
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zoomSchema = toolSchema<ZoomParams>({
|
||||||
|
scale: field.slider({ min: 100, max: 500, step: 10, default: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zoomTool: ToolEntry<ZoomParams> = {
|
||||||
|
id: "zoom-png",
|
||||||
|
title: "Zoom PNG",
|
||||||
|
description:
|
||||||
|
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: zoomSchema,
|
||||||
|
run: (img, p) => zoomImage(img, p.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TrimEmptySpaceParams {
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trimEmptySpaceSchema = toolSchema<TrimEmptySpaceParams>({
|
||||||
|
threshold: field.slider({ min: 0, max: 254, step: 1, default: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimEmptySpaceTool: ToolEntry<TrimEmptySpaceParams> = {
|
||||||
|
id: "trim-empty-space-png",
|
||||||
|
title: "Trim Empty Space PNG",
|
||||||
|
description:
|
||||||
|
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: trimEmptySpaceSchema,
|
||||||
|
run: (img, p) => trimToContent(img, p.threshold),
|
||||||
|
};
|
||||||
|
|
||||||
|
type AspectRatio = "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "16:9" | "9:16";
|
||||||
|
|
||||||
|
interface ChangeAspectRatioParams {
|
||||||
|
ratio: AspectRatio;
|
||||||
|
mode: "crop" | "pad";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const changeAspectRatioSchema = toolSchema<ChangeAspectRatioParams>({
|
||||||
|
ratio: field.select({
|
||||||
|
default: "1:1",
|
||||||
|
options: [
|
||||||
|
{ value: "1:1", label: "1:1" },
|
||||||
|
{ value: "4:3", label: "4:3" },
|
||||||
|
{ value: "3:4", label: "3:4" },
|
||||||
|
{ value: "3:2", label: "3:2" },
|
||||||
|
{ value: "2:3", label: "2:3" },
|
||||||
|
{ value: "16:9", label: "16:9" },
|
||||||
|
{ value: "9:16", label: "9:16" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
mode: field.select({
|
||||||
|
default: "crop",
|
||||||
|
options: [
|
||||||
|
{ value: "crop", label: "Crop to fill" },
|
||||||
|
{ value: "pad", label: "Pad to fit" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeAspectRatioTool: ToolEntry<ChangeAspectRatioParams> = {
|
||||||
|
id: "change-aspect-ratio-png",
|
||||||
|
title: "Change Aspect Ratio PNG",
|
||||||
|
description:
|
||||||
|
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: changeAspectRatioSchema,
|
||||||
|
run: (img, p) => {
|
||||||
|
const [rw, rh] = p.ratio.split(":").map(Number);
|
||||||
|
const ratio = rw / rh;
|
||||||
|
return p.mode === "pad" ? padToRatio(img, ratio) : cropToRatio(img, ratio);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SwapOrientationParams {
|
||||||
|
target: "portrait" | "landscape";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const swapOrientationSchema = toolSchema<SwapOrientationParams>({
|
||||||
|
target: field.select({
|
||||||
|
default: "portrait",
|
||||||
|
options: [
|
||||||
|
{ value: "portrait", label: "Portrait" },
|
||||||
|
{ value: "landscape", label: "Landscape" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const swapOrientationTool: ToolEntry<SwapOrientationParams> = {
|
||||||
|
id: "swap-orientation-png",
|
||||||
|
title: "Swap Orientation PNG",
|
||||||
|
description:
|
||||||
|
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: swapOrientationSchema,
|
||||||
|
run: (img, p) => forceOrientation(img, p.target),
|
||||||
|
};
|
||||||
|
|
||||||
|
type SymmetricAxis = "horizontal" | "vertical";
|
||||||
|
type KeepSide = "left" | "right" | "top" | "bottom";
|
||||||
|
|
||||||
|
interface SymmetricCopyParams {
|
||||||
|
axis: SymmetricAxis;
|
||||||
|
keepSide: KeepSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const symmetricCopySchema = toolSchema<SymmetricCopyParams>({
|
||||||
|
axis: field.select({
|
||||||
|
default: "vertical",
|
||||||
|
options: [
|
||||||
|
{ value: "vertical", label: "Vertical (double width)" },
|
||||||
|
{ value: "horizontal", label: "Horizontal (double height)" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
keepSide: field.select({
|
||||||
|
default: "left",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "bottom", label: "Bottom" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const symmetricCopyTool: ToolEntry<SymmetricCopyParams> = {
|
||||||
|
id: "symmetric-copy-png",
|
||||||
|
title: "Symmetric Copy PNG",
|
||||||
|
description:
|
||||||
|
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: symmetricCopySchema,
|
||||||
|
run: (img, p) => symmetricCopy(img, p.axis, p.keepSide),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShiftParams {
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shiftSchema = toolSchema<ShiftParams>({
|
||||||
|
offsetX: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
|
||||||
|
offsetY: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
|
||||||
|
color: field.color({ default: "#ffffff" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftTool: ToolEntry<ShiftParams> = {
|
||||||
|
id: "shift-png",
|
||||||
|
title: "Shift PNG",
|
||||||
|
description: "Moves content by the given X and Y offset.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: shiftSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
transformImage(
|
||||||
|
img,
|
||||||
|
[1, 0, 0, 1, -Math.trunc(p.offsetX), -Math.trunc(p.offsetY)],
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
p.color,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const geometryEntries = [
|
export const geometryEntries = [
|
||||||
addBorder,
|
addBorder,
|
||||||
fitOnBackground,
|
fitOnBackground,
|
||||||
changeCanvasSizeTool,
|
changeCanvasSizeTool,
|
||||||
resizeTool,
|
resizeTool,
|
||||||
cropTool,
|
cropTool,
|
||||||
|
rotateTool,
|
||||||
|
flipTool,
|
||||||
|
addPaddingTool,
|
||||||
|
tileTool,
|
||||||
|
centerByAlphaTool,
|
||||||
|
skewTool,
|
||||||
|
rotateFreeTool,
|
||||||
|
zoomTool,
|
||||||
|
trimEmptySpaceTool,
|
||||||
|
changeAspectRatioTool,
|
||||||
|
swapOrientationTool,
|
||||||
|
symmetricCopyTool,
|
||||||
|
shiftTool,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user