Files
Genarrative/src/components/image-editor/ImageCanvasSceneStyleControl.tsx
T
suzmii 72f268e088
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Feat/游戏场景需求v1.0 (#139)
实现[游戏场景需求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>
2026-08-08 10:25:42 +08:00

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