feat: add support for draggable grid lines in image slicing, with options to add, delete, and reset horizontal/vertical lines

This commit is contained in:
HouYunFei
2026-07-05 16:08:23 +08:00
parent 65a91b22e1
commit 0b69dfce46
4 changed files with 120 additions and 27 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased
+ [新增] 图片切图支持等分线直接拖拽调整,并可新增、删除和重置横向 / 纵向切图线。
+ [新增] 新增Codex App插件支持。
+ [新增] 配置与用户偏好新增独立页面和 Codex 连接配置 Tab。
+ [修复] 修复生图工作台重试成功结果刷新后丢失的问题。
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
- Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。
- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。
- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。
@@ -1,24 +1,32 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2 } from "lucide-react";
import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
export type CanvasImageSplitParams = ImageSplitParams;
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2, horizontalLines: [0.5], verticalLines: [0.5] };
const maxGridSize = 12;
type ActiveLine = { axis: "horizontal" | "vertical"; index: number } | null;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const total = params.rows * params.columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
const [active, setActive] = useState<ActiveLine>(null);
const previewRef = useRef<HTMLDivElement>(null);
const horizontalLines = params.horizontalLines || [];
const verticalLines = params.verticalLines || [];
const rows = horizontalLines.length + 1;
const columns = verticalLines.length + 1;
const total = rows * columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / columns)), height: Math.max(1, Math.floor(image.height / rows)) } : null;
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setActive(null);
setImage(null);
}, [dataUrl, open]);
@@ -27,9 +35,53 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
const update = (key: "rows" | "columns", value: string | number | null) => {
const count = clampGrid(value ?? params[key]);
setActive(null);
setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) }));
};
const addLine = (axis: "horizontal" | "vertical") => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b);
return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns };
});
};
const deleteLine = () => {
if (!active) return;
setParams((current) => {
const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = (current[key] || []).filter((_, index) => index !== active.index);
return { ...current, [key]: lines, rows: active.axis === "horizontal" ? lines.length + 1 : current.rows, columns: active.axis === "vertical" ? lines.length + 1 : current.columns };
});
setActive(null);
};
const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => {
event.preventDefault();
setActive({ axis, index });
const box = previewRef.current?.getBoundingClientRect();
if (!box) return;
const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width);
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || [])];
lines[index] = clampLine(value, lines[index - 1] ?? 0, lines[index + 1] ?? 1);
return { ...current, [key]: lines };
});
};
const resetLines = () => {
setActive(null);
setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) }));
};
const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns };
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
@@ -41,9 +93,9 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid rows={params.rows} columns={params.columns} />
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
@@ -52,19 +104,25 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>线</Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>线</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>线</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>线</Button>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="opacity-60"></span>
<span className="font-semibold">{total} </span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span>
<span className="opacity-60"></span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}>
</Button>
</div>
@@ -83,19 +141,45 @@ function NumberField({ label, value, onChange }: { label: string; value: number;
);
}
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
function SplitGrid({ horizontalLines, verticalLines, active, onPointerDown }: { horizontalLines: number[]; verticalLines: number[]; active: ActiveLine; onPointerDown: (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => void }) {
return (
<div className="pointer-events-none absolute inset-0">
{Array.from({ length: columns - 1 }).map((_, index) => (
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
{verticalLines.map((line, index) => (
<div key={`column-${index}`} className="pointer-events-auto absolute inset-y-0 -ml-2 w-4 cursor-ew-resize" style={{ left: `${line * 100}%` }} onPointerDown={(event) => onPointerDown("vertical", index, event)}>
<div className={`absolute left-1/2 top-0 h-full border-l shadow-[0_0_0_1px_rgba(0,0,0,.35)] ${active?.axis === "vertical" && active.index === index ? "border-amber-300" : "border-white/90"}`} />
</div>
))}
{Array.from({ length: rows - 1 }).map((_, index) => (
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
{horizontalLines.map((line, index) => (
<div key={`row-${index}`} className="pointer-events-auto absolute inset-x-0 -mt-2 h-4 cursor-ns-resize" style={{ top: `${line * 100}%` }} onPointerDown={(event) => onPointerDown("horizontal", index, event)}>
<div className={`absolute left-0 top-1/2 w-full border-t shadow-[0_0_0_1px_rgba(0,0,0,.35)] ${active?.axis === "horizontal" && active.index === index ? "border-amber-300" : "border-white/90"}`} />
</div>
))}
</div>
);
}
function buildGridLines(count: number) {
return Array.from({ length: Math.max(1, count) - 1 }, (_, index) => (index + 1) / count);
}
function findLineSpot(lines: number[]) {
const cuts = [0, ...lines, 1].sort((a, b) => a - b);
let spot = 0.5;
let max = 0;
for (let index = 0; index < cuts.length - 1; index += 1) {
const gap = cuts[index + 1] - cuts[index];
if (gap > max) {
max = gap;
spot = cuts[index] + gap / 2;
}
}
return spot;
}
function clampLine(value: number, min: number, max: number) {
return Math.min(max - 0.01, Math.max(min + 0.01, value));
}
function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
+15 -8
View File
@@ -24,6 +24,8 @@ export type ImageUpscaleParams = {
export type ImageSplitParams = {
rows: number;
columns: number;
horizontalLines?: number[];
verticalLines?: number[];
};
export type ImageSplitPiece = {
@@ -45,16 +47,16 @@ export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
const image = await loadImage(dataUrl);
const rows = Math.max(1, Math.floor(params.rows));
const columns = Math.max(1, Math.floor(params.columns));
const xCuts = buildSplitCuts(params.verticalLines, image.width, Math.max(1, Math.floor(params.columns)));
const yCuts = buildSplitCuts(params.horizontalLines, image.height, Math.max(1, Math.floor(params.rows)));
const pieces: ImageSplitPiece[] = [];
for (let row = 0; row < rows; row += 1) {
const sy = Math.floor((row * image.height) / rows);
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
for (let column = 0; column < columns; column += 1) {
const sx = Math.floor((column * image.width) / columns);
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
for (let row = 0; row < yCuts.length - 1; row += 1) {
const sy = yCuts[row];
const sh = yCuts[row + 1] - sy;
for (let column = 0; column < xCuts.length - 1; column += 1) {
const sx = xCuts[column];
const sw = xCuts[column + 1] - sx;
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
}
}
@@ -62,6 +64,11 @@ export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): P
return pieces;
}
function buildSplitCuts(lines: number[] | undefined, size: number, count: number) {
if (!lines?.length) return Array.from({ length: count + 1 }, (_, index) => Math.floor((index * size) / count));
return [0, ...lines.map((line) => Math.round(line * size)).filter((line) => line > 0 && line < size).sort((a, b) => a - b), size];
}
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");