fix(canvas): 提示词编辑 - 修复小视口溢出与窗口调整手柄失效

Refs #507
This commit is contained in:
ddCat
2026-09-18 13:19:57 +08:00
parent 10f2aa302c
commit 4eaa28b807
4 changed files with 72 additions and 27 deletions

View File

@@ -20,8 +20,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
画布图片/视频节点“放大编辑”窗口改为 `min(1200px, 92vw)`,编辑区最小高度 200px,右下角可对角拖拽,方向键可微调。不改变节点浮动面板的现有视觉。
- [ ] 点击放大后窗口明显大于节点面板;右下角拖动手柄可同时改宽高,最小约 560×320,不超出视口。
PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束、浏览器缩放窗口后的尺寸收敛、短屏滚动和展开态单一调整手柄。尺寸回归测试与 Vite 生产打包通过;真实 Chrome 组件验证(临时数据,未调用生成接口)覆盖图片/视频、参考图、首尾帧控件、明暗主题、窄屏/短屏、鼠标与键盘调整、切换节点重置。完整 `bun run build` 仍被既有 `canvas-cloud-agent-panel.tsx` 中未定义的 `markdownPlainText` 阻断。
- [ ] 点击放大后窗口明显大于节点面板;右下角拖动手柄可同时改宽高,桌面最小约 560×320,小视口优先限制在视口减 24px 内。
- [ ] 方向键可调整,Shift 加速;切换节点后尺寸恢复默认。
- [ ] 窗口缩小后弹窗尺寸自动收敛;短屏下可滚动访问编辑区、参数和提交按钮,右下角手柄保持可达。
- [ ] 展开态仅显示右下角窗口手柄;紧凑面板仍可独立调整输入高度。明暗主题继续跟随当前画布。
## 支付订单与对账 CSV 导出

View File

