Browse Source

增加放大编辑的功能

feature/video-erase
Luckyu_js 1 week ago
parent
commit
26b15520d8
  1. 65
      src/components/__tests__/GenerationComposer.test.tsx
  2. 368
      src/components/composer/GenerationComposer.tsx
  3. 8
      src/i18n/index.tsx

65
src/components/__tests__/GenerationComposer.test.tsx

@ -70,8 +70,8 @@ vi.mock("@/i18n", () => {
"composer.videoStitchReady": "Video stitch ready",
"composer.videoStitchNeedTwo": "Connect at least 2 video clips to stitch.",
"composer.describeContent": "Describe content",
"composer.expand": "Expand composer",
"composer.collapse": "Collapse composer",
"composer.enlarge": "Enlarge composer",
"composer.shrink": "Shrink composer",
"composer.selectModel": "Select model",
"composer.mainPlaceholder": "Describe what you want to generate, press / for commands, @ to reference assets",
"composer.promptProvidedByUpstream": "Prompt provided by upstream",
@ -696,14 +696,6 @@ function getPromptEditor(expectedText?: string): HTMLDivElement {
return editor;
}
function queryPromptEditor(expectedText: string): HTMLDivElement | null {
return screen.queryAllByRole("textbox").find((element) =>
element instanceof HTMLDivElement &&
element.getAttribute("contenteditable") === "true" &&
element.textContent === expectedText
) as HTMLDivElement | undefined ?? null;
}
function setPromptEditorCaret(editor: HTMLDivElement, offset: number): void {
const selection = window.getSelection();
if (!selection) return;
@ -738,12 +730,16 @@ function queryGenerateButton(): HTMLElement | null {
return screen.queryByRole("button", { name: /Generate|生成/ });
}
function getCollapseButton(): HTMLElement {
return screen.getByRole("button", { name: /Collapse composer|收起底部栏/ });
function getEnlargeButton(): HTMLElement {
return screen.getByRole("button", { name: /Enlarge composer|放大/ });
}
function getExpandButton(): HTMLElement {
return screen.getByRole("button", { name: /Expand composer|展开底部栏/ });
function getShrinkButton(): HTMLElement {
const closeButton = document.querySelector(".ant-modal-close");
if (!(closeButton instanceof HTMLElement)) {
throw new Error("Enlarged composer modal close button not found");
}
return closeButton;
}
function getSelectModelButton(): HTMLElement {
@ -1262,40 +1258,52 @@ describe("GenerationComposer", () => {
expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument();
});
it("collapses and expands the bottom composer without losing the selected node context", () => {
it("enlarges and shrinks the composer without losing the selected node context", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
expectPromptEditorValue("visible prompt");
fireEvent.click(getCollapseButton());
fireEvent.click(getEnlargeButton());
expect(queryPromptEditor("visible prompt")).not.toBeInTheDocument();
// The node-anchored panel is replaced by the enlarged modal.
expect(screen.queryByTestId("node-inline-composer-panel")).not.toBeInTheDocument();
// The same editor keeps its value inside the enlarged modal.
expectPromptEditorValue("visible prompt");
expect(screen.getByText("apiyi_nano_banana_2")).toBeInTheDocument();
expect(screen.getByText("visible prompt")).toBeInTheDocument();
fireEvent.click(getExpandButton());
fireEvent.click(getShrinkButton());
expect(screen.getByTestId("node-inline-composer-panel")).toBeInTheDocument();
expectPromptEditorValue("visible prompt");
});
it("expands the collapsed composer when clicking the summary content", () => {
it("shrinks the enlarged composer when clicking outside the modal", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
fireEvent.click(getCollapseButton());
fireEvent.click(screen.getByText("visible prompt"));
fireEvent.click(getEnlargeButton());
expect(screen.queryByTestId("node-inline-composer-panel")).not.toBeInTheDocument();
const mask = document.querySelector(".ant-modal-wrap");
if (!(mask instanceof HTMLElement)) {
throw new Error("Enlarged composer modal mask not found");
}
// antd only closes on a full press+release that both land on the mask.
fireEvent.mouseDown(mask);
fireEvent.mouseUp(mask);
fireEvent.click(mask);
expect(screen.getByTestId("node-inline-composer-panel")).toBeInTheDocument();
expectPromptEditorValue("visible prompt");
});
it("opens model selection from the collapsed model label without expanding", () => {
it("opens model selection from inside the enlarged composer", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
fireEvent.click(getCollapseButton());
fireEvent.click(getEnlargeButton());
fireEvent.click(getSelectModelButton());
expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "image");
expect(queryPromptEditor("visible prompt")).not.toBeInTheDocument();
});
it("shows the concrete selected image model name in the composer", async () => {
@ -2148,15 +2156,12 @@ describe("GenerationComposer", () => {
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
});
it("expands from the collapsed summary without submitting", async () => {
it("enlarges the composer without submitting", async () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
await act(async () => {
fireEvent.click(getCollapseButton());
});
await act(async () => {
fireEvent.click(screen.getByText("visible prompt"));
fireEvent.click(getEnlargeButton());
});
expect(useWorkflowStore.getState().regenerateNode).not.toHaveBeenCalled();

368
src/components/composer/GenerationComposer.tsx

@ -419,7 +419,7 @@ function ComposerEditor({
const referenceMaterialsRef = useRef<ComposerReferenceMaterial[]>([]);
const [config, setConfig] = useState<ComposerConfig>(() => configFromDraft(initialDraft));
const [prompt, setPrompt] = useState(initialDraft.prompt);
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
const [isEnlarged, setIsEnlarged] = useState(false);
const [playingReferenceAudioKey, setPlayingReferenceAudioKey] = useState<string | null>(null);
const [priceState, setPriceState] = useState<{ amount: number | null; isLoading: boolean }>({
amount: null,
@ -841,6 +841,7 @@ function ComposerEditor({
onPersistConfig(previous.context, buildGenerationConfigForContext(previous.context));
setConfig(configFromDraft(initialDraft));
setPrompt(initialDraft.prompt);
setIsEnlarged(false);
referenceMaterialsRef.current = [];
previousContextRef.current = { key: contextKey, context };
// The current local draft belongs to the previous composer identity.
@ -851,6 +852,7 @@ function ComposerEditor({
if (isHidden) {
persistCurrentComposerConfig();
stopReferenceAudioPreview();
setIsEnlarged(false);
onHiddenPersisted?.();
return;
}
@ -993,189 +995,213 @@ function ComposerEditor({
onPersistConfig(context, generationConfig);
const { prompt: _prompt, ...nextConfig } = generationConfig;
void onRun(context, prompt, nextConfig);
if (isEnlarged) setIsEnlarged(false);
}, [
buildGenerationConfigForContext,
context,
isCurrentNodeRunning,
isEnlarged,
onCancelRunning,
onPersistConfig,
onRun,
prompt,
]);
return (
// 放大态输入框固定 17 行高度,超出后出现滚动条;普通态沿用 2~4 行。
const promptMinHeightPx = isEnlarged ? PROMPT_LINE_HEIGHT_PX * 17 : PROMPT_MIN_HEIGHT_PX;
const promptMaxHeightPx = isEnlarged ? PROMPT_LINE_HEIGHT_PX * 17 : PROMPT_MAX_HEIGHT_PX;
const composerBody = (
<>
<ComposerFrame>
<section
data-testid="node-inline-composer-panel"
className="nodrag nopan nowheel pointer-events-auto relative flex max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] shadow-[var(--shadow-panel)] backdrop-blur transition-[width] duration-200"
style={{ width: isComposerCollapsed ? 360 : COMPOSER_WIDTH }}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 text-[var(--text-muted)]">
<IconButton
label={isComposerCollapsed ? t("composer.expand") : t("composer.collapse")}
onClick={() => {
if (!isComposerCollapsed) persistCurrentComposerConfig();
setIsComposerCollapsed((value) => !value);
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
{isComposerCollapsed ? <path d="M7 17 17 7M8 7h9v9" /> : <path d="M17 7 7 17M16 17H7V8" />}
</svg>
</IconButton>
<ComposerPromptMaterials
hasMediaHeader={hasComposerMediaHeader}
headerStatusContent={undefined}
activeCapability={activeCapability}
nodeId={context.mode === "node-edit" ? context.node?.id : undefined}
selectedModel={config.selectedModel}
connectedTextCards={connectedTextCards}
referenceMaterials={referenceMaterials}
playingReferenceAudioKey={playingReferenceAudioKey}
onRemoveConnectedEdge={(edgeId) => onRemoveEdge(edgeId)}
onReferenceMaterialDragStart={(event, material) => {
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData("application/x-popiart-reference-material", getReferenceMaterialKey(material));
}}
onReferenceMaterialDrop={handleReferenceMaterialDrop}
onRemoveReferenceMaterial={(material) => material.edgeId && onRemoveEdge(material.edgeId)}
onToggleReferenceAudio={handleReferenceAudioPreviewToggle}
onStopReferenceAudio={stopReferenceAudioPreview}
onVideoSubTypeChange={(subType) => {
if (context.mode !== "node-edit" || !context.node) return;
const nextConfig = {
...config,
subType,
parameters: filterTaskRoutingParameters(config.parameters),
};
setConfig(nextConfig);
updateNodeData(context.node.id, {
subType,
parameters: nextConfig.parameters,
} as Partial<GenerateVideoNodeData>);
}}
promptInputRef={promptInputRef}
promptValue={prompt}
promptReadOnly={false}
promptMinHeightPx={promptMinHeightPx}
promptMaxHeightPx={promptMaxHeightPx}
referenceItems={mentionReferenceItems}
onDraftPatch={(patch) => {
const nextPrompt = typeof patch.prompt === "string" ? patch.prompt : prompt;
if (typeof patch.prompt === "string") {
setPrompt(patch.prompt);
}
if (patch.parameters) {
const nextParameters = patch.parameters;
const nextConfig = {
...config,
parameters: nextParameters,
};
setConfig(nextConfig);
}
}}
onSubmitShortcut={runAction}
bodyContent={processBody}
/>
{errorMessage && (
<div className="nowheel mx-2 mb-2 max-h-20 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{errorMessage}
</div>
)}
<div className="flex shrink-0 items-center gap-3 px-2 py-2 text-sm text-[var(--text-secondary)]">
{isProcessNode ? (
<div className="flex min-w-0 items-center gap-2 text-[var(--text-muted)]">
<span className="truncate">
{context.node?.type === "easeCurve"
? ((connectedInputs?.videos.length ?? 0) >= 1 ? t("composer.easeCurveReady") : t("composer.easeCurveNoVideo"))
: ((connectedInputs?.videos.length ?? 0) >= 2 ? t("composer.videoStitchReady") : t("composer.videoStitchNeedTwo"))}
</span>
</div>
{isComposerCollapsed ? (
<button
type="button"
onClick={() => setIsComposerCollapsed(false)}
className="flex h-14 min-w-0 items-center gap-3 px-3 pr-14 text-left"
) : (
<ComposerModelConfig
activeCapability={activeCapability}
config={config}
modelSchema={modelSchema}
extensionFields={extensionFields}
extraTaskParams={config.extraTaskParams ?? EMPTY_EXTRA_TASK_PARAMS}
canChooseModel={canChooseModel}
isProcessNode={false}
onModelNeedsDetail={onModelNeedsDetail}
onModelChangeStart={handleModelChangeStart}
onModelChangeComplete={handleModelChangeComplete}
onConfigChange={handleConfigChange}
onExtraTaskParamsChange={handleExtraTaskParamsChange}
onAspectRatioChange={(value) => {
if (context.mode === "node-edit" && context.node) onApplyNodeSize(context.node.id, value);
}}
getPopupContainer={getComposerModelPopupContainer}
selectModelPlaceholder={t("composer.selectModel")}
showLlmModelConfig={false}
llmModelConfigContent={null}
llmModelConfigSummary=""
/>
)}
<div className="ml-auto flex items-center gap-2">
{priceLabel ? (
<div
className="flex h-9 items-center gap-1 text-xs font-medium text-[var(--text-muted)]"
aria-live="polite"
title={modelPointCostLabel ?? "Loading points"}
>
<span className="min-w-0 flex-1 truncate text-sm text-[var(--text-primary)]">
{prompt || config.selectedModel.displayName || t("composer.describeContent")}
</span>
<span className="shrink-0 text-xs text-[var(--text-muted)]">
{isCurrentNodeRunning ? submitButtonLabel : priceLabel ?? submitButtonLabel}
</span>
</button>
) : (
<>
<ComposerPromptMaterials
hasMediaHeader={hasComposerMediaHeader}
headerStatusContent={undefined}
activeCapability={activeCapability}
nodeId={context.mode === "node-edit" ? context.node?.id : undefined}
selectedModel={config.selectedModel}
connectedTextCards={connectedTextCards}
referenceMaterials={referenceMaterials}
playingReferenceAudioKey={playingReferenceAudioKey}
onRemoveConnectedEdge={(edgeId) => onRemoveEdge(edgeId)}
onReferenceMaterialDragStart={(event, material) => {
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData("application/x-popiart-reference-material", getReferenceMaterialKey(material));
}}
onReferenceMaterialDrop={handleReferenceMaterialDrop}
onRemoveReferenceMaterial={(material) => material.edgeId && onRemoveEdge(material.edgeId)}
onToggleReferenceAudio={handleReferenceAudioPreviewToggle}
onStopReferenceAudio={stopReferenceAudioPreview}
onVideoSubTypeChange={(subType) => {
if (context.mode !== "node-edit" || !context.node) return;
const nextConfig = {
...config,
subType,
parameters: filterTaskRoutingParameters(config.parameters),
};
setConfig(nextConfig);
updateNodeData(context.node.id, {
subType,
parameters: nextConfig.parameters,
} as Partial<GenerateVideoNodeData>);
}}
promptInputRef={promptInputRef}
promptValue={prompt}
promptReadOnly={false}
promptMinHeightPx={PROMPT_MIN_HEIGHT_PX}
promptMaxHeightPx={PROMPT_MAX_HEIGHT_PX}
referenceItems={mentionReferenceItems}
onDraftPatch={(patch) => {
const nextPrompt = typeof patch.prompt === "string" ? patch.prompt : prompt;
if (typeof patch.prompt === "string") {
setPrompt(patch.prompt);
}
if (patch.parameters) {
const nextParameters = patch.parameters;
const nextConfig = {
...config,
parameters: nextParameters,
};
setConfig(nextConfig);
}
}}
onSubmitShortcut={runAction}
bodyContent={processBody}
/>
{errorMessage && (
<div className="nowheel mx-2 mb-2 max-h-20 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{errorMessage}
</div>
)}
<div className="flex shrink-0 items-center gap-3 px-2 py-2 text-sm text-[var(--text-secondary)]">
{isProcessNode ? (
<div className="flex min-w-0 items-center gap-2 text-[var(--text-muted)]">
<span className="truncate">
{context.node?.type === "easeCurve"
? ((connectedInputs?.videos.length ?? 0) >= 1 ? t("composer.easeCurveReady") : t("composer.easeCurveNoVideo"))
: ((connectedInputs?.videos.length ?? 0) >= 2 ? t("composer.videoStitchReady") : t("composer.videoStitchNeedTwo"))}
</span>
</div>
) : (
<ComposerModelConfig
activeCapability={activeCapability}
config={config}
modelSchema={modelSchema}
extensionFields={extensionFields}
extraTaskParams={config.extraTaskParams ?? EMPTY_EXTRA_TASK_PARAMS}
canChooseModel={canChooseModel}
isProcessNode={false}
onModelNeedsDetail={onModelNeedsDetail}
onModelChangeStart={handleModelChangeStart}
onModelChangeComplete={handleModelChangeComplete}
onConfigChange={handleConfigChange}
onExtraTaskParamsChange={handleExtraTaskParamsChange}
onAspectRatioChange={(value) => {
if (context.mode === "node-edit" && context.node) onApplyNodeSize(context.node.id, value);
}}
getPopupContainer={getComposerModelPopupContainer}
selectModelPlaceholder={t("composer.selectModel")}
showLlmModelConfig={false}
llmModelConfigContent={null}
llmModelConfigSummary=""
/>
)}
<div className="ml-auto flex items-center gap-2">
{priceLabel ? (
<div
className="flex h-9 items-center gap-1 text-xs font-medium text-[var(--text-muted)]"
aria-live="polite"
title={modelPointCostLabel ?? "Loading points"}
>
<svg className="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 4 14h6l-1 8 10-13h-6l0-7Z" />
</svg>
<span>{priceLabel}</span>
</div>
) : null}
<button
type="button"
onClick={runAction}
disabled={!canClickSubmit}
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
aria-label={submitButtonLabel}
title={submitButtonLabel}
>
{isCurrentNodeRunning ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
</svg>
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
)}
</button>
</div>
</div>
</>
)}
</section>
</ComposerFrame>
<svg className="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 4 14h6l-1 8 10-13h-6l0-7Z" />
</svg>
<span>{priceLabel}</span>
</div>
) : null}
<button
type="button"
onClick={runAction}
disabled={!canClickSubmit}
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
aria-label={submitButtonLabel}
title={submitButtonLabel}
>
{isCurrentNodeRunning ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
</svg>
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
)}
</button>
</div>
</div>
</>
);
if (isEnlarged) {
return (
<Modal
open
onCancel={() => {
persistCurrentComposerConfig();
setIsEnlarged(false);
}}
footer={null}
width={880}
centered
mask={{ closable: true }}
destroyOnHidden
getContainer={() => document.body}
title={null}
closable
closeIcon={
<span title={t("composer.shrink")} aria-label={t("composer.shrink")}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path d="M9 9 4 4m0 0v5m0-5h5M15 15l5 5m0 0v-5m0 5h-5" />
</svg>
</span>
}
styles={{ body: { padding: 0 } }}
className="composer-enlarged-modal"
>
<div className="flex flex-col">{composerBody}</div>
</Modal>
);
}
return (
<ComposerFrame>
<section
data-testid="node-inline-composer-panel"
className="nodrag nopan nowheel pointer-events-auto relative flex max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] shadow-[var(--shadow-panel)] backdrop-blur"
style={{ width: COMPOSER_WIDTH }}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 text-[var(--text-muted)]">
<IconButton
label={t("composer.enlarge")}
onClick={() => {
persistCurrentComposerConfig();
setIsEnlarged(true);
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
</IconButton>
</div>
{composerBody}
</section>
</ComposerFrame>
);
}

8
src/i18n/index.tsx

@ -783,8 +783,8 @@ const en = {
"composer.describeContent": "Describe what you want to generate",
"composer.mainPlaceholder": "Describe what you want to generate, press / for commands, @ to reference assets",
"composer.uploadReferenceImage": "Upload reference image",
"composer.expand": "Expand composer",
"composer.collapse": "Collapse composer",
"composer.enlarge": "Enlarge composer",
"composer.shrink": "Shrink composer",
"composer.style": "Style",
"composer.mark": "Mark",
"composer.focus": "Focus",
@ -1854,8 +1854,8 @@ const zhCN: Dictionary = {
"composer.describeContent": "描述你想生成的内容",
"composer.mainPlaceholder": "描述你想生成的内容,按@ 引用素材",
"composer.uploadReferenceImage": "上传参考图片",
"composer.expand": "展开底部栏",
"composer.collapse": "收起底部栏",
"composer.enlarge": "放大",
"composer.shrink": "缩小",
"composer.style": "风格",
"composer.mark": "标记",
"composer.focus": "聚焦",

Loading…
Cancel
Save