diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2b4e2..fdb6478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ++ [新增] 图片切图支持等分线直接拖拽调整,并可新增、删除和重置横向 / 纵向切图线。 + [新增] 新增Codex App插件支持。 + [新增] 配置与用户偏好新增独立页面和 Codex 连接配置 Tab。 + [修复] 修复生图工作台重试成功结果刷新后丢失的问题。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index ae5de8d..293804a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。 - Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。 - 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。 - 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。 diff --git a/web/src/components/canvas/canvas-node-split-dialog.tsx b/web/src/components/canvas/canvas-node-split-dialog.tsx index a2f5a16..08a9261 100644 --- a/web/src/components/canvas/canvas-node-split-dialog.tsx +++ b/web/src/components/canvas/canvas-node-split-dialog.tsx @@ -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(null); + const previewRef = useRef(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 ( @@ -41,9 +93,9 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
-
+
- +
@@ -52,19 +104,25 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
- update("rows", value)} /> - update("columns", value)} /> + update("rows", value)} /> + update("columns", value)} /> +
+ + + + +
- 子节点 + 切片数量 {total} 个
- 单块约 + 平均约 {pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}
-
@@ -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 (
- {Array.from({ length: columns - 1 }).map((_, index) => ( -
+ {verticalLines.map((line, index) => ( +
onPointerDown("vertical", index, event)}> +
+
))} - {Array.from({ length: rows - 1 }).map((_, index) => ( -
+ {horizontalLines.map((line, index) => ( +
onPointerDown("horizontal", index, event)}> +
+
))}
); } +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))); diff --git a/web/src/lib/canvas/canvas-image-data.ts b/web/src/lib/canvas/canvas-image-data.ts index c9fd8e4..c807499 100644 --- a/web/src/lib/canvas/canvas-image-data.ts +++ b/web/src/lib/canvas/canvas-image-data.ts @@ -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 { 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");