@@ -6,6 +6,7 @@ import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText,
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelQuoteRequest } from "@/lib/model-pricing";
@@ -67,9 +68,6 @@ const PROMPT_EDITOR_EXPANDED_MAX_LINES = 14;
const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)";
const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200;
const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420;
const PROMPT_EDITOR_MODAL_MIN_WIDTH = 560;
const PROMPT_EDITOR_MODAL_MIN_HEIGHT = 320;
const PROMPT_EDITOR_MODAL_VIEWPORT_MARGIN = 24;
export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
@@ -92,7 +90,6 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const [promptContentHeight, setPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, false));
const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, true));
const [manualPromptHeight, setManualPromptHeight] = useState<number | null>(null);
const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState<number | null>(null);
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const [autoLinkEnabled, setAutoLinkEnabled] = useState(true);
@@ -172,7 +169,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds);
const expandedComposerHeight = clampPromptHeight(manualExpandedPromptHeight ?? expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds);
const expandedComposerHeight = clampPromptHeight(expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds);
const measureExpandedModalSize = () => {
const rect = expandedModalRef.current?.getBoundingClientRect();
if (!rect?.width || !rect.height) return { width: PROMPT_EDITOR_MODAL_DEFAULT_WIDTH, height: PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT };
@@ -197,9 +194,20 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false));
setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true));
setManualPromptHeight(null);
setManualExpandedPromptHeight(null);
}, [node.id]);
useEffect(() => {
if (!expandedPromptOpen) return;
const constrainSize = () => setExpandedModalSize((size) => {
if (!size) return size;
const next = clampExpandedModalSize(size);
return next.width === size.width && next.height === size.height ? size : next;
});
constrainSize();
window.addEventListener("resize", constrainSize);
return () => window.removeEventListener("resize", constrainSize);
}, [expandedPromptOpen]);
useEffect(() => {
if (!creditsEnabled || !quoteRequest) {
setQuotedCredits(null);
@@ -455,7 +463,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const height = expanded ? expandedComposerHeight : composerHeight;
return (
<>
<div className={fill ? "canvas-node-composer-editor min-h-0 flex-1" : "canvas-node-composer-editor"} style={fill ? undefined : { height }}>
<div className={fill ? "canvas-node-composer-editor flex-1" : "canvas-node-composer-editor"} style={fill ? { minHeight: bounds.min } : { height, ...(expanded ? { flexShrink: 0 } : null) }}>
<ConnectedReferenceShelf
targetNodeId={node.id}
references={resolvedMentionReferences}
@@ -484,12 +492,12 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
aria-label={`${modeDisplayName(mode)}提示词`}
/>
</div>
<PromptResizeHandle
{!expanded && <PromptResizeHandle
height={height}
min={bounds.min}
max={bounds.max}
onResize={expanded ? setManualExpandedPromptHeight : setManualPromptHeight}
/>
onResize={setManualPromptHeight}
/>}
</>
);
};
@@ -551,6 +559,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
footer={null}
centered
width={expandedModalSize ? expandedModalSize.width : PROMPT_EDITOR_MODAL_WIDTH}
style={{ maxWidth: `calc(100vw - ${PROMPT_EDITOR_VIEWPORT_MARGIN}px)` }}
destroyOnHidden
onCancel={() => {
setExpandedPresetOpen(false);
@@ -561,15 +570,17 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
body: { minHeight: 0, padding: 0 },
}}
>
<div ref={expandedModalRef} className="relative flex min-h-0 flex-col gap-2.5 overflow-visible p-3" style={{ ...composerTokens, color: theme.node.text, ...(expandedModalSize ? { height: expandedModalSize.height } : null) }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
{renderPromptEditor(true, Boolean(expandedModalSize))}
{hasVideoPromptTools ? (
<div className="canvas-node-composer-parameters shrink-0">
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
</div>
) : null}
<div className="shrink-0">{renderComposerControls(true)}</div>
<div ref={expandedModalRef} className="relative flex min-h-0 flex-col" style={{ ...composerTokens, color: theme.node.text, maxHeight: `calc(100dvh - ${PROMPT_EDITOR_VIEWPORT_MARGIN}px)`, ...(expandedModalSize ? { height: expandedModalSize.height } : null) }}>
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto p-3">
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
{renderPromptEditor(true, Boolean(expandedModalSize))}
{hasVideoPromptTools ? (
<div className="canvas-node-composer-parameters shrink-0">
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
</div>
) : null}
<div className="shrink-0">{renderComposerControls(true)}</div>
</div>
<PromptModalResizeHandle size={expandedModalSize} measure={measureExpandedModalSize} onResize={setExpandedModalSize} accent={theme.node.muted} />
</div>
</Modal>
@@ -933,12 +944,7 @@ function PromptResizeHandle({ height, min, max, onResize }: { height: number; mi
}
function clampExpandedModalSize(size: { width: number; height: number }) {
const maxWidth = Math.max(PROMPT_EDITOR_MODAL_MIN_WIDTH, window.innerWidth - PROMPT_EDITOR_MODAL_VIEWPORT_MARGIN);
const maxHeight = Math.max(PROMPT_EDITOR_MODAL_MIN_HEIGHT, window.innerHeight - PROMPT_EDITOR_MODAL_VIEWPORT_MARGIN);
return {
width: Math.min(maxWidth, Math.max(PROMPT_EDITOR_MODAL_MIN_WIDTH, Math.round(size.width))),
height: Math.min(maxHeight, Math.max(PROMPT_EDITOR_MODAL_MIN_HEIGHT, Math.round(size.height))),
};
return clampPromptEditorModalSize(size, { width: window.innerWidth, height: window.innerHeight });
}
function PromptModalResizeHandle({ size, measure, onResize, accent }: { size: { width: number; height: number } | null; measure: () => { width: number; height: number }; onResize: (size: { width: number; height: number }) => void; accent: string }) {
@@ -989,7 +995,8 @@ function PromptModalResizeHandle({ size, measure, onResize, accent }: { size: {
return;
}
event.stopPropagation();
onResize(clampExpandedModalSize({ width: drag.width + event.clientX - drag.startX, height: drag.height + event.clientY - drag.startY }));
// The modal stays centered, so each edge moves by half the size change.
onResize(clampExpandedModalSize({ width: drag.width + 2 * (event.clientX - drag.startX), height: drag.height + 2 * (event.clientY - drag.startY) }));
}}
onPointerUp={finishResize}
onPointerCancel={finishResize}

View File

@@ -0,0 +1,12 @@
type Size = { width: number; height: number };
export const PROMPT_EDITOR_VIEWPORT_MARGIN = 24;
export function clampPromptEditorModalSize(size: Size, viewport: Size): Size {
const maxWidth = Math.max(1, viewport.width - PROMPT_EDITOR_VIEWPORT_MARGIN);
const maxHeight = Math.max(1, viewport.height - PROMPT_EDITOR_VIEWPORT_MARGIN);
return {
width: Math.min(maxWidth, Math.max(560, Math.round(size.width))),
height: Math.min(maxHeight, Math.max(320, Math.round(size.height))),
};
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { clampPromptEditorModalSize } from "../src/lib/canvas/canvas-prompt-editor-size";
describe("prompt editor window bounds", () => {
test("keeps desktop minimums and leaves a viewport margin", () => {
const viewport = { width: 1440, height: 900 };
expect(clampPromptEditorModalSize({ width: 100, height: 100 }, viewport)).toEqual({ width: 560, height: 320 });
expect(clampPromptEditorModalSize({ width: 2000, height: 2000 }, viewport)).toEqual({ width: 1416, height: 876 });
});
test("a narrow or short viewport takes precedence over desktop minimums", () => {
expect(clampPromptEditorModalSize({ width: 1200, height: 800 }, { width: 390, height: 844 })).toEqual({ width: 366, height: 800 });
expect(clampPromptEditorModalSize({ width: 1200, height: 800 }, { width: 844, height: 300 })).toEqual({ width: 820, height: 276 });
});
test("reconstrains a previously resized window after viewport shrink", () => {
const desktop = clampPromptEditorModalSize({ width: 1200, height: 800 }, { width: 1440, height: 900 });
const mobile = clampPromptEditorModalSize(desktop, { width: 390, height: 300 });
expect(mobile).toEqual({ width: 366, height: 276 });
expect(clampPromptEditorModalSize(mobile, { width: 390, height: 300 })).toEqual(mobile);
});
});