03a4410272
主要工作是共享同一套“画布内核与通用 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>
561 lines
19 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|