fix(*): 质量检查与版本发布 - 修复格式门禁和创作灵感兼容导出并发布 v1.6.2

This commit is contained in:
ddcat666
2026-10-08 20:43:15 +08:00
parent 04689257d2
commit 9174aae013
86 changed files with 22712 additions and 635 deletions

View File

@@ -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>
);
}

View File

@@ -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,
};
});

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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;
};

View File

@@ -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>
);
}