fix(*): 质量检查与版本发布 - 修复格式门禁和创作灵感兼容导出并发布 v1.6.2
This commit is contained in:
@@ -19,32 +19,65 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
const format = normalizeAudioFormatForConfig(config, config.audioFormat);
|
||||
const speed = normalizeAudioSpeedValue(config.audioSpeed);
|
||||
const selectVoice = (value: string) => onConfigChange("audioVoice", value);
|
||||
return <ImageSettingsTheme theme={theme}>
|
||||
<section className={className} style={{ color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} aria-label="音频生成参数">
|
||||
{showTitle ? <h3 className="text-base font-semibold">音频设置</h3> : null}
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>音色</legend>
|
||||
{streaming ? <>
|
||||
<p className="text-xs leading-5" style={{ color: theme.node.muted }}>无参考素材时按提示词生成;可连接最多 3 段音频或 1 张图片,图片与音频不能混用。</p>
|
||||
<Select className="w-full" ariaLabel="音色" value={voice} onChange={selectVoice} options={[{ value: "", label: "不指定音色" }, ...(voice ? [{ value: voice, label: `当前音色 · ${voice}` }] : [])]} />
|
||||
</> : <>
|
||||
<Input aria-label="渠道音色 ID" value={config.audioVoice || ""} list={listId} placeholder="输入渠道音色 ID" onChange={(event) => selectVoice(event.target.value)} />
|
||||
<datalist id={listId}>{currentVoice.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</datalist>
|
||||
<Radio.Group aria-label="常用音色" value={voice} onChange={(event) => selectVoice(event.target.value)} options={currentVoice} className="flex flex-wrap gap-2" />
|
||||
</>}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>音频格式</legend>
|
||||
<Radio.Group aria-label="音频格式" value={format} options={formats} onChange={(event) => onConfigChange("audioFormat", event.target.value)} className="flex flex-wrap gap-2" />
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>语速</legend>
|
||||
<Radio.Group aria-label="常用语速" value={speed} options={["0.75", "1", "1.25", "1.5"].map((value) => ({ value, label: audioSpeedLabel(value) }))} onChange={(event) => onConfigChange("audioSpeed", event.target.value)} />
|
||||
<InputNumber aria-label="自定义语速" min={0.25} max={4} step={0.05} value={Number(speed)} className="w-full" onChange={(value) => { if (value !== null) onConfigChange("audioSpeed", normalizeAudioSpeedValue(String(value))); }} />
|
||||
</fieldset>
|
||||
{!streaming ? <label className="flex flex-col gap-2 text-xs" style={{ color: theme.node.muted }}>声音指令
|
||||
<Input.TextArea aria-label="声音指令" rows={3} value={config.audioInstructions || ""} placeholder="例如:自然、温暖的旁白" onChange={(event) => onConfigChange("audioInstructions", event.target.value)} />
|
||||
</label> : null}
|
||||
</section>
|
||||
</ImageSettingsTheme>;
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<section className={className} style={{ color: theme.node.text }} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} aria-label="音频生成参数">
|
||||
{showTitle ? <h3 className="text-base font-semibold">音频设置</h3> : null}
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
音色
|
||||
</legend>
|
||||
{streaming ? (
|
||||
<>
|
||||
<p className="text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
无参考素材时按提示词生成;可连接最多 3 段音频或 1 张图片,图片与音频不能混用。
|
||||
</p>
|
||||
<Select className="w-full" ariaLabel="音色" value={voice} onChange={selectVoice} options={[{ value: "", label: "不指定音色" }, ...(voice ? [{ value: voice, label: `当前音色 · ${voice}` }] : [])]} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input aria-label="渠道音色 ID" value={config.audioVoice || ""} list={listId} placeholder="输入渠道音色 ID" onChange={(event) => selectVoice(event.target.value)} />
|
||||
<datalist id={listId}>
|
||||
{currentVoice.map((item) => (
|
||||
<option value={item.value} key={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<Radio.Group aria-label="常用音色" value={voice} onChange={(event) => selectVoice(event.target.value)} options={currentVoice} className="flex flex-wrap gap-2" />
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
音频格式
|
||||
</legend>
|
||||
<Radio.Group aria-label="音频格式" value={format} options={formats} onChange={(event) => onConfigChange("audioFormat", event.target.value)} className="flex flex-wrap gap-2" />
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="mb-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
语速
|
||||
</legend>
|
||||
<Radio.Group aria-label="常用语速" value={speed} options={["0.75", "1", "1.25", "1.5"].map((value) => ({ value, label: audioSpeedLabel(value) }))} onChange={(event) => onConfigChange("audioSpeed", event.target.value)} />
|
||||
<InputNumber
|
||||
aria-label="自定义语速"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
value={Number(speed)}
|
||||
className="w-full"
|
||||
onChange={(value) => {
|
||||
if (value !== null) onConfigChange("audioSpeed", normalizeAudioSpeedValue(String(value)));
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
{!streaming ? (
|
||||
<label className="flex flex-col gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
声音指令
|
||||
<Input.TextArea aria-label="声音指令" rows={3} value={config.audioInstructions || ""} placeholder="例如:自然、温暖的旁白" onChange={(event) => onConfigChange("audioInstructions", event.target.value)} />
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
</ImageSettingsTheme>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ export function composerReferences(inputs: NodeGenerationInput[], skills: Canvas
|
||||
const media = inputs.map((input): CanvasResourceReference => {
|
||||
const label = generationInputMentionLabel(input, inputs);
|
||||
return {
|
||||
id: `composer:${input.nodeId}`, nodeId: input.nodeId, kind: input.type, label,
|
||||
mentionToken: `@${label}`, title: input.title, active: true, text: input.text,
|
||||
id: `composer:${input.nodeId}`,
|
||||
nodeId: input.nodeId,
|
||||
kind: input.type,
|
||||
label,
|
||||
mentionToken: `@${label}`,
|
||||
title: input.title,
|
||||
active: true,
|
||||
text: input.text,
|
||||
previewUrl: input.sourceKind === "drawing" ? undefined : input.image?.dataUrl || input.previewUrl,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,9 +9,14 @@ export type CanvasImageCropRect = ImageCropRect;
|
||||
type CropProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void };
|
||||
const initialSelection: ImageCropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
|
||||
const grips: { name: string; vector: CropGrip }[] = [
|
||||
{ name: "nw", vector: [-1, -1] }, { name: "n", vector: [0, -1] }, { name: "ne", vector: [1, -1] },
|
||||
{ name: "w", vector: [-1, 0] }, { name: "e", vector: [1, 0] },
|
||||
{ name: "sw", vector: [-1, 1] }, { name: "s", vector: [0, 1] }, { name: "se", vector: [1, 1] },
|
||||
{ name: "nw", vector: [-1, -1] },
|
||||
{ name: "n", vector: [0, -1] },
|
||||
{ name: "ne", vector: [1, -1] },
|
||||
{ name: "w", vector: [-1, 0] },
|
||||
{ name: "e", vector: [1, 0] },
|
||||
{ name: "sw", vector: [-1, 1] },
|
||||
{ name: "s", vector: [0, 1] },
|
||||
{ name: "se", vector: [1, 1] },
|
||||
];
|
||||
type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null };
|
||||
|
||||
@@ -31,7 +36,8 @@ function CropEditor({ dataUrl, onClose, onConfirm }: CropProps) {
|
||||
if (event.button !== 0 || gesture.current || !stage.current || !size) return;
|
||||
const bounds = stage.current.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip };
|
||||
};
|
||||
@@ -42,61 +48,116 @@ function CropEditor({ dataUrl, onClose, onConfirm }: CropProps) {
|
||||
};
|
||||
const choose = (value: string) => {
|
||||
if (!size) return;
|
||||
const ratio = value === "原图" ? 1 : (() => { const [w, h] = value.split(":").map(Number); return (w * size.height) / (h * size.width); })();
|
||||
setSelection((box) => selectAspect(box, ratio)); setPreset(value); setLocked(true);
|
||||
const ratio =
|
||||
value === "原图"
|
||||
? 1
|
||||
: (() => {
|
||||
const [w, h] = value.split(":").map(Number);
|
||||
return (w * size.height) / (h * size.width);
|
||||
})();
|
||||
setSelection((box) => selectAspect(box, ratio));
|
||||
setPreset(value);
|
||||
setLocked(true);
|
||||
};
|
||||
return (
|
||||
<AppModal open centered width={780} title="裁剪图片" onCancel={onClose} onOk={() => size && onConfirm(selection)} okText="确认裁剪" okButtonProps={{ disabled: !size }}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div ref={stage} className="relative inline-block max-w-full touch-none select-none overflow-hidden rounded-lg"
|
||||
<div
|
||||
ref={stage}
|
||||
className="relative inline-block max-w-full touch-none select-none overflow-hidden rounded-lg"
|
||||
onPointerMove={(event) => {
|
||||
const drag = gesture.current;
|
||||
if (!drag || drag.pointer !== event.pointerId) return;
|
||||
const dx = (event.clientX - drag.origin[0]) / drag.display[0];
|
||||
const dy = (event.clientY - drag.origin[1]) / drag.display[1];
|
||||
setSelection(drag.grip ? resizeSelection(drag.selection, drag.grip, dx, dy, locked) : translateSelection(drag.selection, dx, dy));
|
||||
}} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={finish}>
|
||||
}}
|
||||
onPointerUp={finish}
|
||||
onPointerCancel={finish}
|
||||
onLostPointerCapture={finish}
|
||||
>
|
||||
<img src={dataUrl} alt="待裁剪图片" draggable={false} className="block max-h-[60vh] max-w-full" />
|
||||
{size ? <div role="group" aria-label="裁剪区域" tabIndex={0} className="absolute cursor-move border border-white outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
style={{ left: `${selection.x * 100}%`, top: `${selection.y * 100}%`, width: `${selection.width * 100}%`, height: `${selection.height * 100}%`, boxShadow: "0 0 0 100vmax rgb(0 0 0 / 55%)" }}
|
||||
onPointerDown={(event) => begin(event, null)}
|
||||
onKeyDown={(event) => {
|
||||
const vectors: Record<string, readonly [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
const direction = vectors[event.key];
|
||||
if (!direction) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const step = event.shiftKey ? 10 : 1;
|
||||
setSelection((box) => translateSelection(box, (direction[0] * step) / size.width, (direction[1] * step) / size.height));
|
||||
}}>
|
||||
<svg viewBox="0 0 3 3" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||
<path d="M1 0V3M2 0V3M0 1H3M0 2H3" stroke="white" strokeOpacity={0.5} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
{grips.map(({ name, vector }) => <button type="button" key={name} aria-label={`调整裁剪框 ${name}`} className="absolute size-3 rounded-full border border-black bg-white"
|
||||
style={{ left: `${(vector[0] + 1) * 50}%`, top: `${(vector[1] + 1) * 50}%`, transform: "translate(-50%, -50%)", cursor: `${name}-resize` }}
|
||||
onPointerDown={(event) => begin(event, vector)}
|
||||
{size ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="裁剪区域"
|
||||
tabIndex={0}
|
||||
className="absolute cursor-move border border-white outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
style={{ left: `${selection.x * 100}%`, top: `${selection.y * 100}%`, width: `${selection.width * 100}%`, height: `${selection.height * 100}%`, boxShadow: "0 0 0 100vmax rgb(0 0 0 / 55%)" }}
|
||||
onPointerDown={(event) => begin(event, null)}
|
||||
onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const vectors: Record<string, readonly [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
const direction = vectors[event.key];
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const step = event.shiftKey ? 10 : 1;
|
||||
const dx = (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0) / size.width;
|
||||
const dy = (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0) / size.height;
|
||||
setSelection((box) => resizeSelection(box, vector, dx, dy, locked));
|
||||
}} />)}
|
||||
</div> : null}
|
||||
setSelection((box) => translateSelection(box, (direction[0] * step) / size.width, (direction[1] * step) / size.height));
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 3 3" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||
<path d="M1 0V3M2 0V3M0 1H3M0 2H3" stroke="white" strokeOpacity={0.5} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
{grips.map(({ name, vector }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
aria-label={`调整裁剪框 ${name}`}
|
||||
className="absolute size-3 rounded-full border border-black bg-white"
|
||||
style={{ left: `${(vector[0] + 1) * 50}%`, top: `${(vector[1] + 1) * 50}%`, transform: "translate(-50%, -50%)", cursor: `${name}-resize` }}
|
||||
onPointerDown={(event) => begin(event, vector)}
|
||||
onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const step = event.shiftKey ? 10 : 1;
|
||||
const dx = (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0) / size.width;
|
||||
const dy = (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0) / size.height;
|
||||
setSelection((box) => resizeSelection(box, vector, dx, dy, locked));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="常用裁剪比例">
|
||||
{["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => <Button key={value} size="small" disabled={!size} aria-pressed={preset === value} onClick={() => choose(value)}>{value}</Button>)}
|
||||
{["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => (
|
||||
<Button key={value} size="small" disabled={!size} aria-pressed={preset === value} onClick={() => choose(value)}>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<output>{output ? `输出 ${output.width} × ${output.height} px` : error || "正在读取图片…"}</output>
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={!size} aria-pressed={locked} onClick={() => { setLocked(!locked); setPreset(""); }}>{locked ? "解除比例锁定" : "锁定当前比例"}</Button>
|
||||
<Button onClick={() => { setSelection(initialSelection); setLocked(false); setPreset(""); }}>重置</Button>
|
||||
<Button
|
||||
disabled={!size}
|
||||
aria-pressed={locked}
|
||||
onClick={() => {
|
||||
setLocked(!locked);
|
||||
setPreset("");
|
||||
}}
|
||||
>
|
||||
{locked ? "解除比例锁定" : "锁定当前比例"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelection(initialSelection);
|
||||
setLocked(false);
|
||||
setPreset("");
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
|
||||
@@ -29,8 +29,12 @@ function SplitEditor({ dataUrl, onClose, onConfirm }: SplitDialogProps) {
|
||||
<div className="relative max-w-full overflow-hidden">
|
||||
<img alt="待切分图片" src={dataUrl} draggable={false} className="block max-h-[340px] max-w-full object-contain" />
|
||||
<svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
{Array.from({ length: grid.columns - 1 }, (_, i) => <line key={`v${i}`} x1={(100 * (i + 1)) / grid.columns} x2={(100 * (i + 1)) / grid.columns} y1={0} y2={100} stroke="white" strokeWidth={1} vectorEffect="non-scaling-stroke" />)}
|
||||
{Array.from({ length: grid.rows - 1 }, (_, i) => <line key={`h${i}`} y1={(100 * (i + 1)) / grid.rows} y2={(100 * (i + 1)) / grid.rows} x1={0} x2={100} stroke="white" strokeWidth={1} vectorEffect="non-scaling-stroke" />)}
|
||||
{Array.from({ length: grid.columns - 1 }, (_, i) => (
|
||||
<line key={`v${i}`} x1={(100 * (i + 1)) / grid.columns} x2={(100 * (i + 1)) / grid.columns} y1={0} y2={100} stroke="white" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{Array.from({ length: grid.rows - 1 }, (_, i) => (
|
||||
<line key={`h${i}`} y1={(100 * (i + 1)) / grid.rows} y2={(100 * (i + 1)) / grid.rows} x1={0} x2={100} stroke="white" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption className="text-sm text-muted-foreground">{size ? `${size.width} × ${size.height} px` : error || "正在读取图片…"}</figcaption>
|
||||
@@ -43,7 +47,11 @@ function SplitEditor({ dataUrl, onClose, onConfirm }: SplitDialogProps) {
|
||||
</label>
|
||||
))}
|
||||
<output className="text-sm text-muted-foreground">{previewSize ? `每块约 ${previewSize.width} × ${previewSize.height} px;边缘像素会完整保留。` : "等待有效图片尺寸"}</output>
|
||||
{error || (size && !valid) ? <p role="alert" className="text-sm text-destructive">{error || "行列数超过图片像素尺寸,请减少切分数量"}</p> : null}
|
||||
{error || (size && !valid) ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error || "行列数超过图片像素尺寸,请减少切分数量"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
|
||||
@@ -29,7 +29,8 @@ function ResizeEditor({ dataUrl, onClose, onConfirm }: UpscaleDialogProps) {
|
||||
<figcaption className="text-sm text-muted-foreground">{size ? `原图 ${size.width} × ${size.height} px` : error || "正在读取图片…"}</figcaption>
|
||||
</figure>
|
||||
<div className="flex flex-col gap-5">
|
||||
<label className="flex flex-col gap-2 text-sm">目标长边
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
目标长边
|
||||
<Select aria-label="目标长边" value={target} onChange={setChosen} options={pixelTargets.map((pixels) => ({ value: pixels, label: `${pixels} px`, disabled: pixels <= edge }))} />
|
||||
</label>
|
||||
<fieldset className="space-y-2">
|
||||
@@ -41,7 +42,11 @@ function ResizeEditor({ dataUrl, onClose, onConfirm }: UpscaleDialogProps) {
|
||||
</Radio.Group>
|
||||
</fieldset>
|
||||
<output className="text-sm">{output ? `输出 ${output.width} × ${output.height} px` : "等待读取尺寸"}</output>
|
||||
{error || (size && !ready) ? <p role="alert" className="text-sm text-destructive">{error || (edge >= MAX_UPSCALE_LONG_EDGE ? "图片已达到 4K,无需放大" : "请选择大于原图的目标尺寸")}</p> : null}
|
||||
{error || (size && !ready) ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error || (edge >= MAX_UPSCALE_LONG_EDGE ? "图片已达到 4K,无需放大" : "请选择大于原图的目标尺寸")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
|
||||
@@ -21,16 +21,52 @@ export function CanvasSettingsPopover({ label, summary, children, placement = "t
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = useId();
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const change = (next: boolean) => { setOpen(next); onOpenChange?.(next); };
|
||||
const change = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
return (
|
||||
<Popover open={open} onOpenChange={change} trigger="click" placement={placement} getPopupContainer={getPopupContainer} autoAdjustOverflow={autoAdjustOverflow}
|
||||
arrow={false} destroyOnHidden styles={{ root: { zIndex: "var(--z-dialog-popover)" }, container: { background: theme.canvas.background, color: theme.node.text, padding: 12 } }}
|
||||
content={<section id={id} aria-label={label} data-canvas-no-zoom data-canvas-wheel-scroll className="canvas-image-settings-popover overflow-y-auto overscroll-contain" style={{ width: `min(${width}px, calc(100vw - 48px))`, maxHeight: "min(560px, calc(100dvh - 100px))" }}
|
||||
onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") change(false); }}>{children}</section>}>
|
||||
<Button type="text" size="small" aria-expanded={open} aria-controls={open ? id : undefined} aria-label={`${label}:${summary}`} title={`${label} · ${summary}`}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={change}
|
||||
trigger="click"
|
||||
placement={placement}
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoAdjustOverflow={autoAdjustOverflow}
|
||||
arrow={false}
|
||||
destroyOnHidden
|
||||
styles={{ root: { zIndex: "var(--z-dialog-popover)" }, container: { background: theme.canvas.background, color: theme.node.text, padding: 12 } }}
|
||||
content={
|
||||
<section
|
||||
id={id}
|
||||
aria-label={label}
|
||||
data-canvas-no-zoom
|
||||
data-canvas-wheel-scroll
|
||||
className="canvas-image-settings-popover overflow-y-auto overscroll-contain"
|
||||
style={{ width: `min(${width}px, calc(100vw - 48px))`, maxHeight: "min(560px, calc(100dvh - 100px))" }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") change(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? id : undefined}
|
||||
aria-label={`${label}:${summary}`}
|
||||
title={`${label} · ${summary}`}
|
||||
className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`}
|
||||
style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
||||
style={{ background: theme.node.fill, color: theme.node.text }}
|
||||
icon={<Settings2 className="size-3.5" />}
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
@@ -18,16 +18,44 @@ export function CanvasSizePicker({ value, className, onChange }: CanvasSizePicke
|
||||
};
|
||||
return (
|
||||
<div className={className} data-canvas-no-zoom onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Select ariaLabel="生成比例" className="canvas-compact-control canvas-control-select h-full w-full" placeholder="比例" showSearch
|
||||
value={value || undefined} options={options} searchValue={query} open={expanded} popupMatchSelectWidth={false}
|
||||
onSearch={(text) => { draft.current = text; setQuery(text); }} onOpenChange={setExpanded}
|
||||
onChange={commit} onBlur={() => { if (draft.current.trim()) commit(draft.current); else setExpanded(false); }}
|
||||
<Select
|
||||
ariaLabel="生成比例"
|
||||
className="canvas-compact-control canvas-control-select h-full w-full"
|
||||
placeholder="比例"
|
||||
showSearch
|
||||
value={value || undefined}
|
||||
options={options}
|
||||
searchValue={query}
|
||||
open={expanded}
|
||||
popupMatchSelectWidth={false}
|
||||
onSearch={(text) => {
|
||||
draft.current = text;
|
||||
setQuery(text);
|
||||
}}
|
||||
onOpenChange={setExpanded}
|
||||
onChange={commit}
|
||||
onBlur={() => {
|
||||
if (draft.current.trim()) commit(draft.current);
|
||||
else setExpanded(false);
|
||||
}}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "Enter" && draft.current.trim()) { event.preventDefault(); commit(draft.current); }
|
||||
if (event.key === "Escape") { draft.current = ""; setQuery(""); setExpanded(false); }
|
||||
if (event.key === "Enter" && draft.current.trim()) {
|
||||
event.preventDefault();
|
||||
commit(draft.current);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
draft.current = "";
|
||||
setQuery("");
|
||||
setExpanded(false);
|
||||
}
|
||||
}}
|
||||
popupRender={(menu) => <div onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>{menu}</div>} />
|
||||
popupRender={(menu) => (
|
||||
<div onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,26 @@ type PinchState = {
|
||||
initialScale: number;
|
||||
};
|
||||
|
||||
export function CanvasViewport({ interactive = true, containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: CanvasViewportProps) {
|
||||
export function CanvasViewport({
|
||||
interactive = true,
|
||||
containerRef,
|
||||
viewport,
|
||||
appearance,
|
||||
backgroundMode = "lines",
|
||||
onViewportChange,
|
||||
onViewportPreviewChange,
|
||||
onCanvasMouseDown,
|
||||
boxSelectEnabled = false,
|
||||
onCanvasDoubleClick,
|
||||
onCanvasDeselect,
|
||||
onContextMenu,
|
||||
onDrop,
|
||||
onFileDragEnter,
|
||||
onFileDragLeave,
|
||||
onFileDragOver,
|
||||
graphicsLayer,
|
||||
children,
|
||||
}: CanvasViewportProps) {
|
||||
const colorTheme = useActiveTheme();
|
||||
const resolvedAppearance = resolveCanvasAppearance(appearance, colorTheme);
|
||||
const panState = useRef({
|
||||
@@ -118,7 +137,9 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
const syncViewport = useCallback(() => { if (interactive) onViewportChange(viewportRef.current); }, [interactive, onViewportChange]);
|
||||
const syncViewport = useCallback(() => {
|
||||
if (interactive) onViewportChange(viewportRef.current);
|
||||
}, [interactive, onViewportChange]);
|
||||
|
||||
const scheduleViewportChange = useCallback(
|
||||
(next: ViewportTransform, commitAfterIdle = false) => {
|
||||
@@ -128,14 +149,15 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
const container = containerRef.current;
|
||||
if (container) container.dataset.canvasViewportInteracting = "true";
|
||||
nextViewportRef.current = next;
|
||||
if (!frameRef.current) frameRef.current = requestAnimationFrame((now) => {
|
||||
frameRef.current = null;
|
||||
const pending = nextViewportRef.current;
|
||||
if (!pending) return;
|
||||
const notify = now - lastPreviewNotifyRef.current >= 32;
|
||||
applyCanvasLiveViewport(containerRef.current, pending, notify);
|
||||
if (notify) lastPreviewNotifyRef.current = now;
|
||||
});
|
||||
if (!frameRef.current)
|
||||
frameRef.current = requestAnimationFrame((now) => {
|
||||
frameRef.current = null;
|
||||
const pending = nextViewportRef.current;
|
||||
if (!pending) return;
|
||||
const notify = now - lastPreviewNotifyRef.current >= 32;
|
||||
applyCanvasLiveViewport(containerRef.current, pending, notify);
|
||||
if (notify) lastPreviewNotifyRef.current = now;
|
||||
});
|
||||
if (!commitAfterIdle) return;
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
@@ -203,11 +225,14 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
|
||||
if (looksLikeTrackpadPan) {
|
||||
const panX = event.shiftKey && absX < 1 ? deltaY : deltaX;
|
||||
scheduleViewportChange({
|
||||
x: current.x - panX,
|
||||
y: current.y - (event.shiftKey && absX < 1 ? 0 : deltaY),
|
||||
k: current.k,
|
||||
}, true);
|
||||
scheduleViewportChange(
|
||||
{
|
||||
x: current.x - panX,
|
||||
y: current.y - (event.shiftKey && absX < 1 ? 0 : deltaY),
|
||||
k: current.k,
|
||||
},
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,11 +246,14 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
const worldX = (mouseX - current.x) / current.k;
|
||||
const worldY = (mouseY - current.y) / current.k;
|
||||
|
||||
scheduleViewportChange({
|
||||
x: mouseX - worldX * newScale,
|
||||
y: mouseY - worldY * newScale,
|
||||
k: newScale,
|
||||
}, true);
|
||||
scheduleViewportChange(
|
||||
{
|
||||
x: mouseX - worldX * newScale,
|
||||
y: mouseY - worldY * newScale,
|
||||
k: newScale,
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
[containerRef, scheduleViewportChange],
|
||||
);
|
||||
@@ -317,7 +345,6 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
setIsPanning(true);
|
||||
document.body.style.cursor = "grabbing";
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -425,16 +452,18 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
data-canvas-viewport
|
||||
data-canvas-pan-state={isPanning ? "grabbing" : isSpacePressed || !boxSelectEnabled ? "grab" : undefined}
|
||||
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : isSpacePressed || !boxSelectEnabled ? "cursor-grab" : "canvas-cursor-select"}`}
|
||||
style={{
|
||||
background: resolvedAppearance.background,
|
||||
overscrollBehavior: "none",
|
||||
"--canvas-live-x": `${viewport.x}px`,
|
||||
"--canvas-live-y": `${viewport.y}px`,
|
||||
"--canvas-live-scale": viewport.k,
|
||||
"--canvas-live-inverse-scale": 1 / Math.max(viewport.k, 0.05),
|
||||
"--canvas-committed-scale": viewport.k,
|
||||
"--canvas-live-scale-ratio": 1,
|
||||
} as React.CSSProperties}
|
||||
style={
|
||||
{
|
||||
background: resolvedAppearance.background,
|
||||
overscrollBehavior: "none",
|
||||
"--canvas-live-x": `${viewport.x}px`,
|
||||
"--canvas-live-y": `${viewport.y}px`,
|
||||
"--canvas-live-scale": viewport.k,
|
||||
"--canvas-live-inverse-scale": 1 / Math.max(viewport.k, 0.05),
|
||||
"--canvas-committed-scale": viewport.k,
|
||||
"--canvas-live-scale-ratio": 1,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={(event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
@@ -476,10 +505,7 @@ export function CanvasViewport({ interactive = true, containerRef, viewport, app
|
||||
>
|
||||
<CanvasGrid appearance={appearance} mode={backgroundMode} />
|
||||
{graphicsLayer}
|
||||
<div
|
||||
data-canvas-world-layer
|
||||
className="canvas-world-layer absolute origin-top-left"
|
||||
>
|
||||
<div data-canvas-world-layer className="canvas-world-layer absolute origin-top-left">
|
||||
<div data-canvas-world-raster-layer className="canvas-world-raster-layer absolute origin-top-left">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,12 @@ export function useCanvasNodeResize(node: CanvasNodeData, scale: number, minimum
|
||||
let previous = origin;
|
||||
const minimum = getNodeMinSize(node.type);
|
||||
const snapshot = {
|
||||
position: { ...node.position }, width: node.width, height: node.height, corner,
|
||||
minimumWidth: minimum.width, minimumHeight: minimumHeight ?? minimum.height,
|
||||
position: { ...node.position },
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
corner,
|
||||
minimumWidth: minimum.width,
|
||||
minimumHeight: minimumHeight ?? minimum.height,
|
||||
ratio: shouldKeepAspectRatio(node) ? (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1) : undefined,
|
||||
};
|
||||
const listeners = new AbortController();
|
||||
@@ -43,12 +47,32 @@ export function useCanvasNodeResize(node: CanvasNodeData, scale: number, minimum
|
||||
};
|
||||
target.setPointerCapture(pointer);
|
||||
target.addEventListener("pointermove", move, { signal: listeners.signal });
|
||||
target.addEventListener("pointerup", (next) => {
|
||||
if (next.pointerId !== pointer) return;
|
||||
try { move(next); } finally { stop(); }
|
||||
}, { signal: listeners.signal });
|
||||
target.addEventListener("pointercancel", (next) => { if (next.pointerId === pointer) stop(); }, { signal: listeners.signal });
|
||||
target.addEventListener("lostpointercapture", (next) => { if (next.pointerId === pointer) stop(); }, { signal: listeners.signal });
|
||||
target.addEventListener(
|
||||
"pointerup",
|
||||
(next) => {
|
||||
if (next.pointerId !== pointer) return;
|
||||
try {
|
||||
move(next);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
{ signal: listeners.signal },
|
||||
);
|
||||
target.addEventListener(
|
||||
"pointercancel",
|
||||
(next) => {
|
||||
if (next.pointerId === pointer) stop();
|
||||
},
|
||||
{ signal: listeners.signal },
|
||||
);
|
||||
target.addEventListener(
|
||||
"lostpointercapture",
|
||||
(next) => {
|
||||
if (next.pointerId === pointer) stop();
|
||||
},
|
||||
{ signal: listeners.signal },
|
||||
);
|
||||
window.addEventListener("blur", stop, { signal: listeners.signal });
|
||||
dispose.current = stop;
|
||||
};
|
||||
|
||||
@@ -11,15 +11,24 @@ export function ImageGenerationPending({ className, label = "正在生成图片"
|
||||
useEffect(() => {
|
||||
const update = () => setElapsed(Math.max(0, Date.now() - startedAt));
|
||||
const clock = window.setInterval(update, 1000);
|
||||
const onVisible = () => { if (!document.hidden) update(); };
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) update();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => { window.clearInterval(clock); document.removeEventListener("visibilitychange", onVisible); };
|
||||
return () => {
|
||||
window.clearInterval(clock);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [startedAt]);
|
||||
return (
|
||||
<div aria-busy="true" className={cn("flex flex-col items-center justify-center gap-3 rounded-lg bg-surface-active p-4 text-muted-foreground", compact ? "min-h-24" : "aspect-[4/3]", className)}>
|
||||
<LoaderCircle aria-hidden="true" className="size-5 animate-spin motion-reduce:animate-none" />
|
||||
<span role="status" className="text-sm font-medium">{label}</span>
|
||||
<time className="text-xs tabular-nums" dateTime={`PT${Math.floor(elapsed / 1000)}S`}>已等待 {formatDuration(elapsed)}</time>
|
||||
<span role="status" className="text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<time className="text-xs tabular-nums" dateTime={`PT${Math.floor(elapsed / 1000)}S`}>
|
||||
已等待 {formatDuration(elapsed)}
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user