72f268e088
实现[游戏场景需求v1.0](https://kcnz41bksl1c.feishu.cn/wiki/JSF3wdhduinpqhkrVGKcZFp4nng?psg_id=8477599259997387860&refer_index=1&refer_type=citation)。 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Co-authored-by: suzmii <suzmii@foxmail.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/139 Co-authored-by: 董羽秦 <suzmii@qq.com> Co-committed-by: 董羽秦 <suzmii@qq.com>
217 lines
8.4 KiB
TypeScript
217 lines
8.4 KiB
TypeScript
import { Check, ChevronDown, Eye, Palette } from 'lucide-react';
|
|
import {
|
|
type CSSProperties,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type SetStateAction,
|
|
useCallback,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import {
|
|
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
|
|
EDITOR_SCENE_STYLE_PRESET_OPTIONS,
|
|
EDITOR_SCENE_STYLE_PRESETS,
|
|
type EditorSceneStylePreset,
|
|
getEditorSceneStylePresetLabel,
|
|
} from '../../../packages/shared/src/contracts/editorScene';
|
|
import animePreview from '../../assets/image-editor/scene-styles/style-anime.png';
|
|
import flatPreview from '../../assets/image-editor/scene-styles/style-flat.png';
|
|
import stopMotionPreview from '../../assets/image-editor/scene-styles/style-stop-motion.png';
|
|
import watercolorPreview from '../../assets/image-editor/scene-styles/style-watercolor.png';
|
|
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
|
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
|
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
|
|
|
const SCENE_STYLE_PREVIEWS: Partial<Record<EditorSceneStylePreset, string>> = {
|
|
[EDITOR_SCENE_STYLE_PRESETS.ANIME.value]: animePreview,
|
|
[EDITOR_SCENE_STYLE_PRESETS.WATERCOLOR.value]: watercolorPreview,
|
|
[EDITOR_SCENE_STYLE_PRESETS.FLAT.value]: flatPreview,
|
|
[EDITOR_SCENE_STYLE_PRESETS.STOP_MOTION.value]: stopMotionPreview,
|
|
};
|
|
|
|
type ImageCanvasSceneStyleControlProps = {
|
|
dialog: GenerateDialogState;
|
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
|
renderEditorPortal: (node: ReactNode) => ReactNode;
|
|
buildPortalMenuStyle: (
|
|
anchor: HTMLElement | null,
|
|
placement: 'above' | 'below',
|
|
) => CSSProperties;
|
|
};
|
|
|
|
export function ImageCanvasSceneStyleControl({
|
|
dialog,
|
|
setGenerateDialog,
|
|
renderEditorPortal,
|
|
buildPortalMenuStyle,
|
|
}: ImageCanvasSceneStyleControlProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [hoveredPreview, setHoveredPreview] =
|
|
useState<EditorSceneStylePreset | null>(null);
|
|
const [focusedPreview, setFocusedPreview] =
|
|
useState<EditorSceneStylePreset | null>(null);
|
|
const [clickedPreview, setClickedPreview] =
|
|
useState<EditorSceneStylePreset | null>(null);
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const dismiss = useCallback(() => {
|
|
setIsOpen(false);
|
|
setHoveredPreview(null);
|
|
setFocusedPreview(null);
|
|
setClickedPreview(null);
|
|
}, []);
|
|
const selectedValue =
|
|
dialog.sceneStylePreset ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET;
|
|
const selectedLabel = getEditorSceneStylePresetLabel(selectedValue);
|
|
const activePreview = hoveredPreview ?? focusedPreview ?? clickedPreview;
|
|
|
|
useImageCanvasFloatingOptionDismiss({
|
|
isOpen,
|
|
boundaryRefs: [triggerRef],
|
|
onDismiss: dismiss,
|
|
});
|
|
|
|
const selectPreset = (sceneStylePreset: EditorSceneStylePreset) => {
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...currentDialog,
|
|
status:
|
|
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
|
|
errorMessage:
|
|
currentDialog.status === 'failed'
|
|
? undefined
|
|
: currentDialog.errorMessage,
|
|
sceneStylePreset,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
dismiss();
|
|
};
|
|
|
|
return (
|
|
<div className="image-canvas-editor__scene-style-anchor">
|
|
<PlatformInlineOptionButton
|
|
ref={triggerRef}
|
|
className="image-canvas-editor__option-cluster image-canvas-editor__scene-style-trigger"
|
|
aria-label={`画风预设 ${selectedLabel}`}
|
|
aria-expanded={isOpen}
|
|
disabled={dialog.status === 'generating'}
|
|
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
|
onClick={() => {
|
|
if (isOpen) {
|
|
dismiss();
|
|
return;
|
|
}
|
|
setIsOpen(true);
|
|
}}
|
|
>
|
|
<span className="image-canvas-editor__scene-style-trigger-label">
|
|
<Palette className="h-3.5 w-3.5" aria-hidden="true" />
|
|
<span>{selectedLabel}</span>
|
|
</span>
|
|
</PlatformInlineOptionButton>
|
|
|
|
{isOpen
|
|
? renderEditorPortal(
|
|
<PlatformFloatingMenu
|
|
className="image-canvas-editor__scene-style-menu image-canvas-editor__portal-menu"
|
|
label="画风预设"
|
|
placement="top-start"
|
|
style={buildPortalMenuStyle(triggerRef.current, 'above')}
|
|
>
|
|
<div className="image-canvas-editor__scene-style-menu-heading">
|
|
<strong>画风预设</strong>
|
|
</div>
|
|
{EDITOR_SCENE_STYLE_PRESET_OPTIONS.map((option) => {
|
|
const selected = option.value === selectedValue;
|
|
const preview = SCENE_STYLE_PREVIEWS[option.value];
|
|
const previewActive = activePreview === option.value;
|
|
return (
|
|
<div
|
|
key={option.value}
|
|
className="image-canvas-editor__scene-style-row"
|
|
role="none"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__scene-style-choice"
|
|
role="menuitemradio"
|
|
aria-checked={selected}
|
|
onClick={() => selectPreset(option.value)}
|
|
>
|
|
<span>
|
|
<strong>{option.label}</strong>
|
|
<small>{option.description}</small>
|
|
</span>
|
|
<Check
|
|
className="image-canvas-editor__scene-style-check"
|
|
data-visible={selected}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
{preview ? (
|
|
<span
|
|
className="image-canvas-editor__scene-style-preview-wrap"
|
|
onPointerEnter={() => setHoveredPreview(option.value)}
|
|
onPointerLeave={() =>
|
|
setHoveredPreview((current) =>
|
|
current === option.value ? null : current,
|
|
)
|
|
}
|
|
onFocus={() => setFocusedPreview(option.value)}
|
|
onBlur={(event) => {
|
|
if (
|
|
!event.currentTarget.contains(event.relatedTarget)
|
|
) {
|
|
setFocusedPreview((current) =>
|
|
current === option.value ? null : current,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__scene-style-preview-button"
|
|
aria-label={`预览${option.label}画风`}
|
|
aria-expanded={previewActive}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setClickedPreview((currentPreview) => {
|
|
if (currentPreview === option.value) {
|
|
setHoveredPreview(null);
|
|
setFocusedPreview(null);
|
|
return null;
|
|
}
|
|
return option.value;
|
|
});
|
|
}}
|
|
>
|
|
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
|
|
<span>预览</span>
|
|
</button>
|
|
{previewActive ? (
|
|
<figure className="image-canvas-editor__scene-style-preview-card">
|
|
<img
|
|
src={preview}
|
|
alt={`${option.label}固定画风预览`}
|
|
/>
|
|
<figcaption>
|
|
<strong>{option.label}</strong>
|
|
</figcaption>
|
|
</figure>
|
|
) : null}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</PlatformFloatingMenu>,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|