Files
Genarrative/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx
T
menghao 03a4410272
Project CI / Native shell tests (push) Failing after 11m56s
Project CI / Repository checks (push) Successful in 1m5s
Project CI / Frontend tests (push) Successful in 3m20s
Project CI / Backend tests (push) Successful in 4m1s
game agent无限画布开发 (#136)
主要工作是共享同一套“画布内核与通用 UI 源码”,网站和 Tauri 只分别实现宿主适配层。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/136
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-12 10:54:16 +08:00

561 lines
19 KiB
TypeScript

import { CanvasChromeButton } from '@genarrative/image-canvas-react';
import {
Check,
ChevronDown,
Circle,
Cpu,
ImageIcon,
ImagePlus,
Paintbrush,
Square,
} from 'lucide-react';
import type { ComponentType, SVGProps } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useState } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
import {
calculateEditorIconSpritesheetPrice,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
resolveExtraImageReferenceLimit,
UI_EXTRA_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import type {
UiAssetExtractionMark,
UiAssetExtractionState,
UiAssetExtractionTool,
} from './ImageCanvasUiAssetExtractionModel';
import {
buildUiAssetExtractionBrushPath,
resolveUiAssetExtractionGenerationPlan,
} from './ImageCanvasUiAssetExtractionModel';
type ImageCanvasUiAssetExtractionOverlayViewProps = {
variant?: 'ui-extraction' | 'quick-edit';
showIndexLabels?: boolean;
sourceLayer: CanvasLayer | null;
viewport: CanvasViewport;
state: UiAssetExtractionState | null;
onToolChange: (tool: UiAssetExtractionTool | null) => void;
onPointerStart: (point: { x: number; y: number }) => void;
onPointerMove: (point: { x: number; y: number }) => void;
onPointerEnd: () => void;
onModelChange?: (model: string) => void;
onRequestUpload?: () => void;
onRemoveReference?: (referenceId: string) => void;
onSubmit?: () => void;
hasPendingImageReferenceUploads?: boolean;
};
const TOOL_OPTIONS: Array<{
value: UiAssetExtractionTool;
label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
}> = [
{ value: 'rect', label: '矩形框选', icon: Square },
{ value: 'ellipse', label: '椭圆框选', icon: Circle },
{ value: 'brush', label: '画笔自由框选', icon: Paintbrush },
];
function resolvePointerImagePoint(
event: ReactPointerEvent<SVGSVGElement>,
layer: CanvasLayer,
) {
const rect = event.currentTarget.getBoundingClientRect();
const fallbackWidth = Math.max(1, layer.width);
const fallbackHeight = Math.max(1, layer.height);
const ratioX =
(event.clientX - rect.left) / (rect.width > 0 ? rect.width : fallbackWidth);
const ratioY =
(event.clientY - rect.top) /
(rect.height > 0 ? rect.height : fallbackHeight);
return {
x: Math.min(
Math.max(ratioX * Math.max(1, layer.originalWidth), 0),
Math.max(1, layer.originalWidth),
),
y: Math.min(
Math.max(ratioY * Math.max(1, layer.originalHeight), 0),
Math.max(1, layer.originalHeight),
),
};
}
function resolveMarkLabelPoint(mark: UiAssetExtractionMark) {
if (mark.tool === 'brush') {
return mark.points[0] ?? { x: 0, y: 0 };
}
return {
x: Math.min(mark.x, mark.x + mark.width),
y: Math.min(mark.y, mark.y + mark.height),
};
}
function resolveMarkPreviewBounds(
mark: UiAssetExtractionMark,
sourceLayer: CanvasLayer,
) {
const originalWidth = Math.max(1, sourceLayer.originalWidth);
const originalHeight = Math.max(1, sourceLayer.originalHeight);
const normalizeBounds = (bounds: {
x: number;
y: number;
width: number;
height: number;
}) => {
const left = Math.min(Math.max(bounds.x, 0), originalWidth);
const top = Math.min(Math.max(bounds.y, 0), originalHeight);
const right = Math.min(Math.max(bounds.x + bounds.width, 0), originalWidth);
const bottom = Math.min(
Math.max(bounds.y + bounds.height, 0),
originalHeight,
);
return {
x: left,
y: top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
};
};
if (mark.tool === 'brush') {
const xs = mark.points.map((point) => point.x);
const ys = mark.points.map((point) => point.y);
const left = Math.min(...xs);
const top = Math.min(...ys);
const right = Math.max(...xs);
const bottom = Math.max(...ys);
return normalizeBounds({
x: Number.isFinite(left) ? left : 0,
y: Number.isFinite(top) ? top : 0,
width: Math.max(1, Number.isFinite(right - left) ? right - left : 1),
height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1),
});
}
return normalizeBounds({
x: Math.min(mark.x, mark.x + mark.width),
y: Math.min(mark.y, mark.y + mark.height),
width: Math.max(1, Math.abs(mark.width)),
height: Math.max(1, Math.abs(mark.height)),
});
}
function resolveMarkPreviewFallbackSrc(sourceSrc: string) {
const normalizedSrc = sourceSrc.trim();
if (!normalizedSrc) {
return undefined;
}
if (/^(?:data|blob):/iu.test(normalizedSrc)) {
return normalizedSrc;
}
if (
/^https?:\/\//iu.test(normalizedSrc) &&
/[?&](?:x-oss-signature|OSSAccessKeyId|Signature)=/iu.test(normalizedSrc)
) {
return normalizedSrc;
}
return undefined;
}
function UiAssetExtractionMarkPreview({
mark,
index,
sourceLayer,
}: {
mark: UiAssetExtractionMark;
index: number;
sourceLayer: CanvasLayer;
}) {
const bounds = resolveMarkPreviewBounds(mark, sourceLayer);
const originalWidth = Math.max(1, sourceLayer.originalWidth);
const originalHeight = Math.max(1, sourceLayer.originalHeight);
const previewRefreshKey = sourceLayer.taskId ?? sourceLayer.resourceId;
return (
<div
key={mark.id}
aria-label={`框选区域预览 ${index + 1}`}
className="image-canvas-editor__ui-extraction-preview"
>
<ResolvedAssetImage
alt={`${sourceLayer.title}框选预览 ${index + 1}`}
src={sourceLayer.src}
objectKey={sourceLayer.objectKey}
fallbackSrc={resolveMarkPreviewFallbackSrc(sourceLayer.src)}
refreshKey={previewRefreshKey}
style={{
width: `${(originalWidth / bounds.width) * 100}%`,
height: `${(originalHeight / bounds.height) * 100}%`,
left: `${-(bounds.x / bounds.width) * 100}%`,
top: `${-(bounds.y / bounds.height) * 100}%`,
}}
/>
<span>{index + 1}</span>
</div>
);
}
function renderMarkPreview(
mark: UiAssetExtractionMark,
index: number,
sourceLayer: CanvasLayer,
) {
return (
<UiAssetExtractionMarkPreview
key={mark.id}
mark={mark}
index={index}
sourceLayer={sourceLayer}
/>
);
}
function renderMark(
mark: UiAssetExtractionMark,
index: number,
showIndexLabels: boolean,
) {
const markNode =
mark.tool === 'brush' ? (
<path
d={buildUiAssetExtractionBrushPath(mark)}
className="image-canvas-editor__ui-extraction-mark"
/>
) : mark.tool === 'ellipse' ? (
<ellipse
cx={mark.x + mark.width / 2}
cy={mark.y + mark.height / 2}
rx={Math.abs(mark.width) / 2}
ry={Math.abs(mark.height) / 2}
className="image-canvas-editor__ui-extraction-mark"
/>
) : (
<rect
x={Math.min(mark.x, mark.x + mark.width)}
y={Math.min(mark.y, mark.y + mark.height)}
width={Math.abs(mark.width)}
height={Math.abs(mark.height)}
className="image-canvas-editor__ui-extraction-mark"
/>
);
if (!showIndexLabels) {
return <g key={mark.id}>{markNode}</g>;
}
const labelPoint = resolveMarkLabelPoint(mark);
// 中文注释:序号直接落在红色圈选框左上角,生成模型可通过“1号红色圈选框”稳定对应用户修改说明。
return (
<g key={mark.id}>
{markNode}
<g
className="image-canvas-editor__ui-extraction-mark-label"
transform={`translate(${labelPoint.x} ${labelPoint.y})`}
>
<circle r="12" cx="0" cy="0" />
<text x="0" y="0">
{index + 1}
</text>
</g>
</g>
);
}
export function ImageCanvasUiAssetExtractionOverlayView({
variant = 'ui-extraction',
showIndexLabels = false,
sourceLayer,
viewport,
state,
onToolChange,
onPointerStart,
onPointerMove,
onPointerEnd,
onModelChange = () => {},
onRequestUpload,
onRemoveReference,
onSubmit,
hasPendingImageReferenceUploads = false,
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
if (!sourceLayer || !state) {
return null;
}
const isQuickEdit = variant === 'quick-edit';
const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])];
const canSubmit =
state.marks.length > 0 &&
state.status !== 'extracting' &&
!hasPendingImageReferenceUploads;
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
state.marks.length,
);
const extractionPrice = calculateEditorIconSpritesheetPrice(
state.model,
extractionPlan.imageSize,
);
const selectedModelLabel = getEditorImageModelDisplayName(state.model);
const screenFrame = {
left: viewport.x + sourceLayer.x * viewport.scale,
top: viewport.y + sourceLayer.y * viewport.scale,
width: sourceLayer.width * viewport.scale,
height: sourceLayer.height * viewport.scale,
};
return (
<>
<div
className="image-canvas-editor__ui-extraction-toolbar image-canvas-editor__ui-extraction-toolbar--right"
role="toolbar"
aria-label={isQuickEdit ? '快速编辑框选工具' : 'UI素材框选工具'}
style={{
left: screenFrame.left + screenFrame.width + 12,
top: screenFrame.top + screenFrame.height / 2,
}}
onPointerDown={(event) => event.stopPropagation()}
>
{TOOL_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = state.tool === option.value;
return (
<CanvasChromeButton
key={option.value}
pressed={isActive}
className={
isActive
? 'image-canvas-editor__ui-extraction-tool--active'
: undefined
}
label={option.label}
title={option.label}
icon={<Icon className="h-4 w-4" />}
onClick={() => onToolChange(isActive ? null : option.value)}
/>
);
})}
</div>
<svg
className="image-canvas-editor__ui-extraction-overlay"
role="application"
aria-label="UI素材框选画布"
viewBox={`0 0 ${Math.max(1, sourceLayer.originalWidth)} ${Math.max(
1,
sourceLayer.originalHeight,
)}`}
preserveAspectRatio="none"
style={{
left: screenFrame.left,
top: screenFrame.top,
width: screenFrame.width,
height: screenFrame.height,
zIndex: sourceLayer.zIndex + 40,
pointerEvents: state.tool ? 'auto' : 'none',
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
onPointerStart(resolvePointerImagePoint(event, sourceLayer));
}}
onPointerMove={(event) => {
event.preventDefault();
event.stopPropagation();
onPointerMove(resolvePointerImagePoint(event, sourceLayer));
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
onPointerEnd();
}}
onPointerCancel={(event) => {
event.preventDefault();
event.stopPropagation();
onPointerEnd();
}}
>
<rect
x="0"
y="0"
width={sourceLayer.originalWidth}
height={sourceLayer.originalHeight}
className="image-canvas-editor__ui-extraction-hit-area"
/>
{marks.map((mark, index) =>
renderMark(
mark,
index,
showIndexLabels && index < state.marks.length,
),
)}
</svg>
{!isQuickEdit ? (
<div
className="image-canvas-editor__ui-extraction-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
role="dialog"
aria-label="UI素材提取"
style={{
left: screenFrame.left + screenFrame.width / 2,
top: screenFrame.top + screenFrame.height + 12,
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__ui-extraction-panel-head">
使用框选工具框选你希望从画面中提取的素材
</div>
<div className="image-canvas-editor__reference-strip">
{state.references.map((reference, index) => (
<ImageCanvasReferenceSlot
key={reference.id}
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={`参考图${index + 1}`}
ariaLabel={reference.label || `参考图${index + 1}`}
title={reference.label}
disabled={state.status === 'extracting'}
onRemove={
onRemoveReference
? () => onRemoveReference(reference.id)
: undefined
}
removeLabel={`删除${reference.label || `参考图${index + 1}`}`}
/>
))}
{onRequestUpload ? (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="上传UI素材参考图"
disabled={state.status === 'extracting'}
isAdd
onClick={onRequestUpload}
/>
) : null}
</div>
<div
className="image-canvas-editor__ui-extraction-preview-list"
aria-label="预览列表"
>
{state.marks.length ? (
state.marks.map((mark, index) =>
renderMarkPreview(mark, index, sourceLayer),
)
) : (
<span className="image-canvas-editor__ui-extraction-preview-empty">
框选后显示预览
</span>
)}
</div>
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__ui-extraction-panel-footer">
<span
className="image-canvas-editor__readonly-generation-option"
aria-label="计划规格"
>
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
</span>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__ui-extraction-model">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`提取素材模型 ${selectedModelLabel}`}
aria-expanded={isModelMenuOpen}
disabled={state.status === 'extracting'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsModelMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{selectedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
{isModelMenuOpen ? (
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model"
label="提取素材模型选项"
placement="top-start"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
const selected = state.model === option.value;
return (
<button
key={option.value}
type="button"
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--model"
aria-pressed={selected}
onClick={() => {
const referenceLimit =
resolveExtraImageReferenceLimit(
option.value,
UI_EXTRA_REFERENCE_LIMIT,
1,
);
if (state.references.length > referenceLimit) {
window.alert(
`当前已有 ${state.references.length} 张参考图,${option.label} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
);
return;
}
onModelChange(option.value);
setIsModelMenuOpen(false);
}}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</button>
);
})}
</div>
</PlatformFloatingMenu>
) : null}
</div>
<PlatformActionButton
type="button"
aria-label="提取"
className="image-canvas-editor__generation-submit"
disabled={!canSubmit}
onClick={onSubmit}
>
{state.status === 'extracting'
? '提取中'
: `提取 · ${extractionPrice}泥点`}
</PlatformActionButton>
</div>
</div>
) : null}
{state.errorMessage ? (
<div
className="image-canvas-editor__ui-extraction-error"
role="alert"
style={{
left: screenFrame.left + screenFrame.width / 2,
top: screenFrame.top + screenFrame.height + 12,
}}
>
{state.errorMessage}
</div>
) : null}
</>
);
}