优化拆图
This commit is contained in:
1 parent
e072ca06ec
commit
39885a14d1
11 files changed
+240
-92
No files matched your search
@@ -487,6 +487,17 @@ export const ChatDrawer = forwardRef<ChatDrawerRef, ChatDrawerProps>(
|
||||
[activeSessionId]
|
||||
);
|
||||
|
||||
// Rename session
|
||||
const handleRenameSession = useCallback(
|
||||
async (sessionId: string, newTitle: string) => {
|
||||
await chatStorageService.updateSession(sessionId, { title: newTitle });
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === sessionId ? { ...s, title: newTitle } : s))
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Store pending message for retry after session creation or API key config
|
||||
const pendingMessageRef = React.useRef<Message | null>(null);
|
||||
|
||||
@@ -594,7 +605,7 @@ export const ChatDrawer = forwardRef<ChatDrawerRef, ChatDrawerProps>(
|
||||
} else if (context.model.id) {
|
||||
titleText = `模型: ${context.model.id}`;
|
||||
}
|
||||
const title = titleText.length > 30 ? titleText.slice(0, 30) + '...' : titleText;
|
||||
const title = titleText;//.length > 30 ? titleText.slice(0, 30) + '...' : titleText;
|
||||
await chatStorageService.updateSession(newSession.id, { title });
|
||||
newSession.title = title;
|
||||
|
||||
@@ -964,6 +975,7 @@ export const ChatDrawer = forwardRef<ChatDrawerRef, ChatDrawerProps>(
|
||||
onSelectSession={handleSelectSession}
|
||||
onNewSession={handleNewSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
* Displays a single chat session in the session list.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { DeleteIcon } from 'tdesign-icons-react';
|
||||
import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { DeleteIcon, EditIcon } from 'tdesign-icons-react';
|
||||
import type { SessionItemProps } from '../../types/chat.types';
|
||||
|
||||
export const SessionItem: React.FC<SessionItemProps> = React.memo(
|
||||
({ session, isActive, onSelect, onDelete }) => {
|
||||
({ session, isActive, onSelect, onDelete, onRename }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect();
|
||||
}, [onSelect]);
|
||||
if (!isEditing) {
|
||||
onSelect();
|
||||
}
|
||||
}, [onSelect, isEditing]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -22,6 +28,47 @@ export const SessionItem: React.FC<SessionItemProps> = React.memo(
|
||||
[onDelete]
|
||||
);
|
||||
|
||||
const handleStartEdit = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(session.title);
|
||||
setIsEditing(true);
|
||||
}, [session.title]);
|
||||
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
const trimmedValue = editValue.trim();
|
||||
if (trimmedValue && trimmedValue !== session.title) {
|
||||
onRename(trimmedValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}, [editValue, session.title, onRename]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditValue(session.title);
|
||||
setIsEditing(false);
|
||||
}, [session.title]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
}, [handleSaveEdit, handleCancelEdit]);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
// 自动聚焦输入框
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const formattedTime = useMemo(() => {
|
||||
const date = new Date(session.updatedAt);
|
||||
const now = new Date();
|
||||
@@ -42,25 +89,50 @@ export const SessionItem: React.FC<SessionItemProps> = React.memo(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`session-item ${isActive ? 'session-item--active' : ''}`}
|
||||
className={`session-item ${isActive ? 'session-item--active' : ''} ${isEditing ? 'session-item--editing' : ''}`}
|
||||
data-track="chat_click_session_select"
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
|
||||
onKeyDown={(e) => !isEditing && e.key === 'Enter' && handleClick()}
|
||||
>
|
||||
<div className="session-item__content">
|
||||
<div className="session-item__title">{session.title}</div>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="session-item__title-input"
|
||||
value={editValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSaveEdit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
maxLength={50}
|
||||
/>
|
||||
) : (
|
||||
<div className="session-item__title">{session.title}</div>
|
||||
)}
|
||||
<div className="session-item__time">{formattedTime}</div>
|
||||
</div>
|
||||
<button
|
||||
className="session-item__delete"
|
||||
data-track="chat_click_session_delete"
|
||||
onClick={handleDelete}
|
||||
aria-label={`删除会话: ${session.title}`}
|
||||
>
|
||||
<DeleteIcon size={14} />
|
||||
</button>
|
||||
{!isEditing && (
|
||||
<div className="session-item__actions">
|
||||
<button
|
||||
className="session-item__edit"
|
||||
data-track="chat_click_session_edit"
|
||||
onClick={handleStartEdit}
|
||||
aria-label={`编辑会话标题: ${session.title}`}
|
||||
>
|
||||
<EditIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="session-item__delete"
|
||||
data-track="chat_click_session_delete"
|
||||
onClick={handleDelete}
|
||||
aria-label={`删除会话: ${session.title}`}
|
||||
>
|
||||
<DeleteIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SessionItem } from './SessionItem';
|
||||
import type { SessionListProps, ChatSession } from '../../types/chat.types';
|
||||
|
||||
export const SessionList: React.FC<SessionListProps> = React.memo(
|
||||
({ sessions, activeSessionId, onSelectSession, onNewSession, onDeleteSession }) => {
|
||||
({ sessions, activeSessionId, onSelectSession, onNewSession, onDeleteSession, onRenameSession }) => {
|
||||
const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -36,6 +36,13 @@ export const SessionList: React.FC<SessionListProps> = React.memo(
|
||||
setPendingDeleteId(null);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newTitle: string) => {
|
||||
onRenameSession(sessionId, newTitle);
|
||||
},
|
||||
[onRenameSession]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="session-list">
|
||||
<div className="session-list__header">
|
||||
@@ -61,6 +68,7 @@ export const SessionList: React.FC<SessionListProps> = React.memo(
|
||||
isActive={session.id === activeSessionId}
|
||||
onSelect={() => handleSelect(session.id)}
|
||||
onDelete={() => handleDeleteClick(session.id)}
|
||||
onRename={(newTitle) => handleRename(session.id, newTitle)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -548,6 +548,10 @@ $border-radius: 12px;
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
|
||||
&--editing {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -561,13 +565,38 @@ $border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__title-input {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-brand-color);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 82, 217, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 11px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
&__delete {
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&__edit,
|
||||
&__delete {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -575,14 +604,19 @@ $border-radius: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--td-text-color-secondary);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-error-color-1);
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__delete {
|
||||
&__edit:hover {
|
||||
background: var(--td-brand-color-light);
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&__delete:hover {
|
||||
background: var(--td-error-color-1);
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
|
||||
&:hover &__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getCurrentImageModel } from './image-generation';
|
||||
export interface InspirationBoardParams {
|
||||
/** 主题描述 */
|
||||
theme: string;
|
||||
/** 图片数量(6-12,默认 9) */
|
||||
/** 图片数量(1-16,默认 9) */
|
||||
imageCount?: number;
|
||||
/** 图片尺寸比例(默认 16x9 横向) */
|
||||
imageSize?: string;
|
||||
@@ -64,7 +64,7 @@ function executeQueue(params: InspirationBoardParams, options: MCPExecuteOptions
|
||||
}
|
||||
|
||||
// 验证参数范围
|
||||
const validImageCount = Math.min(Math.max(6, imageCount), 12);
|
||||
const validImageCount = Math.min(Math.max(1, imageCount), 16);
|
||||
|
||||
// 构建生图提示词
|
||||
const prompt = buildInspirationBoardPrompt(theme, validImageCount, language);
|
||||
@@ -183,7 +183,7 @@ export const inspirationBoardTool: MCPTool = {
|
||||
},
|
||||
imageCount: {
|
||||
type: 'number',
|
||||
description: '图片数量,6-12 之间,默认 9',
|
||||
description: '图片数量,1-16 之间,默认 9',
|
||||
default: 9,
|
||||
},
|
||||
imageSize: {
|
||||
@@ -219,7 +219,7 @@ export const inspirationBoardTool: MCPTool = {
|
||||
|
||||
parameterGuidance: {
|
||||
theme: '主题描述应该具体且有多样性潜力。好的主题:描述一类事物的多种变体、不同角度或不同场景。避免过于具体的单一描述。',
|
||||
imageCount: '6-12 张,会自动计算最优网格布局(如 9 张 = 3x3,12 张 = 3x4)。',
|
||||
imageCount: '1-16 张,会自动计算最优网格布局(如 9 张 = 3x3,12 张 = 3x4)。',
|
||||
imageSize: '建议使用 16x9 横向比例,生成更大尺寸的图片。',
|
||||
},
|
||||
|
||||
@@ -236,11 +236,6 @@ export const inspirationBoardTool: MCPTool = {
|
||||
args: { theme: '可爱香蕉的各种形态,包含卡通香蕉、写实香蕉、香蕉角色、香蕉图案等不同风格', imageCount: 9 },
|
||||
explanation: '生成 3x3 网格,拆分后 9 张等大图片以散落布局插入',
|
||||
},
|
||||
{
|
||||
input: '创建一个咖啡文化灵感板',
|
||||
args: { theme: '咖啡文化,包含咖啡豆、咖啡杯、拉花、咖啡馆场景、手冲咖啡等不同元素', imageCount: 12 },
|
||||
explanation: '生成 3x4 网格,拆分后 12 张等大图片,内容更丰富',
|
||||
},
|
||||
{
|
||||
input: '做个城市街角的 Mood Board',
|
||||
args: { theme: '城市街角,包含不同城市、不同时间、不同天气、不同风格的街角场景', imageCount: 8, imageSize: '16x9' },
|
||||
|
||||
@@ -33,7 +33,7 @@ async function loadImage(imageUrl: string): Promise<HTMLImageElement> {
|
||||
* 将 Canvas 导出为 DataURL
|
||||
*/
|
||||
function canvasToDataURL(canvas: HTMLCanvasElement, quality: number = 0.92): string {
|
||||
return canvas.toDataURL('image/png', quality);
|
||||
return canvas.toDataURL('image/jpeg', quality);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -290,6 +290,7 @@ export interface SessionListProps {
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
onRenameSession: (sessionId: string, newTitle: string) => void;
|
||||
}
|
||||
|
||||
/** SessionItem 组件 Props */
|
||||
@@ -298,6 +299,7 @@ export interface SessionItemProps {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: (newTitle: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -197,7 +197,7 @@ export const GRID_IMAGE_DEFAULTS = {
|
||||
* 灵感图布局配置
|
||||
*/
|
||||
export interface InspirationBoardLayoutConfig {
|
||||
/** 图片数量(6-12) */
|
||||
/** 图片数量(1-36) */
|
||||
imageCount?: number;
|
||||
/** 最小宽度比例(相对于平均尺寸) */
|
||||
minWidthRatio?: number;
|
||||
|
||||
@@ -293,7 +293,7 @@ export async function removeWhiteBorder(
|
||||
trimmedHeight
|
||||
);
|
||||
|
||||
return trimmedCanvas.toDataURL('image/png', 0.92);
|
||||
return trimmedCanvas.toDataURL('image/jpeg', 0.92);
|
||||
} catch (error) {
|
||||
console.error('[removeWhiteBorder] Error:', error);
|
||||
return imageUrl;
|
||||
|
||||
@@ -42,10 +42,9 @@ export interface GridDetectionResult {
|
||||
|
||||
/**
|
||||
* 检测像素是否为白色/浅色(分割线颜色)
|
||||
* 提高阈值到 250,确保只检测真正的纯白分割线
|
||||
*/
|
||||
function isLightPixel(r: number, g: number, b: number, threshold: number = 250): boolean {
|
||||
// 检查是否接近纯白色
|
||||
function isLightPixel(r: number, g: number, b: number, threshold: number = 240): boolean {
|
||||
// 检查是否接近白色
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
@@ -85,24 +84,22 @@ function getColWhiteRatio(imageData: ImageData, x: number): number {
|
||||
|
||||
/**
|
||||
* 检测一行像素是否为分割线(大部分为白色)
|
||||
* 提高阈值到 95%,确保是真正的分割线
|
||||
*/
|
||||
function isHorizontalSplitLine(
|
||||
imageData: ImageData,
|
||||
y: number,
|
||||
minWhiteRatio: number = 0.95
|
||||
minWhiteRatio: number = 0.90
|
||||
): boolean {
|
||||
return getRowWhiteRatio(imageData, y) >= minWhiteRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测一列像素是否为分割线
|
||||
* 提高阈值到 95%,确保是真正的分割线
|
||||
*/
|
||||
function isVerticalSplitLine(
|
||||
imageData: ImageData,
|
||||
x: number,
|
||||
minWhiteRatio: number = 0.95
|
||||
minWhiteRatio: number = 0.90
|
||||
): boolean {
|
||||
return getColWhiteRatio(imageData, x) >= minWhiteRatio;
|
||||
}
|
||||
@@ -192,9 +189,9 @@ async function detectGridLinesInternal(imageUrl: string): Promise<{
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
// 检测水平分割线,跳过边缘区域(前后 10%)
|
||||
// 检测水平分割线,跳过边缘区域(前后 5%)
|
||||
const horizontalLines: number[] = [];
|
||||
const marginY = Math.floor(height * 0.1);
|
||||
const marginY = Math.floor(height * 0.05);
|
||||
for (let y = marginY; y < height - marginY; y++) {
|
||||
if (isHorizontalSplitLine(imageData, y)) {
|
||||
horizontalLines.push(y);
|
||||
@@ -203,16 +200,16 @@ async function detectGridLinesInternal(imageUrl: string): Promise<{
|
||||
|
||||
// 检测垂直分割线
|
||||
const verticalLines: number[] = [];
|
||||
const marginX = Math.floor(width * 0.1);
|
||||
const marginX = Math.floor(width * 0.05);
|
||||
for (let x = marginX; x < width - marginX; x++) {
|
||||
if (isVerticalSplitLine(imageData, x)) {
|
||||
verticalLines.push(x);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证分割线宽度(至少 3 像素宽)并合并相邻分割线
|
||||
const validatedHorizontal = validateSplitLineWidth(horizontalLines, 3);
|
||||
const validatedVertical = validateSplitLineWidth(verticalLines, 3);
|
||||
// 验证分割线宽度(至少 2 像素宽)并合并相邻分割线
|
||||
const validatedHorizontal = validateSplitLineWidth(horizontalLines, 2);
|
||||
const validatedVertical = validateSplitLineWidth(verticalLines, 2);
|
||||
const mergedHorizontal = mergeSplitLines(validatedHorizontal, Math.floor(height * 0.02));
|
||||
const mergedVertical = mergeSplitLines(validatedVertical, Math.floor(width * 0.02));
|
||||
|
||||
@@ -353,7 +350,7 @@ export async function splitImageByLines(
|
||||
ctx.drawImage(img, finalSx, finalSy, finalSw, finalSh, 0, 0, finalSw, finalSh);
|
||||
|
||||
elements.push({
|
||||
imageData: canvas.toDataURL('image/png', 0.92),
|
||||
imageData: canvas.toDataURL('image/jpeg', 0.92),
|
||||
index: index++,
|
||||
width: finalSw,
|
||||
height: finalSh,
|
||||
@@ -391,16 +388,21 @@ export async function recursiveSplitElement(
|
||||
depth: number = 0,
|
||||
maxDepth: number = 3
|
||||
): Promise<SplitImageElement[]> {
|
||||
console.log(`[ImageSplitter] recursiveSplitElement depth=${depth}, element size=${element.width}x${element.height}`);
|
||||
|
||||
// 防止无限递归
|
||||
if (depth >= maxDepth) {
|
||||
console.log(`[ImageSplitter] Max depth ${maxDepth} reached, stopping recursion`);
|
||||
return [element];
|
||||
}
|
||||
|
||||
// 检测子元素中的分割线
|
||||
const detection = await detectGridLines(element.imageData);
|
||||
console.log(`[ImageSplitter] Depth ${depth}: detection result = ${detection.rows}x${detection.cols}, lines: h=${detection.rowLines.length}, v=${detection.colLines.length}`);
|
||||
|
||||
// 如果没有检测到分割线,返回当前元素
|
||||
if (detection.rows <= 1 && detection.cols <= 1) {
|
||||
console.log(`[ImageSplitter] Depth ${depth}: No split lines found, returning element as-is`);
|
||||
return [element];
|
||||
}
|
||||
|
||||
@@ -408,19 +410,23 @@ export async function recursiveSplitElement(
|
||||
|
||||
// 有分割线,继续拆分
|
||||
const subElements = await splitImageByLines(element.imageData, detection);
|
||||
console.log(`[ImageSplitter] Depth ${depth}: splitImageByLines returned ${subElements.length} elements`);
|
||||
|
||||
// 如果拆分结果和原来一样(只有 1 个),返回当前元素
|
||||
if (subElements.length <= 1) {
|
||||
console.log(`[ImageSplitter] Depth ${depth}: Only ${subElements.length} sub-element, returning original`);
|
||||
return [element];
|
||||
}
|
||||
|
||||
// 递归处理每个子元素
|
||||
const allResults: SplitImageElement[] = [];
|
||||
for (const subEl of subElements) {
|
||||
const recursiveResults = await recursiveSplitElement(subEl, depth + 1, maxDepth);
|
||||
for (let i = 0; i < subElements.length; i++) {
|
||||
console.log(`[ImageSplitter] Depth ${depth}: Recursing into sub-element ${i + 1}/${subElements.length}`);
|
||||
const recursiveResults = await recursiveSplitElement(subElements[i], depth + 1, maxDepth);
|
||||
allResults.push(...recursiveResults);
|
||||
}
|
||||
|
||||
console.log(`[ImageSplitter] Depth ${depth}: Returning ${allResults.length} total elements`);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
@@ -433,13 +439,11 @@ export async function recursiveSplitElement(
|
||||
* @param board - 画板实例
|
||||
* @param imageUrl - 图片 URL
|
||||
* @param sourceRect - 源图片的位置信息(用于计算插入位置)
|
||||
* @param gap - 图片间距(默认 20)
|
||||
*/
|
||||
export async function splitAndInsertImages(
|
||||
board: PlaitBoard,
|
||||
imageUrl: string,
|
||||
sourceRect?: SourceImageRect,
|
||||
gap: number = 20
|
||||
sourceRect?: SourceImageRect
|
||||
): Promise<{ success: boolean; count: number; error?: string }> {
|
||||
try {
|
||||
console.log('[ImageSplitter] Starting smart split...');
|
||||
@@ -449,7 +453,6 @@ export async function splitAndInsertImages(
|
||||
console.log('[ImageSplitter] Grid detection result:', detection);
|
||||
|
||||
let elements: SplitImageElement[] = [];
|
||||
let cols = 1;
|
||||
|
||||
if (detection.rows > 1 || detection.cols > 1) {
|
||||
// 网格分割线格式
|
||||
@@ -457,13 +460,10 @@ export async function splitAndInsertImages(
|
||||
const initialElements = await splitImageByLines(imageUrl, detection);
|
||||
|
||||
// 递归拆分每个元素
|
||||
console.log('[ImageSplitter] Recursively splitting each element...');
|
||||
for (const el of initialElements) {
|
||||
const recursiveResults = await recursiveSplitElement(el, 0, 3);
|
||||
elements.push(...recursiveResults);
|
||||
}
|
||||
|
||||
cols = Math.ceil(Math.sqrt(elements.length));
|
||||
} else {
|
||||
// 2. 尝试灵感图格式(已内置递归拆分)
|
||||
console.log('[ImageSplitter] Grid not detected, trying inspiration board mode...');
|
||||
@@ -483,9 +483,6 @@ export async function splitAndInsertImages(
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
}));
|
||||
|
||||
// 灵感图使用自适应列数
|
||||
cols = Math.ceil(Math.sqrt(elements.length));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,49 +499,77 @@ export async function splitAndInsertImages(
|
||||
|
||||
console.log(`[ImageSplitter] Split into ${elements.length} images (after recursive split)`);
|
||||
|
||||
// 3. 计算插入位置(在源图片下方,左对齐)
|
||||
// 3. 获取原图的实际像素尺寸,计算缩放比例
|
||||
const originalImage = await loadImage(imageUrl);
|
||||
const originalPixelWidth = originalImage.naturalWidth;
|
||||
const originalPixelHeight = originalImage.naturalHeight;
|
||||
|
||||
// 计算缩放比例(原图显示尺寸 / 原图像素尺寸)
|
||||
let scale = 1;
|
||||
if (sourceRect) {
|
||||
scale = Math.min(
|
||||
sourceRect.width / originalPixelWidth,
|
||||
sourceRect.height / originalPixelHeight
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[ImageSplitter] Original: ${originalPixelWidth}x${originalPixelHeight}, Display: ${sourceRect?.width}x${sourceRect?.height}, Scale: ${scale}`);
|
||||
|
||||
// 4. 计算插入位置(在源图片下方,左对齐)
|
||||
let baseX: number;
|
||||
let baseY: number;
|
||||
|
||||
if (sourceRect) {
|
||||
// 有源图片信息:在源图片正下方插入,左对齐
|
||||
baseX = sourceRect.x;
|
||||
baseY = sourceRect.y + sourceRect.height + gap;
|
||||
baseY = sourceRect.y + sourceRect.height + 20;
|
||||
} else {
|
||||
// 兜底:在画布左上角
|
||||
baseX = 100;
|
||||
baseY = 100;
|
||||
}
|
||||
|
||||
// 4. 按网格布局插入图片
|
||||
let currentX = baseX;
|
||||
let currentY = baseY;
|
||||
let rowMaxHeight = 0;
|
||||
let colIndex = 0;
|
||||
// 5. 使用网格布局,中心集中避免重叠
|
||||
const gap = 20; // 图片间距
|
||||
const count = elements.length;
|
||||
const cols = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / cols);
|
||||
|
||||
// 计算所有图片缩放后的最大尺寸
|
||||
const scaledElements = elements.map((el) => ({
|
||||
...el,
|
||||
scaledWidth: el.width * scale,
|
||||
scaledHeight: el.height * scale,
|
||||
}));
|
||||
|
||||
const maxScaledWidth = Math.max(...scaledElements.map((e) => e.scaledWidth));
|
||||
const maxScaledHeight = Math.max(...scaledElements.map((e) => e.scaledHeight));
|
||||
|
||||
// 计算单元格尺寸
|
||||
const cellWidth = maxScaledWidth + gap;
|
||||
const cellHeight = maxScaledHeight + gap;
|
||||
|
||||
console.log(`[ImageSplitter] Grid layout: ${rows}x${cols}, cell=${cellWidth.toFixed(1)}x${cellHeight.toFixed(1)}, scale=${scale}`);
|
||||
|
||||
// 按网格布局插入
|
||||
for (let i = 0; i < scaledElements.length; i++) {
|
||||
const element = scaledElements[i];
|
||||
const row = Math.floor(i / cols);
|
||||
const col = i % cols;
|
||||
|
||||
// 计算单元格位置
|
||||
const cellX = baseX + col * cellWidth;
|
||||
const cellY = baseY + row * cellHeight;
|
||||
|
||||
// 在单元格内居中
|
||||
const insertX = cellX + (cellWidth - element.scaledWidth) / 2;
|
||||
const insertY = cellY + (cellHeight - element.scaledHeight) / 2;
|
||||
|
||||
for (const element of elements) {
|
||||
// 插入图片
|
||||
const imageItem = {
|
||||
url: element.imageData,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
width: element.scaledWidth,
|
||||
height: element.scaledHeight,
|
||||
};
|
||||
|
||||
DrawTransforms.insertImage(board, imageItem, [currentX, currentY] as Point);
|
||||
|
||||
// 更新位置
|
||||
rowMaxHeight = Math.max(rowMaxHeight, element.height);
|
||||
colIndex++;
|
||||
|
||||
if (colIndex >= cols) {
|
||||
// 换行
|
||||
currentX = baseX;
|
||||
currentY += rowMaxHeight + gap;
|
||||
rowMaxHeight = 0;
|
||||
colIndex = 0;
|
||||
} else {
|
||||
currentX += element.width + gap;
|
||||
}
|
||||
DrawTransforms.insertImage(board, imageItem, [insertX, insertY] as Point);
|
||||
}
|
||||
|
||||
console.log('[ImageSplitter] Successfully inserted all images');
|
||||
|
||||
@@ -1003,7 +1003,7 @@ async function splitRegionRecursively(
|
||||
region.height
|
||||
);
|
||||
|
||||
const regionImageUrl = regionCanvas.toDataURL('image/png', 0.92);
|
||||
const regionImageUrl = regionCanvas.toDataURL('image/jpeg', 0.92);
|
||||
|
||||
// 递归拆分这个区域
|
||||
const subElements = await splitRegionRecursively(regionImageUrl, depth + 1, maxDepth);
|
||||
|
||||
Reference in new issue
Block a user