refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证
This commit is contained in:
@@ -1,164 +1,50 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Input, InputNumber, Radio } from "antd";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { audioFormatOptionsForConfig, audioSpeedLabel, audioVoiceOptionsForConfig, isDoubaoAudioConfig, normalizeAudioFormatForConfig, normalizeAudioSpeedValue, normalizeAudioVoiceForConfig } from "@/lib/audio-generation";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import type { CanvasAudioSettingKey } from "@/components/canvas/canvas-audio-settings-popover";
|
||||
|
||||
const speedOptions = ["0.75", "1", "1.25", "1.5"];
|
||||
type AudioSettingsPanelProps = { config: AiConfig; onConfigChange: (key: CanvasAudioSettingKey, value: string) => void; theme: CanvasTheme; showTitle?: boolean; className?: string };
|
||||
|
||||
type AudioSettingKey =
|
||||
| "audioVoice"
|
||||
| "audioFormat"
|
||||
| "audioSpeed"
|
||||
| "audioLanguage"
|
||||
| "audioDialect"
|
||||
| "audioInstructions"
|
||||
| "audioEmotionControlMethod"
|
||||
| "audioEmotionRandom"
|
||||
| "audioEmotionHappy"
|
||||
| "audioEmotionAngry"
|
||||
| "audioEmotionSad"
|
||||
| "audioEmotionAfraid"
|
||||
| "audioEmotionDisgusted"
|
||||
| "audioEmotionMelancholic"
|
||||
| "audioEmotionSurprised"
|
||||
| "audioEmotionCalm";
|
||||
|
||||
type AudioSettingsPanelProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: AudioSettingKey, value: string) => void;
|
||||
theme: CanvasTheme;
|
||||
showTitle?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) {
|
||||
const isDoubao = isDoubaoAudioConfig(config);
|
||||
export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4" }: AudioSettingsPanelProps) {
|
||||
const listId = useId();
|
||||
const streaming = isDoubaoAudioConfig(config);
|
||||
const voice = normalizeAudioVoiceForConfig(config, config.audioVoice);
|
||||
const voiceOptions = audioVoiceOptionsForConfig(config);
|
||||
const visibleVoiceOptions = voiceOptions.some((item) => item.value === voice) ? voiceOptions : [{ value: voice, label: `当前音色(${voice})` }, ...voiceOptions];
|
||||
const voices = audioVoiceOptionsForConfig(config);
|
||||
const currentVoice = voices.some((item) => item.value === voice) ? voices : [{ value: voice, label: voice || "不指定音色" }, ...voices];
|
||||
const formats = audioFormatOptionsForConfig(config);
|
||||
const format = normalizeAudioFormatForConfig(config, config.audioFormat);
|
||||
const formatOptions = audioFormatOptionsForConfig(config);
|
||||
const speed = normalizeAudioSpeedValue(config.audioSpeed);
|
||||
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-lg font-semibold">音频设置</div> : null}
|
||||
<SettingGroup title="音色" color={theme.node.muted}>
|
||||
{isDoubao ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border px-3 py-2 text-sm leading-6" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
不连接素材时,按提示词直接生成。连接音频时按参考音频生成,最多 3 段。连接图片时按参考图片生成,最多 1 张。图片和音频不能同时连接。
|
||||
</div>
|
||||
<select
|
||||
value={voice}
|
||||
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text, background: theme.spatial.elevated }}
|
||||
onChange={(event) => onConfigChange("audioVoice", event.target.value)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<option value="">不指定音色</option>
|
||||
{voice ? <option value={voice}>当前音色</option> : null}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={config.audioVoice || ""}
|
||||
placeholder="输入渠道音色 ID,例如 voice_001"
|
||||
list="audio-voice-options"
|
||||
className="h-9 w-full rounded-xl border bg-transparent px-3 text-sm outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(event) => onConfigChange("audioVoice", event.target.value)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<datalist id="audio-voice-options">
|
||||
{visibleVoiceOptions.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{visibleVoiceOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={voice === item.value} theme={theme} onClick={() => onConfigChange("audioVoice", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
<SettingGroup title="格式" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{formatOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={format === item.value} theme={theme} onClick={() => onConfigChange("audioFormat", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="语速" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{speedOptions.map((value) => (
|
||||
<OptionPill key={value} selected={speed === value} theme={theme} onClick={() => onConfigChange("audioSpeed", value)}>
|
||||
{audioSpeedLabel(value)}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
className="h-9 w-full rounded-full border bg-transparent px-3 text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||
value={config.audioSpeed || "1"}
|
||||
onChange={(event) => onConfigChange("audioSpeed", event.target.value)}
|
||||
onBlur={(event) => onConfigChange("audioSpeed", normalizeAudioSpeedValue(event.target.value))}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</SettingGroup>
|
||||
{!isDoubao ? (
|
||||
<SettingGroup title="声音指令" color={theme.node.muted}>
|
||||
<textarea
|
||||
value={config.audioInstructions || ""}
|
||||
placeholder="例如:自然、温暖、适合旁白。"
|
||||
className="thin-scrollbar h-20 w-full resize-none rounded-xl border bg-transparent px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onChange={(event) => onConfigChange("audioInstructions", event.target.value)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</SettingGroup>
|
||||
) : null}
|
||||
</div>
|
||||
</ImageSettingsTheme>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="text-xs font-medium" style={{ color }}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { ImageAsset } from "@/stores/use-asset-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export const CANVAS_IMAGE_ASSET_DND_TYPE = "application/x-infinite-canvas-image-asset";
|
||||
export const CANVAS_IMAGE_ASSET_DND_TYPE = "application/x-yingce-image-asset";
|
||||
|
||||
type TrayTab = "library" | "canvas";
|
||||
|
||||
|
||||
@@ -1,114 +1,17 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import { AudioSettingsPanel } from "@/components/audio-settings-panel";
|
||||
import { audioFormatLabelForConfig, audioSpeedLabel, audioVoiceLabelForConfig } from "@/lib/audio-generation";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasSettingsPopover, type CanvasSettingsPlacement } from "./canvas-settings-popover";
|
||||
|
||||
export type CanvasAudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioLanguage" | "audioDialect" | "audioInstructions" | "audioEmotionControlMethod" | "audioEmotionRandom" | "audioEmotionHappy" | "audioEmotionAngry" | "audioEmotionSad" | "audioEmotionAfraid" | "audioEmotionDisgusted" | "audioEmotionMelancholic" | "audioEmotionSurprised" | "audioEmotionCalm";
|
||||
type AudioPopoverProps = { config: AiConfig; onConfigChange: (key: CanvasAudioSettingKey, value: string) => void; buttonClassName?: string; placement?: CanvasSettingsPlacement };
|
||||
|
||||
type CanvasAudioSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
|
||||
buttonClassName?: string;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
};
|
||||
|
||||
export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasAudioSettingsPopoverProps) {
|
||||
export function CanvasAudioSettingsPopover({ config, onConfigChange, ...popover }: AudioPopoverProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const summary = `${audioVoiceLabelForConfig(config, config.audioVoice) || "不指定音色"} · ${audioFormatLabelForConfig(config, config.audioFormat)} · ${audioSpeedLabel(config.audioSpeed)}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
syncPosition();
|
||||
window.addEventListener("resize", syncPosition);
|
||||
window.addEventListener("scroll", syncPosition, true);
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncPosition);
|
||||
window.removeEventListener("scroll", syncPosition, true);
|
||||
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const panel = open && buttonRect ? <AudioSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`音频设置:${summary}`} title={`音频设置 · ${summary}`} onClick={() => setOpen((current) => !current)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioSettingsPortal({
|
||||
buttonRect,
|
||||
panelRef,
|
||||
placement,
|
||||
theme,
|
||||
config,
|
||||
onConfigChange,
|
||||
}: {
|
||||
buttonRect: DOMRect;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
placement: CanvasAudioSettingsPopoverProps["placement"];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
|
||||
}) {
|
||||
const width = 356;
|
||||
const gap = 8;
|
||||
const margin = 12;
|
||||
const alignRight = placement?.endsWith("Right");
|
||||
const alignCenter = placement === "top" || placement === "bottom";
|
||||
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
|
||||
const topPlacement = placement?.startsWith("top");
|
||||
const style = {
|
||||
position: "fixed",
|
||||
zIndex: "var(--z-dialog-popover)",
|
||||
width,
|
||||
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
|
||||
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
|
||||
background: theme.spatial.elevated,
|
||||
border: `1px solid ${theme.toolbar.border}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: `0 24px 72px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.08)`,
|
||||
padding: 12,
|
||||
overflowY: "auto",
|
||||
color: theme.node.text,
|
||||
} as const;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="canvas-image-settings-popover aceternity-floating-panel backdrop-blur-2xl"
|
||||
style={style}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<AudioSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
const parts = [audioVoiceLabelForConfig(config, config.audioVoice) || "不指定音色", audioFormatLabelForConfig(config, config.audioFormat), audioSpeedLabel(config.audioSpeed)];
|
||||
return <CanvasSettingsPopover {...popover} label="音频设置" summary={parts.join(" · ")}>
|
||||
<AudioSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} className="space-y-4" />
|
||||
</CanvasSettingsPopover>;
|
||||
}
|
||||
|
||||
22
web/src/components/canvas/canvas-composer-references.ts
Normal file
22
web/src/components/canvas/canvas-composer-references.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { generationInputMentionLabel, type NodeGenerationInput } from "./canvas-node-generation";
|
||||
|
||||
export function composerReferences(inputs: NodeGenerationInput[], skills: CanvasResourceReference[]): CanvasResourceReference[] {
|
||||
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,
|
||||
previewUrl: input.sourceKind === "drawing" ? undefined : input.image?.dataUrl || input.previewUrl,
|
||||
};
|
||||
});
|
||||
return [...skills.map((skill) => ({ ...skill, active: true, mentionToken: `@${skill.label}` })), ...media];
|
||||
}
|
||||
|
||||
export function insertComposerPreset(value: string, start: number, end: number, prompt: string): { text: string; caret: number } {
|
||||
const from = Math.max(0, Math.min(value.length, start));
|
||||
const to = Math.max(from, Math.min(value.length, end));
|
||||
const prefix = value.slice(0, from).replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1");
|
||||
const insertion = `${prompt} `;
|
||||
return { text: prefix + insertion + value.slice(to), caret: prefix.length + insertion.length };
|
||||
}
|
||||
@@ -1,493 +1,78 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||||
import { Button, Image } from "antd";
|
||||
import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, Video, X } from "lucide-react";
|
||||
|
||||
import { Button } from "antd";
|
||||
import { X } from "lucide-react";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { generationInputMentionLabel, normalizeGenerationNodeMentionTokens, type NodeGenerationInput } from "./canvas-node-generation";
|
||||
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
|
||||
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
|
||||
import type { CanvasGenerationMode, CanvasNodeMetadata, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
import { normalizeGenerationNodeMentionTokens, type NodeGenerationInput } from "./canvas-node-generation";
|
||||
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
|
||||
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { getEditableSelection, setEditableSelection } from "./canvas-mention-editable";
|
||||
import { composerReferences, insertComposerPreset } from "./canvas-composer-references";
|
||||
|
||||
type CanvasConfigComposerProps = {
|
||||
value: string;
|
||||
inputs: NodeGenerationInput[];
|
||||
skillReferences?: CanvasResourceReference[];
|
||||
generationMode?: CanvasGenerationMode;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
onChange: (value: string) => void;
|
||||
onMetadataChange?: (patch: Partial<CanvasNodeMetadata>) => void;
|
||||
onClose: () => void;
|
||||
workspaceMode?: CanvasWorkspaceMode;
|
||||
type ComposerProps = {
|
||||
value: string; inputs: NodeGenerationInput[]; skillReferences?: CanvasResourceReference[];
|
||||
generationMode?: CanvasGenerationMode; metadata?: CanvasNodeMetadata;
|
||||
onChange: (value: string) => void; onMetadataChange?: (patch: Partial<CanvasNodeMetadata>) => void;
|
||||
onClose: () => void; workspaceMode?: CanvasWorkspaceMode;
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "reference"; nodeId: string };
|
||||
|
||||
type MentionState = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
type ComposerCandidate =
|
||||
| {
|
||||
kind: "input";
|
||||
input: NodeGenerationInput;
|
||||
}
|
||||
| {
|
||||
kind: "skill";
|
||||
reference: CanvasResourceReference;
|
||||
};
|
||||
|
||||
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g;
|
||||
|
||||
export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode, metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: CanvasConfigComposerProps) {
|
||||
export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode = "image", metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: ComposerProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [presetOpen, setPresetOpen] = useState(false);
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const workflowVideoReferenceMode = generationMode === "video" && isCanvasWorkflowProvider(metadata);
|
||||
const normalizedValue = useMemo(() => normalizeGenerationNodeMentionTokens(value, inputs), [inputs, value]);
|
||||
const tokens = useMemo(() => parseComposerTokens(normalizedValue, inputs), [inputs, normalizedValue]);
|
||||
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
|
||||
const videoFrameOptions = useMemo(
|
||||
() =>
|
||||
inputs
|
||||
.filter((input) => input.type === "image" && input.image)
|
||||
.map((input) => ({ nodeId: input.nodeId, label: resourceLabel(input, inputs), title: input.title, previewUrl: input.image?.dataUrl })),
|
||||
[inputs],
|
||||
);
|
||||
const candidates = useMemo(() => {
|
||||
if (!mention) return [];
|
||||
const query = (mention.query || "").trim().toLowerCase();
|
||||
const items: ComposerCandidate[] = [...skillReferences.map((reference) => ({ kind: "skill" as const, reference })), ...inputs.map((input) => ({ kind: "input" as const, input }))];
|
||||
if (!query) return items;
|
||||
return items.filter((item) => candidateSearchText(item, inputs).toLowerCase().includes(query));
|
||||
}, [inputs, mention, skillReferences]);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const caret = useRef<{ start: number; end: number } | null>(null);
|
||||
const [presetsOpen, setPresetsOpen] = useState(false);
|
||||
const references = useMemo(() => composerReferences(inputs, skillReferences), [inputs, skillReferences]);
|
||||
const prompt = useMemo(() => normalizeGenerationNodeMentionTokens(value, inputs), [value, inputs]);
|
||||
const simple = workspaceMode === "simple";
|
||||
const workflow = generationMode === "video" && isCanvasWorkflowProvider(metadata);
|
||||
useEffect(() => { if (prompt !== value) onChange(prompt); }, [prompt, value, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedValue !== value) {
|
||||
onChange(normalizedValue);
|
||||
return;
|
||||
}
|
||||
if (document.activeElement === editorRef.current) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.textContent = "";
|
||||
tokens.forEach((token) => {
|
||||
if (token.type === "text") {
|
||||
editor.append(document.createTextNode(token.value));
|
||||
return;
|
||||
}
|
||||
const input = referenceById.get(token.nodeId);
|
||||
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
|
||||
});
|
||||
}, [inputs, normalizedValue, onChange, referenceById, theme, tokens, value]);
|
||||
|
||||
const syncFromEditor = () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const next = serializeEditor(editor);
|
||||
onChange(next);
|
||||
syncMention();
|
||||
const rememberCaret = () => {
|
||||
const editor = container.current?.querySelector<HTMLElement>("[contenteditable='true'],textarea");
|
||||
caret.current = editor instanceof HTMLTextAreaElement ? { start: editor.selectionStart, end: editor.selectionEnd } : getEditableSelection(editor || null);
|
||||
};
|
||||
|
||||
const syncMention = () => {
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match || (!inputs.length && !skillReferences.length)) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
const nextQuery = match[1] || "";
|
||||
if (mention?.query !== nextQuery) {
|
||||
setMention({ query: nextQuery });
|
||||
setActiveIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const closeMention = () => {
|
||||
setMention(null);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const insertCandidate = (candidate: ComposerCandidate) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
removeActiveMention();
|
||||
const space = document.createTextNode(" ");
|
||||
const node = candidate.kind === "skill" ? document.createTextNode(`@${candidate.reference.label}`) : createReferenceChip(candidate.input, inputs, theme, setImagePreview);
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (range) {
|
||||
range.insertNode(space);
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(space);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
} else {
|
||||
editor.append(node, space);
|
||||
placeCaretAtEnd(editor);
|
||||
}
|
||||
closeMention();
|
||||
onChange(serializeEditor(editor));
|
||||
};
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||||
|
||||
const insertPreset = (preset: CanvasPromptPreset) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
removeActiveSlash(editor);
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
const text = document.createTextNode(`${preset.prompt} `);
|
||||
if (range && editor.contains(range.commonAncestorContainer)) {
|
||||
range.insertNode(text);
|
||||
range.setStartAfter(text);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
} else {
|
||||
editor.append(text);
|
||||
placeCaretAtEnd(editor);
|
||||
}
|
||||
onChange(serializeEditor(editor));
|
||||
const position = caret.current || { start: prompt.length, end: prompt.length };
|
||||
const next = insertComposerPreset(prompt, position.start, position.end, preset.prompt);
|
||||
onChange(next.text);
|
||||
setPresetsOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const editor = container.current?.querySelector<HTMLElement>("[contenteditable='true'],textarea");
|
||||
editor?.focus();
|
||||
if (editor instanceof HTMLTextAreaElement) editor.setSelectionRange(next.caret, next.caret);
|
||||
else if (editor) setEditableSelection(editor, next.caret);
|
||||
});
|
||||
};
|
||||
|
||||
const frameOptions = references.filter((ref) => ref.kind === "image" && ref.previewUrl).map((ref) => ({ nodeId: ref.nodeId, label: ref.label, title: ref.title, previewUrl: ref.previewUrl }));
|
||||
return (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="canvas-config-composer aceternity-floating-panel rounded-xl p-4 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, color: theme.node.text }}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">{simpleMode ? "快速生成" : "组装提示词"}</div>
|
||||
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : workflowVideoReferenceMode ? "已连接媒体会按工作流字段顺序自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
|
||||
<section data-canvas-no-zoom className="canvas-config-composer rounded-xl bg-surface-active p-4" style={{ background: theme.spatial.elevated, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
|
||||
<header className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xs font-semibold">{simple ? "快速生成" : "组装提示词"}</h3>
|
||||
<p className="mt-1 text-xs opacity-60">{workflow ? "工作流按字段顺序接收连接素材" : "输入 @ 选择已连接素材或技能"}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{simpleMode ? null : <CanvasPresetPicker mode={generationMode || "image"} skillReferences={skillReferences} open={presetOpen} onOpenChange={setPresetOpen} onSelect={insertPreset} />}
|
||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||
{!simple ? <CanvasPresetPicker mode={generationMode} skillReferences={skillReferences} open={presetsOpen} onOpenChange={setPresetsOpen} onSelect={insertPreset} /> : null}
|
||||
<Button size="small" type="text" aria-label="关闭提示词编辑器" icon={<X className="size-4" />} onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
{generationMode === "video" && onMetadataChange && !simpleMode ? (
|
||||
<div className="mb-3 rounded-lg px-2 py-2" style={{ background: theme.node.fill }}>
|
||||
<CanvasVideoPromptTools
|
||||
metadata={metadata}
|
||||
frameOptions={videoFrameOptions}
|
||||
referenceMode={workflowVideoReferenceMode ? "all" : "frames"}
|
||||
referenceSummary={{
|
||||
imageCount: inputs.filter((input) => input.type === "image" || input.type === "character").length,
|
||||
videoCount: inputs.filter((input) => input.type === "video").length,
|
||||
audioCount: inputs.filter((input) => input.type === "audio").length,
|
||||
}}
|
||||
onMetadataChange={onMetadataChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="canvas-config-composer-editor relative rounded-lg" style={{ background: theme.node.fill }}>
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-4 top-3 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接素材或技能</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="thin-scrollbar min-h-32 max-h-[min(42vh,360px)] w-full overflow-y-auto whitespace-pre-wrap break-words px-4 py-3 text-sm leading-7 outline-none"
|
||||
style={{ color: theme.node.text }}
|
||||
onInput={() => {
|
||||
if (!composingRef.current) syncFromEditor();
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
syncFromEditor();
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
</header>
|
||||
{generationMode === "video" && onMetadataChange && !simple ? <div className="mb-3">
|
||||
<CanvasVideoPromptTools metadata={metadata} frameOptions={frameOptions} referenceMode={workflow ? "all" : "frames"}
|
||||
referenceSummary={{ imageCount: inputs.filter((input) => input.type === "image" || input.type === "character").length, videoCount: inputs.filter((input) => input.type === "video").length, audioCount: inputs.filter((input) => input.type === "audio").length }} onMetadataChange={onMetadataChange} />
|
||||
</div> : null}
|
||||
<div ref={container} className="canvas-config-composer-editor rounded-lg" style={{ background: theme.node.fill }} onBlurCapture={rememberCaret} onKeyUpCapture={rememberCaret} onPointerUpCapture={rememberCaret}>
|
||||
<CanvasResourceMentionTextarea aria-label="组装生成提示词" value={prompt} references={references} onChange={onChange} sendOnEnter={false}
|
||||
placeholder="输入提示词,按 @ 引用连接素材或技能" className="min-h-32 max-h-[min(42vh,360px)] w-full px-4 py-3 text-sm leading-7"
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "/" && !mention) window.setTimeout(() => setPresetOpen(true));
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
insertCandidate(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
|
||||
event.preventDefault();
|
||||
requestAnimationFrame(syncFromEditor);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(syncMention);
|
||||
}}
|
||||
onBlur={() => window.setTimeout(closeMention, 120)}
|
||||
/>
|
||||
{mention && candidates.length ? <MentionMenu candidates={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertCandidate} /> : null}
|
||||
if (event.key === "/" && !simple && !event.nativeEvent.isComposing) requestAnimationFrame(() => { rememberCaret(); setPresetsOpen(true); });
|
||||
}} />
|
||||
</div>
|
||||
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
function removeActiveSlash(editor: HTMLDivElement) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (!range || !editor.contains(range.commonAncestorContainer)) return;
|
||||
const node = range.startContainer;
|
||||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||
const value = node.textContent || "";
|
||||
const before = value.slice(0, range.startOffset);
|
||||
const match = /(^|\s)\/[\p{L}\p{N}_-]*$/u.exec(before);
|
||||
if (!match) return;
|
||||
node.textContent = `${before.slice(0, match.index)}${match[1]}${value.slice(range.startOffset)}`;
|
||||
range.setStart(node, match.index + match[1].length);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function MentionMenu({ candidates, allInputs, activeIndex, theme, onSelect }: { candidates: ComposerCandidate[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (candidate: ComposerCandidate) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex, candidates]);
|
||||
|
||||
const selectInput = (candidate: ComposerCandidate) => {
|
||||
if (selectedRef.current) return;
|
||||
selectedRef.current = true;
|
||||
onSelect(candidate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="aceternity-floating-panel absolute left-2 top-[calc(100%+6px)] z-[var(--z-modal)] max-h-56 w-64 overflow-y-auto rounded-lg border p-1" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border }}>
|
||||
{candidates.map((candidate, index) => (
|
||||
<button
|
||||
key={candidate.kind === "skill" ? candidate.reference.id : candidate.input.nodeId}
|
||||
ref={index === activeIndex ? activeItemRef : undefined}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition"
|
||||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectInput(candidate);
|
||||
}}
|
||||
>
|
||||
<ResourcePreview candidate={candidate} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{candidate.kind === "skill" ? candidate.reference.label : resourceLabel(candidate.input, allInputs)}</span>
|
||||
{candidate.kind !== "skill" ? <span className="block truncate opacity-65">{candidate.input.text || candidate.input.title}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcePreview({ candidate }: { candidate: ComposerCandidate }) {
|
||||
if (candidate.kind === "skill") {
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-cyan-500/12 text-cyan-600 dark:text-cyan-200">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const input = candidate.input;
|
||||
if (input.sourceKind === "drawing") {
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Pencil className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||
if (input.type === "video" && input.previewUrl) return <img src={input.previewUrl} alt="" className="size-9 rounded-md bg-black object-cover" loading="lazy" decoding="async" />;
|
||||
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function candidateSearchText(candidate: ComposerCandidate, inputs: NodeGenerationInput[]) {
|
||||
if (candidate.kind === "skill") return `技能 ${candidate.reference.label} ${candidate.reference.title} ${candidate.reference.text || ""}`;
|
||||
return `${resourceLabel(candidate.input, inputs)} ${candidate.input.title} ${candidate.input.text || ""}`;
|
||||
}
|
||||
|
||||
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.contentEditable = "false";
|
||||
wrapper.dataset.referenceNodeId = input.nodeId;
|
||||
wrapper.dataset.referenceToken = `@${generationInputMentionLabel(input, inputs)}`;
|
||||
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||||
Object.assign(wrapper.style, chipStyle(theme));
|
||||
const previewUrl = input.type === "image" && input.image && input.sourceKind !== "drawing" ? input.image.dataUrl : input.type === "video" ? input.previewUrl : "";
|
||||
if (previewUrl) {
|
||||
const image = document.createElement("img");
|
||||
image.src = previewUrl;
|
||||
image.alt = input.title;
|
||||
image.className = "size-6 rounded object-cover";
|
||||
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
|
||||
wrapper.appendChild(image);
|
||||
wrapper.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onImagePreview(previewUrl);
|
||||
});
|
||||
} else {
|
||||
wrapper.title = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.text || input.title;
|
||||
const text = document.createElement("span");
|
||||
text.className = "block truncate";
|
||||
text.textContent = input.sourceKind === "drawing" ? resourceLabel(input, inputs) : input.type === "text" ? input.text || input.title : input.title;
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function serializeEditor(editor: HTMLElement) {
|
||||
return serializeNodes(editor.childNodes).replace(/\uFEFF/g, "");
|
||||
}
|
||||
|
||||
function serializeNodes(nodes: NodeListOf<ChildNode>) {
|
||||
let result = "";
|
||||
nodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const referenceToken = node.dataset.referenceToken;
|
||||
if (referenceToken) result += referenceToken;
|
||||
else if (node.tagName === "BR") result += "\n";
|
||||
else result += serializeNodes(node.childNodes);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeActiveMention() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match) return;
|
||||
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
function deleteAdjacentReference(key: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = adjacentReferenceNode(range, key);
|
||||
if (!target) return false;
|
||||
const nextCaretNode = document.createTextNode("");
|
||||
target.replaceWith(nextCaretNode);
|
||||
range.setStart(nextCaretNode, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adjacentReferenceNode(range: Range, key: string) {
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const previous = key === "Backspace";
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container.textContent || "";
|
||||
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
|
||||
return findReferenceSibling(container, previous);
|
||||
}
|
||||
const children = Array.from(container.childNodes);
|
||||
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
|
||||
}
|
||||
|
||||
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
|
||||
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
|
||||
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
|
||||
return current instanceof HTMLElement && current.dataset.referenceNodeId ? current : null;
|
||||
}
|
||||
|
||||
function textBeforeCaret() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return "";
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
const editor = closestEditor(range.startContainer);
|
||||
if (!editor) return "";
|
||||
range.setStart(editor, 0);
|
||||
return range.toString();
|
||||
}
|
||||
|
||||
function closestEditor(node: Node) {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
return element?.closest("[contenteditable='true']") || null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(element: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function parseComposerTokens(value: string, inputs: NodeGenerationInput[]): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
const inputByLabel = new Map(inputs.map((input) => [generationInputMentionLabel(input, inputs), input]));
|
||||
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
|
||||
let lastIndex = 0;
|
||||
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
|
||||
if (match.index === undefined) continue;
|
||||
const input = match[1] ? inputByNodeId.get(match[1]) : inputByLabel.get(`${match[2]}${match[3]}`);
|
||||
const end = match.index + match[0].length;
|
||||
if (!input || (!match[1] && !hasMentionBoundary(value, end))) continue;
|
||||
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||||
tokens.push({ type: "reference", nodeId: input.nodeId });
|
||||
lastIndex = end;
|
||||
}
|
||||
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
|
||||
return generationInputMentionLabel(input, inputs);
|
||||
}
|
||||
|
||||
function hasMentionBoundary(value: string, index: number) {
|
||||
const char = value[index];
|
||||
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
|
||||
}
|
||||
|
||||
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
|
||||
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasSettingsPopover, type CanvasSettingsPlacement } from "./canvas-settings-popover";
|
||||
|
||||
type CanvasImageSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
@@ -16,121 +12,23 @@ type CanvasImageSettingsPopoverProps = {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
placement?: CanvasSettingsPlacement;
|
||||
autoAdjustOverflow?: boolean;
|
||||
showCount?: boolean;
|
||||
};
|
||||
|
||||
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: CanvasImageSettingsPopoverProps) {
|
||||
export function CanvasImageSettingsPopover({ config, onConfigChange, showCount = true, onMissingConfig: _unused, ...popover }: CanvasImageSettingsPopoverProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
|
||||
const normalized = normalizeImageValue(profile, config);
|
||||
const summaryParts = [
|
||||
...(profile.size.parameter !== "none" ? [imageSizeLabel(normalized.size)] : []),
|
||||
...(profile.quality.supported ? [imageQualityLabel(normalized.quality)] : []),
|
||||
...(showCount && profile.maxOutputs > 1 ? [`${normalized.count} 张`] : []),
|
||||
...(profile.transparentBackground.supported && normalized.transparentBackground === "true" ? ["透明"] : []),
|
||||
];
|
||||
const summary = summaryParts.join(" · ");
|
||||
const hasSettings = profile.size.parameter !== "none" || profile.quality.supported || profile.transparentBackground.supported || (showCount && profile.maxOutputs > 1);
|
||||
const updateOpen = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
if (document.activeElement instanceof HTMLElement && panelRef.current?.contains(document.activeElement)) document.activeElement.blur();
|
||||
setOpen(false);
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
syncPosition();
|
||||
window.addEventListener("resize", syncPosition);
|
||||
window.addEventListener("scroll", syncPosition, true);
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncPosition);
|
||||
window.removeEventListener("scroll", syncPosition, true);
|
||||
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} showCount={showCount} onConfigChange={onConfigChange} /> : null;
|
||||
|
||||
if (!hasSettings) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" 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" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSettingsPortal({
|
||||
buttonRect,
|
||||
panelRef,
|
||||
placement,
|
||||
theme,
|
||||
config,
|
||||
showCount,
|
||||
onConfigChange,
|
||||
}: {
|
||||
buttonRect: DOMRect;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
placement: CanvasImageSettingsPopoverProps["placement"];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
config: AiConfig;
|
||||
showCount: boolean;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
}) {
|
||||
const gap = 8;
|
||||
const margin = 12;
|
||||
const width = Math.min(420, window.innerWidth - margin * 2);
|
||||
const alignRight = placement?.endsWith("Right");
|
||||
const alignCenter = placement === "top" || placement === "bottom";
|
||||
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
|
||||
const topPlacement = placement?.startsWith("top");
|
||||
const style = {
|
||||
position: "fixed",
|
||||
zIndex: "var(--z-dialog-popover)",
|
||||
width,
|
||||
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
|
||||
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
|
||||
background: theme.canvas.background,
|
||||
border: `1px solid ${theme.toolbar.border}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: `0 24px 72px ${theme.spatial.shadow}`,
|
||||
padding: 12,
|
||||
overflowY: "auto",
|
||||
color: theme.node.text,
|
||||
} as const;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="canvas-image-settings-popover aceternity-floating-panel backdrop-blur-2xl"
|
||||
style={style}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image;
|
||||
if (!profile) return null;
|
||||
const current = normalizeImageValue(profile, config);
|
||||
const parts: string[] = [];
|
||||
if (profile.size.parameter !== "none") parts.push(imageSizeLabel(current.size));
|
||||
if (profile.quality.supported) parts.push(imageQualityLabel(current.quality));
|
||||
if (showCount && profile.maxOutputs > 1) parts.push(`${current.count} 张`);
|
||||
if (profile.transparentBackground.supported && current.transparentBackground === "true") parts.push("透明");
|
||||
if (!parts.length && !profile.transparentBackground.supported) return null;
|
||||
return <CanvasSettingsPopover {...popover} label="图像设置" summary={parts.join(" · ") || "背景"} width={420}>
|
||||
<ImageSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
|
||||
</CanvasSettingsPopover>;
|
||||
}
|
||||
|
||||
@@ -1,271 +1,103 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, Modal } from "antd";
|
||||
import { Check, Lock, LockOpen, X } from "lucide-react";
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import { Button } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { resolveCropPixels, type ImageCropRect } from "@/lib/canvas/image-operation-plan";
|
||||
import { resizeSelection, selectAspect, translateSelection, type CropGrip } from "@/lib/canvas/image-crop-selection";
|
||||
import { useImageToolSource } from "./use-image-tool-source";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
|
||||
export type CanvasImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type DragMode = "move" | "resize";
|
||||
type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
|
||||
type CropAspectPreset = "original" | "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "16:9" | "9:16";
|
||||
|
||||
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
||||
const minSize = 0.06;
|
||||
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
|
||||
const cropAspectPresets: Array<{ value: CropAspectPreset; label: string; ratio?: number }> = [
|
||||
{ value: "original", label: "原图" },
|
||||
{ value: "1:1", label: "1:1", ratio: 1 },
|
||||
{ value: "3:2", label: "3:2", ratio: 3 / 2 },
|
||||
{ value: "2:3", label: "2:3", ratio: 2 / 3 },
|
||||
{ value: "4:3", label: "4:3", ratio: 4 / 3 },
|
||||
{ value: "3:4", label: "3:4", ratio: 3 / 4 },
|
||||
{ value: "16:9", label: "16:9", ratio: 16 / 9 },
|
||||
{ value: "9:16", label: "9:16", ratio: 9 / 16 },
|
||||
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] },
|
||||
];
|
||||
type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null };
|
||||
|
||||
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
||||
const [lockedRatio, setLockedRatio] = useState<number | null>(null);
|
||||
const [activePreset, setActivePreset] = useState<CropAspectPreset | "custom" | null>(null);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
|
||||
export function CanvasNodeCropDialog(props: CropProps) {
|
||||
return props.open && props.dataUrl ? <CropEditor key={props.dataUrl} {...props} /> : null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCrop(defaultCrop);
|
||||
setLockedRatio(null);
|
||||
setActivePreset(null);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setImage(null);
|
||||
void readImageMeta(dataUrl).then((meta) => {
|
||||
if (!cancelled) setImage(meta);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dataUrl, open]);
|
||||
|
||||
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
|
||||
const box = boxRef.current?.getBoundingClientRect();
|
||||
if (!box) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const start = { x: event.clientX, y: event.clientY, crop };
|
||||
const move = (event: PointerEvent) => {
|
||||
const dx = (event.clientX - start.x) / box.width;
|
||||
const dy = (event.clientY - start.y) / box.height;
|
||||
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", lockedRatio, box));
|
||||
};
|
||||
const up = () => {
|
||||
document.removeEventListener("pointermove", move);
|
||||
document.removeEventListener("pointerup", up);
|
||||
};
|
||||
document.addEventListener("pointermove", move);
|
||||
document.addEventListener("pointerup", up);
|
||||
function CropEditor({ dataUrl, onClose, onConfirm }: CropProps) {
|
||||
const { size, error } = useImageToolSource(dataUrl);
|
||||
const [selection, setSelection] = useState(initialSelection);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [preset, setPreset] = useState("");
|
||||
const stage = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const output = size ? resolveCropPixels(size, selection) : null;
|
||||
const begin = (event: PointerEvent<HTMLElement>, grip: CropGrip | null) => {
|
||||
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.currentTarget.setPointerCapture(event.pointerId);
|
||||
gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip };
|
||||
};
|
||||
|
||||
const selectAspectPreset = (preset: (typeof cropAspectPresets)[number]) => {
|
||||
if (!image) return;
|
||||
const ratio = preset.ratio || image.width / image.height;
|
||||
setCrop((current) => fitCropToAspect(current, ratio, image));
|
||||
setLockedRatio(ratio);
|
||||
setActivePreset(preset.value);
|
||||
const finish = (event: PointerEvent<HTMLElement>) => {
|
||||
if (event.pointerId !== gesture.current?.pointer) return;
|
||||
gesture.current = null;
|
||||
if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) event.target.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const toggleAspectLock = () => {
|
||||
if (lockedRatio !== null) {
|
||||
setLockedRatio(null);
|
||||
setActivePreset(null);
|
||||
return;
|
||||
}
|
||||
if (!image) return;
|
||||
setLockedRatio((crop.width * image.width) / (crop.height * image.height));
|
||||
setActivePreset("custom");
|
||||
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 resetCrop = () => {
|
||||
setCrop(defaultCrop);
|
||||
setLockedRatio(null);
|
||||
setActivePreset(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
|
||||
<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={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
|
||||
<CropMask crop={crop} />
|
||||
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
|
||||
{handles.map((handle) => (
|
||||
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
|
||||
))}
|
||||
</div>
|
||||
<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}>
|
||||
<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)}
|
||||
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>
|
||||
<div className="mb-2 text-sm font-medium">常用比例</div>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-8">
|
||||
{cropAspectPresets.map((preset) => (
|
||||
<Button key={preset.value} size="small" type={activePreset === preset.value ? "primary" : "default"} disabled={!image} aria-pressed={activePreset === preset.value} onClick={() => selectAspectPreset(preset)}>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<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>)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
|
||||
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
|
||||
<span>比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
|
||||
{image ? (
|
||||
<span>
|
||||
原图 {image.width} x {image.height}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button disabled={!image} icon={lockedRatio !== null ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={toggleAspectLock}>
|
||||
{lockedRatio !== null ? "解除比例锁定" : "锁定当前比例"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button onClick={resetCrop}>重置</Button>
|
||||
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function CropMask({ crop }: { crop: CanvasImageCropRect }) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
|
||||
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
|
||||
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect {
|
||||
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
|
||||
}
|
||||
|
||||
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, lockedRatio: number | null, box: DOMRect): CanvasImageCropRect {
|
||||
if (lockedRatio !== null) return resizeLockedCrop(crop, dx, dy, handle, lockedRatio, box);
|
||||
let next = { ...crop };
|
||||
if (handle.includes("e")) next.width = crop.width + dx;
|
||||
if (handle.includes("s")) next.height = crop.height + dy;
|
||||
if (handle.includes("w")) {
|
||||
next.x = crop.x + dx;
|
||||
next.width = crop.width - dx;
|
||||
}
|
||||
if (handle.includes("n")) {
|
||||
next.y = crop.y + dy;
|
||||
next.height = crop.height - dy;
|
||||
}
|
||||
next.width = clamp(next.width, minSize, 1);
|
||||
next.height = clamp(next.height, minSize, 1);
|
||||
next.x = clamp(next.x, 0, 1 - next.width);
|
||||
next.y = clamp(next.y, 0, 1 - next.height);
|
||||
return next;
|
||||
}
|
||||
|
||||
function resizeLockedCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, ratio: number, box: DOMRect): CanvasImageCropRect {
|
||||
// 裁剪坐标按显示框归一化,像素宽高比需先换算到归一化坐标系。
|
||||
const normalizedRatio = (ratio * box.height) / box.width;
|
||||
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return crop;
|
||||
|
||||
const horizontal = handle.includes("e") || handle.includes("w");
|
||||
const vertical = handle.includes("n") || handle.includes("s");
|
||||
const candidateWidth = handle.includes("w") ? crop.width - dx : handle.includes("e") ? crop.width + dx : crop.width;
|
||||
const candidateHeight = handle.includes("n") ? crop.height - dy : handle.includes("s") ? crop.height + dy : crop.height;
|
||||
let width = candidateWidth;
|
||||
if (!horizontal || (vertical && Math.abs((candidateHeight - crop.height) * box.height) > Math.abs((candidateWidth - crop.width) * box.width))) {
|
||||
width = candidateHeight * normalizedRatio;
|
||||
}
|
||||
|
||||
// 角点固定对角,单边固定对边并以另一轴中心为锚点,避免锁定比例时裁剪框漂移。
|
||||
const anchorX = handle.includes("w") ? crop.x + crop.width : handle.includes("e") ? crop.x : crop.x + crop.width / 2;
|
||||
const anchorY = handle.includes("n") ? crop.y + crop.height : handle.includes("s") ? crop.y : crop.y + crop.height / 2;
|
||||
const maxWidthByX = handle.includes("w") ? anchorX : handle.includes("e") ? 1 - anchorX : 2 * Math.min(anchorX, 1 - anchorX);
|
||||
const maxHeightByY = handle.includes("n") ? anchorY : handle.includes("s") ? 1 - anchorY : 2 * Math.min(anchorY, 1 - anchorY);
|
||||
const maxWidth = Math.min(maxWidthByX, maxHeightByY * normalizedRatio);
|
||||
if (maxWidth <= 0) return crop;
|
||||
const minWidth = Math.min(maxWidth, Math.max(minSize, minSize * normalizedRatio));
|
||||
width = clamp(width, minWidth, maxWidth);
|
||||
const height = width / normalizedRatio;
|
||||
|
||||
const x = handle.includes("w") ? anchorX - width : handle.includes("e") ? anchorX : anchorX - width / 2;
|
||||
const y = handle.includes("n") ? anchorY - height : handle.includes("s") ? anchorY : anchorY - height / 2;
|
||||
return { x: clamp(x, 0, 1 - width), y: clamp(y, 0, 1 - height), width, height };
|
||||
}
|
||||
|
||||
function fitCropToAspect(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect {
|
||||
const normalizedRatio = (ratio * image.height) / image.width;
|
||||
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) return crop;
|
||||
// 切换快捷比例时尽量保持当前裁剪面积和中心位置,超出边界时再整体缩小。
|
||||
const area = crop.width * crop.height;
|
||||
let width = Math.sqrt(area * normalizedRatio);
|
||||
let height = width / normalizedRatio;
|
||||
const minimumScale = Math.max(1, minSize / width, minSize / height);
|
||||
width *= minimumScale;
|
||||
height *= minimumScale;
|
||||
const maximumScale = Math.min(1, 1 / width, 1 / height);
|
||||
width *= maximumScale;
|
||||
height *= maximumScale;
|
||||
const centerX = crop.x + crop.width / 2;
|
||||
const centerY = crop.y + crop.height / 2;
|
||||
return {
|
||||
x: clamp(centerX - width / 2, 0, 1 - width),
|
||||
y: clamp(centerY - height / 2, 0, 1 - height),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function cropStyle(crop: CanvasImageCropRect) {
|
||||
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
|
||||
}
|
||||
|
||||
function handleStyle(handle: ResizeHandle) {
|
||||
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
|
||||
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
|
||||
return { top, left, cursor: `${handle}-resize` };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatRatio(width: number, height: number) {
|
||||
const divisor = gcd(width, height);
|
||||
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
return b ? gcd(b, a % b) : Math.max(1, a);
|
||||
}
|
||||
|
||||
@@ -1,327 +1,107 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, Input, Modal, Slider } from "antd";
|
||||
import { Brush, ChevronDown, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { Button, Collapse, Input, Segmented, Slider } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { ImageSettingsPanel } from "@/components/image-settings-panel";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultImageParamsForModel } from "@/lib/model-selection";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { defaultImageParamsForModel } from "@/lib/model-selection";
|
||||
import { exportEditMask, paintMaskStrokes, type MaskPoint, type MaskStroke } from "@/lib/canvas/image-mask-document";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { useImageToolSource } from "./use-image-tool-source";
|
||||
|
||||
export type CanvasImageMaskEditPayload = {
|
||||
prompt: string;
|
||||
maskDataUrl: string;
|
||||
prompt: string;
|
||||
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality" | "count" | "transparentBackground">>;
|
||||
};
|
||||
type MaskEditorProps = { dataUrl: string; open: boolean; config: AiConfig; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void };
|
||||
|
||||
type DrawMode = "paint" | "erase";
|
||||
export function CanvasNodeMaskEditDialog(props: MaskEditorProps) {
|
||||
return props.open && props.dataUrl ? <MaskEditor key={props.dataUrl} {...props} /> : null;
|
||||
}
|
||||
|
||||
const defaultBrushSize = 100;
|
||||
const maskFillColor = "rgba(37, 99, 235, .38)";
|
||||
const maskBorderColor = "rgba(255, 255, 255, .72)";
|
||||
|
||||
export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onConfirm }: { dataUrl: string; open: boolean; config: AiConfig; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
|
||||
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
function MaskEditor({ dataUrl, config, onClose, onConfirm }: MaskEditorProps) {
|
||||
const { size, error: loadError } = useImageToolSource(dataUrl);
|
||||
const surface = useRef<HTMLCanvasElement>(null);
|
||||
const strokes = useRef<MaskStroke[]>([]);
|
||||
const activePointer = useRef<number | null>(null);
|
||||
const frame = useRef<number | null>(null);
|
||||
const [diameter, setDiameter] = useState(100);
|
||||
const [mode, setMode] = useState("paint");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [brushSize, setBrushSize] = useState(defaultBrushSize);
|
||||
const [mode, setMode] = useState<DrawMode>("paint");
|
||||
const [settings, setSettings] = useState(config);
|
||||
const [error, setError] = useState("");
|
||||
const [generationConfig, setGenerationConfig] = useState<AiConfig>(() => config);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [strokeCount, setStrokeCount] = useState(0);
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPrompt("");
|
||||
setBrushSize(defaultBrushSize);
|
||||
setMode("paint");
|
||||
setError("");
|
||||
setAdvancedOpen(true);
|
||||
setGenerationConfig(config);
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
clearCanvas(maskCanvasRef.current);
|
||||
clearCanvas(previewCanvasRef.current);
|
||||
}, [image]);
|
||||
|
||||
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
const context = maskCanvas?.getContext("2d");
|
||||
if (!maskCanvas || !context) return;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.lineWidth = brushSize;
|
||||
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
|
||||
context.strokeStyle = "#000";
|
||||
context.fillStyle = "#000";
|
||||
if (!drawingRef.current.last) {
|
||||
drawMaskStroke(context, point, point, brushSize);
|
||||
} else {
|
||||
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
|
||||
}
|
||||
renderMaskPreview(maskCanvas, previewCanvasRef.current);
|
||||
drawingRef.current.last = point;
|
||||
if (mode === "paint") {
|
||||
setError("");
|
||||
}
|
||||
useEffect(() => () => { if (frame.current !== null) cancelAnimationFrame(frame.current); }, []);
|
||||
const repaint = () => {
|
||||
if (frame.current !== null) return;
|
||||
frame.current = requestAnimationFrame(() => {
|
||||
frame.current = null;
|
||||
if (!surface.current) return;
|
||||
try { paintMaskStrokes(surface.current, strokes.current, "#2563eb"); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : "遮罩预览失败"); }
|
||||
});
|
||||
};
|
||||
|
||||
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drawingRef.current = { active: true, last: null };
|
||||
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
|
||||
draw(event);
|
||||
const pointAt = (event: PointerEvent<HTMLCanvasElement>): MaskPoint => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
return [((event.clientX - rect.left) * event.currentTarget.width) / Math.max(1, rect.width), ((event.clientY - rect.top) * event.currentTarget.height) / Math.max(1, rect.height)];
|
||||
};
|
||||
|
||||
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingRef.current.active) return;
|
||||
event.preventDefault();
|
||||
draw(event);
|
||||
const finishStroke = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
if (activePointer.current !== event.pointerId) return;
|
||||
activePointer.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setStrokeCount(strokes.current.length);
|
||||
};
|
||||
|
||||
const stopDraw = () => {
|
||||
drawingRef.current = { active: false, last: null };
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
|
||||
};
|
||||
|
||||
const resetMask = () => {
|
||||
clearCanvas(maskCanvasRef.current);
|
||||
clearCanvas(previewCanvasRef.current);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const nextPrompt = prompt.trim();
|
||||
const canvas = maskCanvasRef.current;
|
||||
if (!nextPrompt) return setError("请输入修改要求");
|
||||
if (!canvas) return;
|
||||
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
|
||||
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas), generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality, count: generationConfig.count, transparentBackground: generationConfig.transparentBackground } });
|
||||
if (!prompt.trim()) return setError("请填写修改要求");
|
||||
if (!size) return;
|
||||
let maskDataUrl: string;
|
||||
try { maskDataUrl = exportEditMask(size.width, size.height, strokes.current); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : "遮罩生成失败"); return; }
|
||||
const { model, imageModel, quality, count, transparentBackground } = settings;
|
||||
onConfirm({ prompt: prompt.trim(), maskDataUrl, generationConfig: { model, imageModel, size: settings.size, quality, count, transparentBackground } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal className="workspace-modal workspace-modal-wide" title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_340px]">
|
||||
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
|
||||
<img src={dataUrl} alt="" className="relative z-0 block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
|
||||
{image ? (
|
||||
<>
|
||||
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-crosshair touch-none"
|
||||
onPointerDown={startDraw}
|
||||
onPointerMove={moveDraw}
|
||||
onPointerUp={stopDraw}
|
||||
onPointerCancel={stopDraw}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<AppModal open centered title="局部重绘" className="workspace-modal workspace-modal-wide" onCancel={onClose} onOk={submit} okText="AI 修改" okButtonProps={{ disabled: !size }}>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<section className="flex min-w-0 flex-col items-center justify-center gap-3 rounded-lg bg-surface-active p-3">
|
||||
<div className="relative max-w-full select-none overflow-hidden rounded-lg">
|
||||
<img src={dataUrl} alt="局部重绘原图" draggable={false} className="block max-h-[62vh] max-w-full" />
|
||||
{size ? <canvas ref={surface} width={size.width} height={size.height} aria-label="涂抹需要修改的图片区域" className="absolute inset-0 h-full w-full touch-none cursor-crosshair opacity-40"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || activePointer.current !== null) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
activePointer.current = event.pointerId;
|
||||
strokes.current.push({ diameter, erase: mode === "erase", points: [pointAt(event)] });
|
||||
setError(""); repaint();
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (activePointer.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
strokes.current.at(-1)?.points.push(pointAt(event)); repaint();
|
||||
}} onPointerUp={finishStroke} onPointerCancel={finishStroke} onLostPointerCapture={finishStroke} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex max-h-[68vh] min-h-[360px] flex-col overflow-hidden">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">局部重绘</h2>
|
||||
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
画笔
|
||||
</Button>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
擦除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium opacity-75">笔刷大小</span>
|
||||
<span className="font-semibold">{brushSize}px</span>
|
||||
</div>
|
||||
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-75">修改要求</div>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={prompt}
|
||||
status={error && !prompt.trim() ? "error" : undefined}
|
||||
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((current) => !current)}
|
||||
>
|
||||
<span>高级生成设置</span>
|
||||
<ChevronDown className={`size-4 shrink-0 opacity-60 transition-transform ${advancedOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-3 pb-3 pt-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-75">生成模型</div>
|
||||
<ModelPicker
|
||||
config={generationConfig}
|
||||
value={generationConfig.imageModel || generationConfig.model}
|
||||
capability="image"
|
||||
fullWidth
|
||||
showSelectedPrice={false}
|
||||
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
|
||||
/>
|
||||
</div>
|
||||
<ImageSettingsPanel
|
||||
config={generationConfig}
|
||||
showTitle={false}
|
||||
showCount={false}
|
||||
bypassPriceGuard
|
||||
className="space-y-3"
|
||||
theme={theme}
|
||||
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{size ? `${size.width} × ${size.height} px · 蓝色区域将被重新生成` : loadError || "正在读取图片…"}</p>
|
||||
</section>
|
||||
<section className="flex max-h-[68vh] flex-col gap-4 overflow-y-auto pr-1">
|
||||
<Segmented block value={mode} onChange={setMode} options={[{ label: "画笔", value: "paint" }, { label: "擦除", value: "erase" }]} />
|
||||
<label className="text-sm">笔刷直径 · {diameter} px<Slider min={8} max={160} step={2} value={diameter} onChange={setDiameter} /></label>
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={!strokeCount} onClick={() => { strokes.current.pop(); setStrokeCount(strokes.current.length); repaint(); }}>撤销笔画</Button>
|
||||
<Button onClick={() => { strokes.current = []; activePointer.current = null; setStrokeCount(0); setError(""); repaint(); }}>清空遮罩</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border/50 pt-3">
|
||||
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
|
||||
重置
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
|
||||
AI 修改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm">修改要求<Input.TextArea rows={4} value={prompt} placeholder="描述选中区域需要发生的变化" onChange={(event) => { setPrompt(event.target.value); setError(""); }} /></label>
|
||||
<Collapse defaultActiveKey={["settings"]} items={[{ key: "settings", label: "生成设置", children: <div className="space-y-4">
|
||||
<ModelPicker config={settings} value={settings.imageModel || settings.model} capability="image" fullWidth showSelectedPrice={false} onChange={(model) => setSettings((previous) => ({ ...previous, ...defaultImageParamsForModel(previous, model), imageModel: model, model }))} />
|
||||
<ImageSettingsPanel config={settings} theme={theme} showTitle={false} showCount={false} bypassPriceGuard onConfigChange={(key, value) => setSettings((previous) => ({ ...previous, [key]: value }))} />
|
||||
</div> }]} />
|
||||
{error || loadError ? <p role="alert" className="text-sm text-destructive">{error || loadError}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width,
|
||||
y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function clearCanvas(canvas: HTMLCanvasElement | null) {
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, size: number) {
|
||||
if (from.x === to.x && from.y === to.y) {
|
||||
context.beginPath();
|
||||
context.arc(to.x, to.y, size / 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
context.beginPath();
|
||||
context.moveTo(from.x, from.y);
|
||||
context.lineTo(to.x, to.y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function canvasHasPaint(canvas: HTMLCanvasElement) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return false;
|
||||
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
for (let index = 3; index < data.length; index += 4) {
|
||||
if (data[index] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
|
||||
const context = previewCanvas?.getContext("2d");
|
||||
if (!previewCanvas || !context) return;
|
||||
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||
context.fillStyle = maskFillColor;
|
||||
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||
context.globalCompositeOperation = "destination-in";
|
||||
context.drawImage(maskCanvas, 0, 0);
|
||||
context.globalCompositeOperation = "source-over";
|
||||
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
|
||||
}
|
||||
|
||||
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
|
||||
const maskContext = maskCanvas.getContext("2d");
|
||||
if (!maskContext) return;
|
||||
const { width, height } = maskCanvas;
|
||||
const data = maskContext.getImageData(0, 0, width, height).data;
|
||||
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
|
||||
const dash = step * 8;
|
||||
const gap = step * 5;
|
||||
const period = dash + gap;
|
||||
|
||||
context.save();
|
||||
context.fillStyle = maskBorderColor;
|
||||
context.shadowColor = "rgba(0, 0, 0, .24)";
|
||||
context.shadowBlur = step * 1.5;
|
||||
for (let y = step; y < height - step; y += step) {
|
||||
for (let x = step; x < width - step; x += step) {
|
||||
const offset = (y * width + x) * 4 + 3;
|
||||
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
|
||||
if ((x + y) % period > dash) continue;
|
||||
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
|
||||
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
|
||||
}
|
||||
|
||||
function buildEditMask(selectionCanvas: HTMLCanvasElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = selectionCanvas.width;
|
||||
canvas.height = selectionCanvas.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return selectionCanvas.toDataURL("image/png");
|
||||
const selectionContext = selectionCanvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
if (!selectionContext) return canvas.toDataURL("image/png");
|
||||
const selection = selectionContext.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const mask = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let index = 3; index < mask.data.length; index += 4) {
|
||||
if (selection.data[index] > 0) mask.data[index] = 0;
|
||||
}
|
||||
context.putImageData(mask, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
@@ -1,102 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, InputNumber, Modal } from "antd";
|
||||
import { Grid2x2 } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
|
||||
import { useState } from "react";
|
||||
import { InputNumber } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { MAX_IMAGE_GRID, planImageTiles, type ImageSplitParams } from "@/lib/canvas/image-operation-plan";
|
||||
import { useImageToolSource } from "./use-image-tool-source";
|
||||
|
||||
export type CanvasImageSplitParams = ImageSplitParams;
|
||||
type SplitDialogProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void };
|
||||
|
||||
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
|
||||
const maxGridSize = 12;
|
||||
export function CanvasNodeSplitDialog(props: SplitDialogProps) {
|
||||
return props.open && props.dataUrl ? <SplitEditor key={props.dataUrl} {...props} /> : null;
|
||||
}
|
||||
|
||||
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const total = params.rows * params.columns;
|
||||
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setParams(defaultParams);
|
||||
setImage(null);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
|
||||
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
|
||||
function SplitEditor({ dataUrl, onClose, onConfirm }: SplitDialogProps) {
|
||||
const [grid, setGrid] = useState<ImageSplitParams>({ rows: 2, columns: 2 });
|
||||
const { size, error } = useImageToolSource(dataUrl);
|
||||
const valid = size !== null && size.width >= grid.columns && size.height >= grid.rows;
|
||||
const tiles = valid ? planImageTiles(size, grid) : [];
|
||||
const previewSize = tiles[0];
|
||||
const updateCount = (axis: keyof ImageSplitParams, value: number | null) => {
|
||||
if (value === null || !Number.isFinite(value)) return;
|
||||
setGrid((previous) => ({ ...previous, [axis]: Math.min(MAX_IMAGE_GRID, Math.max(1, Math.round(value))) }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">宫格切分</h2>
|
||||
<p className="mt-1 text-sm opacity-60">生成 {total} 个图片子节点,并按原图网格排列到画布右侧</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
|
||||
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
|
||||
<SplitGrid rows={params.rows} columns={params.columns} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="opacity-60">原图</span>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 py-2">
|
||||
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
|
||||
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="opacity-60">子节点</span>
|
||||
<span className="font-semibold">{total} 个</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="opacity-60">单块约</span>
|
||||
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
|
||||
生成子节点
|
||||
</Button>
|
||||
<AppModal open title="宫格切分" width={780} centered onCancel={onClose} okText={`生成 ${grid.rows * grid.columns} 个子节点`} onOk={() => valid && onConfirm(grid)} okButtonProps={{ disabled: !valid }}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">按行、列拆分原图,子节点保持网格顺序。</p>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_220px]">
|
||||
<figure className="m-0 flex min-w-0 flex-col items-center gap-3 rounded-lg border border-border bg-surface-active p-3">
|
||||
<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" />)}
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption className="text-sm text-muted-foreground">{size ? `${size.width} × ${size.height} px` : error || "正在读取图片…"}</figcaption>
|
||||
</figure>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(["rows", "columns"] as const).map((axis) => (
|
||||
<label key={axis} className="flex flex-col gap-2 text-sm">
|
||||
{axis === "rows" ? "行数" : "列数"}
|
||||
<InputNumber aria-label={axis === "rows" ? "切分行数" : "切分列数"} min={1} max={MAX_IMAGE_GRID} precision={0} value={grid[axis]} onChange={(value) => updateCount(axis, value)} />
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="font-medium opacity-75">{label}</span>
|
||||
<InputNumber className="w-full" min={1} max={maxGridSize} precision={0} value={value} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{Array.from({ length: columns - 1 }).map((_, index) => (
|
||||
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
|
||||
))}
|
||||
{Array.from({ length: rows - 1 }).map((_, index) => (
|
||||
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampGrid(value: string | number) {
|
||||
const numberValue = Number(value);
|
||||
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
|
||||
}
|
||||
|
||||
@@ -1,113 +1,49 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Modal, Segmented } from "antd";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data";
|
||||
import { useState } from "react";
|
||||
import { Radio, Select } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/image-operation-plan";
|
||||
import { useImageToolSource } from "./use-image-tool-source";
|
||||
|
||||
export type CanvasImageUpscaleParams = ImageUpscaleParams;
|
||||
type UpscaleDialogProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void };
|
||||
const pixelTargets = [1024, 2048, MAX_UPSCALE_LONG_EDGE];
|
||||
|
||||
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
|
||||
{ value: "high", title: "高质量插值", description: "适合照片和细节图" },
|
||||
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
|
||||
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
|
||||
];
|
||||
|
||||
const targetOptions = [
|
||||
{ label: "1K", value: 1024 },
|
||||
{ label: "2K", value: 2048 },
|
||||
{ label: "4K", value: MAX_UPSCALE_LONG_EDGE },
|
||||
];
|
||||
|
||||
const defaultParams: CanvasImageUpscaleParams = {
|
||||
targetLongEdge: 2048,
|
||||
algorithm: "high",
|
||||
};
|
||||
|
||||
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
|
||||
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
|
||||
const outputSize = useMemo(() => (image ? resolveUpscaleSize(image.width, image.height, params.targetLongEdge) : null), [image, params.targetLongEdge]);
|
||||
const canUpscale = Boolean(image && sourceLongEdge < params.targetLongEdge && params.targetLongEdge <= MAX_UPSCALE_LONG_EDGE);
|
||||
const reachedMax = Boolean(image && sourceLongEdge >= MAX_UPSCALE_LONG_EDGE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setParams(defaultParams);
|
||||
setImage(null);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image) return;
|
||||
const nextTarget = targetOptions.find((option) => sourceLongEdge < option.value)?.value || MAX_UPSCALE_LONG_EDGE;
|
||||
setParams((current) => ({ ...current, targetLongEdge: nextTarget }));
|
||||
}, [image, sourceLongEdge]);
|
||||
export function CanvasNodeUpscaleDialog(props: UpscaleDialogProps) {
|
||||
return props.open && props.dataUrl ? <ResizeEditor key={props.dataUrl} {...props} /> : null;
|
||||
}
|
||||
|
||||
function ResizeEditor({ dataUrl, onClose, onConfirm }: UpscaleDialogProps) {
|
||||
const { size, error } = useImageToolSource(dataUrl);
|
||||
const [chosen, setChosen] = useState<number | null>(null);
|
||||
const [algorithm, setAlgorithm] = useState<ImageUpscaleAlgorithm>("high");
|
||||
const edge = size ? Math.max(size.width, size.height) : 0;
|
||||
const target = chosen ?? pixelTargets.find((pixels) => pixels > edge) ?? MAX_UPSCALE_LONG_EDGE;
|
||||
const output = size ? resolveUpscaleSize(size.width, size.height, target) : null;
|
||||
const ready = Boolean(size && target > edge);
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">调整尺寸</h2>
|
||||
<p className="mt-2 text-sm opacity-60">通过插值放大像素尺寸,另存为新图片,保留原图。这不是 AI 超分,不会生成新的真实细节。</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="grid min-h-[280px] place-items-center rounded-lg bg-black/5">
|
||||
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="opacity-60">源图</span>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium opacity-75">目标像素</div>
|
||||
<Segmented
|
||||
block
|
||||
value={params.targetLongEdge}
|
||||
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
|
||||
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
|
||||
/>
|
||||
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium opacity-75">放大算法</div>
|
||||
<Segmented
|
||||
block
|
||||
value={params.algorithm}
|
||||
options={algorithms.map((item) => ({
|
||||
value: item.value,
|
||||
label: (
|
||||
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<span className="text-xs opacity-55">{item.description}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
onChange={(value) => setParams((current) => ({ ...current, algorithm: value as ImageUpscaleAlgorithm }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="opacity-60">输出尺寸</span>
|
||||
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
|
||||
生成放大图
|
||||
</Button>
|
||||
<AppModal open centered title="调整图片尺寸" width={760} onCancel={onClose} okText="生成放大图" okButtonProps={{ disabled: !ready }} onOk={() => ready && onConfirm({ targetLongEdge: target, algorithm })}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">插值放大并另存为新图片,原图保留。此操作不生成新的真实细节。</p>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<figure className="m-0 flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-surface-active p-3">
|
||||
<img alt="待调整尺寸的图片" src={dataUrl} draggable={false} className="max-h-[320px] max-w-full object-contain" />
|
||||
<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">目标长边
|
||||
<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">
|
||||
<legend className="mb-2 text-sm">插值方式</legend>
|
||||
<Radio.Group value={algorithm} onChange={(event) => setAlgorithm(event.target.value)} className="flex flex-col gap-2">
|
||||
<Radio value="high">高质量 · 照片与细节图</Radio>
|
||||
<Radio value="bilinear">双线性 · 平滑快速</Radio>
|
||||
<Radio value="nearest">最近邻 · 像素图</Radio>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Position } from "@/types/canvas";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
|
||||
import { getNodeDefinition, getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import type { NodeResizeCorner } from "@/lib/canvas/node-resize-geometry";
|
||||
import { useCanvasNodeResize } from "./use-canvas-node-resize";
|
||||
import { CanvasNodeContent, CanvasNodeImageInfo, CanvasNodeProducedModel } from "./canvas-node-content";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
|
||||
type CanvasNodeProps = {
|
||||
@@ -140,18 +141,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
|
||||
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const resizeRef = useRef({
|
||||
isResizing: false,
|
||||
corner: "bottom-right" as ResizeCorner,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startLeft: 0,
|
||||
startTop: 0,
|
||||
startWidth: 0,
|
||||
startHeight: 0,
|
||||
keepRatio: false,
|
||||
ratio: 1,
|
||||
});
|
||||
const handleResizePointerDown = useCanvasNodeResize(data, scale, scriptMinHeight, onResize, readOnly);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
@@ -188,81 +178,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
|
||||
}, [isEditingContent]);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!resizeRef.current.isResizing) return;
|
||||
|
||||
const dx = (event.clientX - resizeRef.current.startX) / scale;
|
||||
const dy = (event.clientY - resizeRef.current.startY) / scale;
|
||||
const minSize = getNodeMinSize(data.type);
|
||||
const minWidth = minSize.width;
|
||||
// 分镜脚本的高度由表格内容动态撑开,覆盖注册表里的静态下限。
|
||||
const minHeight = scriptMinHeight || minSize.height;
|
||||
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
|
||||
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
|
||||
const fromLeft = resizeRef.current.corner.includes("left");
|
||||
const fromTop = resizeRef.current.corner.includes("top");
|
||||
const rawWidth = Math.max(minWidth, resizeRef.current.startWidth + (fromLeft ? -dx : dx));
|
||||
const rawHeight = Math.max(minHeight, resizeRef.current.startHeight + (fromTop ? -dy : dy));
|
||||
let width = rawWidth;
|
||||
let height = rawHeight;
|
||||
if (resizeRef.current.keepRatio) {
|
||||
const ratio = resizeRef.current.ratio;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
height = width / ratio;
|
||||
} else {
|
||||
width = height * ratio;
|
||||
}
|
||||
if (height < minHeight) {
|
||||
height = minHeight;
|
||||
width = height * ratio;
|
||||
}
|
||||
if (width < minWidth) {
|
||||
width = minWidth;
|
||||
height = width / ratio;
|
||||
}
|
||||
}
|
||||
|
||||
onResize(data.id, width, height, {
|
||||
x: fromLeft ? startRight - width : resizeRef.current.startLeft,
|
||||
y: fromTop ? startBottom - height : resizeRef.current.startTop,
|
||||
});
|
||||
},
|
||||
[data.id, data.type, onResize, scale, scriptMinHeight],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback(() => {
|
||||
resizeRef.current.isResizing = false;
|
||||
window.removeEventListener("mousemove", handleResizeMove);
|
||||
window.removeEventListener("mouseup", handleResizeUp);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
resizeRef.current = {
|
||||
isResizing: true,
|
||||
corner,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startLeft: data.position.x,
|
||||
startTop: data.position.y,
|
||||
startWidth: data.width,
|
||||
startHeight: data.height,
|
||||
keepRatio: shouldKeepAspectRatio(data),
|
||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||
};
|
||||
window.addEventListener("mousemove", handleResizeMove);
|
||||
window.addEventListener("mouseup", handleResizeUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleResizeMove);
|
||||
window.removeEventListener("mouseup", handleResizeUp);
|
||||
};
|
||||
}, [handleResizeMove, handleResizeUp]);
|
||||
|
||||
const commitTitle = () => {
|
||||
const next = titleDraft.trim();
|
||||
setIsEditingTitle(false);
|
||||
@@ -489,10 +404,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
) : null}
|
||||
|
||||
{showChrome && !readOnly && !data.metadata?.locked && (isSelected || hovered) ? <>
|
||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="top-left" onPointerDown={handleResizePointerDown} />
|
||||
<ResizeHandle corner="top-right" onPointerDown={handleResizePointerDown} />
|
||||
<ResizeHandle corner="bottom-left" onPointerDown={handleResizePointerDown} />
|
||||
<ResizeHandle corner="bottom-right" onPointerDown={handleResizePointerDown} />
|
||||
</> : null}
|
||||
</div>
|
||||
|
||||
@@ -631,7 +546,7 @@ function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvas
|
||||
);
|
||||
}
|
||||
|
||||
function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
|
||||
function ResizeHandle({ corner, onPointerDown }: { corner: NodeResizeCorner; onPointerDown: (event: React.PointerEvent<HTMLDivElement>, corner: NodeResizeCorner) => void }) {
|
||||
const positionClass = {
|
||||
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
|
||||
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
|
||||
@@ -639,7 +554,7 @@ function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDo
|
||||
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
|
||||
}[corner];
|
||||
|
||||
return <div className={`absolute z-[var(--node-z-handle)] size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
|
||||
return <div data-resize-corner={corner} className={`absolute z-[var(--node-z-handle)] size-7 touch-none ${positionClass}`} onPointerDown={(event) => onPointerDown(event, corner)} onMouseDown={(event) => event.stopPropagation()} />;
|
||||
}
|
||||
|
||||
const NODE_EXTERNAL_HEADER_MIN_SCALE = 0.35;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Link } from "react-router";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { getProject, getProjectUnit, type ProjectDetail, type ProjectUnit } from "@/services/api/projects";
|
||||
|
||||
export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-infinite-canvas-project-chapter";
|
||||
export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-yingce-project-chapter";
|
||||
export type CanvasProjectChapterPayload = Pick<ProjectUnit, "id" | "title" | "position"> & { projectId: string; sourceText?: string };
|
||||
|
||||
const CHAPTER_ROW_HEIGHT = 36;
|
||||
|
||||
38
web/src/components/canvas/canvas-settings-popover.tsx
Normal file
38
web/src/components/canvas/canvas-settings-popover.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Button, Popover } from "antd";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
|
||||
export type CanvasSettingsPlacement = "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
type SettingsPopoverProps = {
|
||||
label: string;
|
||||
summary: string;
|
||||
children: ReactNode;
|
||||
placement?: CanvasSettingsPlacement;
|
||||
buttonClassName?: string;
|
||||
width?: number;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
autoAdjustOverflow?: boolean;
|
||||
};
|
||||
|
||||
export function CanvasSettingsPopover({ label, summary, children, placement = "topLeft", buttonClassName, width = 356, onOpenChange, getPopupContainer, autoAdjustOverflow = true }: SettingsPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = useId();
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
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}`}
|
||||
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" />}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,33 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRef, useState } from "react";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
|
||||
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
|
||||
|
||||
type CanvasSizePickerProps = {
|
||||
value: string;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
type CanvasSizePickerProps = { value: string; className?: string; onChange: (value: string) => void };
|
||||
const presets = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
|
||||
|
||||
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
|
||||
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
|
||||
const selectSize = (next: string) => {
|
||||
onChange(next.trim());
|
||||
setSearch("");
|
||||
setOpen(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const draft = useRef("");
|
||||
const options = [...new Set([...presets, value, query.trim()].filter(Boolean))].map((option) => ({ label: option, value: option }));
|
||||
const commit = (text: string) => {
|
||||
const next = text.trim();
|
||||
draft.current = "";
|
||||
setQuery("");
|
||||
setExpanded(false);
|
||||
if (next && next !== value) onChange(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
|
||||
setOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", close, true);
|
||||
return () => window.removeEventListener("pointerdown", close, true);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={className}>
|
||||
<Select
|
||||
showSearch
|
||||
open={open}
|
||||
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
|
||||
value={value || undefined}
|
||||
searchValue={search}
|
||||
placeholder="比例"
|
||||
options={options}
|
||||
popupMatchSelectWidth={false}
|
||||
popupRender={(menu) => (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={setOpen}
|
||||
onSearch={setSearch}
|
||||
onChange={selectSize}
|
||||
onBlur={() => {
|
||||
if (search.trim()) selectSize(search);
|
||||
}}
|
||||
<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); }}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key === "Enter" && search.trim()) selectSize(search);
|
||||
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); }
|
||||
}}
|
||||
/>
|
||||
popupRender={(menu) => <div onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>{menu}</div>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,125 +1,20 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, resolveVideoRatioValue, resolveVideoResolutionValue } from "@/lib/model-capabilities";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasSettingsPopover, type CanvasSettingsPlacement } from "./canvas-settings-popover";
|
||||
|
||||
type CanvasVideoSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
buttonClassName?: string;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
};
|
||||
type VideoPopoverProps = { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; buttonClassName?: string; placement?: CanvasSettingsPlacement };
|
||||
|
||||
export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) {
|
||||
export function CanvasVideoSettingsPopover({ config, onConfigChange, ...popover }: VideoPopoverProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const videoProfile = modelCapabilityConfigFor(config, config.model).video;
|
||||
const resolutionSupported = Boolean(videoProfile?.resolutions.length);
|
||||
const sizeSupported = Boolean(videoProfile?.ratios.length);
|
||||
const resolution = videoProfile ? resolveVideoResolutionValue(videoProfile, config.vquality) : "";
|
||||
const size = videoProfile ? resolveVideoRatioValue(videoProfile, config.size) : "";
|
||||
const summary = [
|
||||
...(resolutionSupported ? [videoResolutionLabel(resolution)] : []),
|
||||
...(sizeSupported ? [videoSizeLabel(size)] : []),
|
||||
videoSecondsLabel(config.videoSeconds),
|
||||
].join(" · ");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
syncPosition();
|
||||
window.addEventListener("resize", syncPosition);
|
||||
window.addEventListener("scroll", syncPosition, true);
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncPosition);
|
||||
window.removeEventListener("scroll", syncPosition, true);
|
||||
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const panel = open && buttonRect ? <VideoSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`视频设置:${summary}`} title={`视频设置 · ${summary}`} onClick={() => setOpen((current) => !current)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoSettingsPortal({
|
||||
buttonRect,
|
||||
panelRef,
|
||||
placement,
|
||||
theme,
|
||||
config,
|
||||
onConfigChange,
|
||||
}: {
|
||||
buttonRect: DOMRect;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
placement: CanvasVideoSettingsPopoverProps["placement"];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
}) {
|
||||
const gap = 8;
|
||||
const margin = 12;
|
||||
const width = Math.min(356, window.innerWidth - margin * 2);
|
||||
const alignRight = placement?.endsWith("Right");
|
||||
const alignCenter = placement === "top" || placement === "bottom";
|
||||
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
|
||||
const topPlacement = placement?.startsWith("top");
|
||||
const estimatedHeight = 370;
|
||||
const topSpace = buttonRect.top - gap - margin;
|
||||
const bottomSpace = window.innerHeight - buttonRect.bottom - gap - margin;
|
||||
const placeAbove = topPlacement ? topSpace >= estimatedHeight || topSpace >= bottomSpace : bottomSpace < estimatedHeight && topSpace > bottomSpace;
|
||||
const style = {
|
||||
position: "fixed",
|
||||
zIndex: "var(--z-dialog-popover)",
|
||||
width,
|
||||
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
|
||||
...(placeAbove ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, topSpace) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, bottomSpace) }),
|
||||
background: theme.canvas.background,
|
||||
border: `1px solid ${theme.toolbar.border}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: `0 24px 72px ${theme.spatial.shadow}`,
|
||||
padding: 12,
|
||||
overflowY: "auto",
|
||||
color: theme.node.text,
|
||||
} as const;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="canvas-image-settings-popover aceternity-floating-panel backdrop-blur-2xl"
|
||||
style={style}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<VideoSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-3" />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
const profile = modelCapabilityConfigFor(config, config.model).video;
|
||||
const parts: string[] = [];
|
||||
if (profile?.resolutions.length) parts.push(videoResolutionLabel(resolveVideoResolutionValue(profile, config.vquality)));
|
||||
if (profile?.ratios.length) parts.push(videoSizeLabel(resolveVideoRatioValue(profile, config.size)));
|
||||
parts.push(videoSecondsLabel(config.videoSeconds));
|
||||
return <CanvasSettingsPopover {...popover} label="视频设置" summary={parts.join(" · ")}>
|
||||
<VideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} className="space-y-3" />
|
||||
</CanvasSettingsPopover>;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/c
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type InfiniteCanvasProps = {
|
||||
type CanvasViewportProps = {
|
||||
interactive?: boolean;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewport: ViewportTransform;
|
||||
@@ -45,7 +45,7 @@ type PinchState = {
|
||||
initialScale: number;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({ interactive = true, containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
|
||||
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({
|
||||
@@ -51,15 +51,25 @@ export function applyActorReferenceMaterial(root: Object3D, baseColor: string) {
|
||||
uMetalness: { value: 0.05 },
|
||||
},
|
||||
vertexShader: `
|
||||
#include <common>
|
||||
#include <skinning_pars_vertex>
|
||||
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
varying float vHeight;
|
||||
|
||||
void main() {
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
#include <beginnormal_vertex>
|
||||
#include <skinbase_vertex>
|
||||
#include <skinnormal_vertex>
|
||||
#include <defaultnormal_vertex>
|
||||
#include <begin_vertex>
|
||||
#include <skinning_vertex>
|
||||
|
||||
vNormal = normalize(transformedNormal);
|
||||
vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);
|
||||
vViewPosition = -mvPosition.xyz;
|
||||
vHeight = position.y;
|
||||
vHeight = transformed.y;
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`,
|
||||
|
||||
55
web/src/components/canvas/use-canvas-node-resize.ts
Normal file
55
web/src/components/canvas/use-canvas-node-resize.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, type PointerEvent } from "react";
|
||||
import type { CanvasNodeData, Position } from "@/types/canvas";
|
||||
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
|
||||
import { solveNodeResize, type NodeResizeCorner } from "@/lib/canvas/node-resize-geometry";
|
||||
|
||||
type ResizeCallback = (id: string, width: number, height: number, position?: Position) => void;
|
||||
|
||||
export function useCanvasNodeResize(node: CanvasNodeData, scale: number, minimumHeight: number | null, onResize: ResizeCallback, readOnly = false) {
|
||||
const dispose = useRef<(() => void) | null>(null);
|
||||
const latest = useRef(onResize);
|
||||
latest.current = onResize;
|
||||
const disabled = readOnly || Boolean(node.metadata?.locked);
|
||||
useEffect(() => {
|
||||
if (disabled) dispose.current?.();
|
||||
return () => dispose.current?.();
|
||||
}, [node.id, disabled]);
|
||||
|
||||
return (event: PointerEvent<HTMLElement>, corner: NodeResizeCorner) => {
|
||||
event.stopPropagation();
|
||||
if (disabled || dispose.current || event.button !== 0 || !(scale > 0) || !Number.isFinite(scale)) return;
|
||||
event.preventDefault();
|
||||
const target: HTMLElement = event.currentTarget;
|
||||
const pointer = event.pointerId;
|
||||
const origin = { x: event.clientX, y: event.clientY };
|
||||
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,
|
||||
ratio: shouldKeepAspectRatio(node) ? (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1) : undefined,
|
||||
};
|
||||
const listeners = new AbortController();
|
||||
const stop = () => {
|
||||
listeners.abort();
|
||||
if (target.hasPointerCapture(pointer)) target.releasePointerCapture(pointer);
|
||||
dispose.current = null;
|
||||
};
|
||||
const move = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId !== pointer || (previous.x === next.clientX && previous.y === next.clientY)) return;
|
||||
previous = { x: next.clientX, y: next.clientY };
|
||||
const size = solveNodeResize(snapshot, { x: (next.clientX - origin.x) / scale, y: (next.clientY - origin.y) / scale });
|
||||
latest.current(node.id, size.width, size.height, size.position);
|
||||
};
|
||||
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 });
|
||||
window.addEventListener("blur", stop, { signal: listeners.signal });
|
||||
dispose.current = stop;
|
||||
};
|
||||
}
|
||||
21
web/src/components/canvas/use-image-tool-source.ts
Normal file
21
web/src/components/canvas/use-image-tool-source.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadRasterImage } from "@/lib/image-raster";
|
||||
import type { PixelSize } from "@/lib/canvas/image-operation-plan";
|
||||
|
||||
export function useImageToolSource(source: string) {
|
||||
const [result, setResult] = useState<{ source: string; size: PixelSize | null; error: string }>({ source, size: null, error: "" });
|
||||
useEffect(() => {
|
||||
const request = new AbortController();
|
||||
setResult({ source, size: null, error: "" });
|
||||
void loadRasterImage(source, request.signal).then(
|
||||
(image) => {
|
||||
if (!request.signal.aborted) setResult({ source, size: { width: image.naturalWidth, height: image.naturalHeight }, error: "" });
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!request.signal.aborted) setResult({ source, size: null, error: error instanceof Error ? error.message : "图片读取失败" });
|
||||
},
|
||||
);
|
||||
return () => request.abort();
|
||||
}, [source]);
|
||||
return result.source === source ? result : { source, size: null, error: "" };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { ChangeEvent } from "react";
|
||||
|
||||
import type { ChannelHeader } from "@/stores/use-config-store";
|
||||
|
||||
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
|
||||
const DEFAULT_USER_AGENT = "Yingce/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
|
||||
const MAX_HEADER_COUNT = 32;
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"authorization",
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
import { formatDuration } from "@/lib/image-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
|
||||
|
||||
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
type PendingImageProps = { className?: string; label?: string; compact?: boolean };
|
||||
|
||||
export function ImageGenerationPending({ className, label = "正在生成图片", compact = false }: PendingImageProps) {
|
||||
const [startedAt] = useState(() => Date.now());
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const index = Math.floor(tick / 2) % pendingMessages.length;
|
||||
const progress = Math.min(98, 10 + (1 - Math.exp(-tick / 28)) * 88);
|
||||
|
||||
const update = () => setElapsed(Math.max(0, Date.now() - startedAt));
|
||||
const clock = window.setInterval(update, 1000);
|
||||
const onVisible = () => { if (!document.hidden) update(); };
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => { window.clearInterval(clock); document.removeEventListener("visibilitychange", onVisible); };
|
||||
}, [startedAt]);
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden bg-stone-100 dark:bg-white/10", compact ? "min-h-24" : "aspect-[4/3]", className)}>
|
||||
<div
|
||||
className="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
|
||||
backgroundSize: "16px 16px",
|
||||
maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-4 top-4 flex items-center gap-2 text-[var(--fs-body-lg)] font-medium text-stone-500 dark:text-stone-300">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
<span>{label || pendingMessages[index]}</span>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-stone-500 dark:text-stone-400">
|
||||
<span>{formatDuration(tick * 1000)}</span>
|
||||
<span>{Math.floor(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-stone-300/70 dark:bg-white/12">
|
||||
<div className="h-full rounded-full bg-stone-900 dark:bg-stone-100" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import { useUserStore } from "@/stores/use-user-store";
|
||||
export function ModelSetupGuide({ hidden = false }: { hidden?: boolean }) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const registrationGuide = typeof window !== "undefined" && window.sessionStorage.getItem("infinite-canvas:model-setup-guide") === "1";
|
||||
const registrationGuide = typeof window !== "undefined" && window.sessionStorage.getItem("yingce:model-setup-guide") === "1";
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const models = useConfigStore((state) => state.config.models);
|
||||
if (hidden || dismissed || !hydrated || !user || user.role === "admin" || (!registrationGuide && models.length > 0)) return null;
|
||||
|
||||
const close = () => {
|
||||
window.sessionStorage.removeItem("infinite-canvas:model-setup-guide");
|
||||
window.sessionStorage.removeItem("yingce:model-setup-guide");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const WORKSPACE_SIDEBAR_STORAGE_KEY = "infinite-canvas:workspace-sidebar-collapsed";
|
||||
export const WORKSPACE_SIDEBAR_STORAGE_KEY = "yingce:workspace-sidebar-collapsed";
|
||||
export const WORKSPACE_SIDEBAR_CHANGE_EVENT = "workspace:sidebar-collapsed-change";
|
||||
|
||||
type WorkspaceSidebarStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
Reference in New Issue
Block a user