eb608ac6f2
为画布历史记录补充操作类型和内容消失保护 增加撤销成功、恢复成功及阻止提示并在三秒后隐藏 补充拖动、上传、生成和替换等操作的历史语义 保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈 补充撤销与恢复规则的定向测试和说明文档 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89 Co-authored-by: oj-afraid-student <1373241747@qq.com> Co-committed-by: oj-afraid-student <1373241747@qq.com>
591 lines
20 KiB
TypeScript
591 lines
20 KiB
TypeScript
import {
|
|
ImagePlus,
|
|
Layers,
|
|
Map as MapIcon,
|
|
MessageCircle,
|
|
Redo2,
|
|
RotateCcw,
|
|
Undo2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import type {
|
|
CSSProperties,
|
|
KeyboardEvent as ReactKeyboardEvent,
|
|
PointerEvent as ReactPointerEvent,
|
|
} from 'react';
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
import {
|
|
PlatformFloatingMenu,
|
|
PlatformFloatingMenuItem,
|
|
} from '../common/PlatformFloatingMenu';
|
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
|
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
|
import {
|
|
CANVAS_BACKGROUND_OPTIONS,
|
|
canvasDisplayScaleToViewportScale,
|
|
clamp,
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
formatCanvasDisplayScalePercent,
|
|
normalizeCanvasBackgroundHex,
|
|
} from './ImageCanvasEditorModel';
|
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
|
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
|
|
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
|
|
|
type ImageCanvasPanelDockViewProps = {
|
|
viewport: CanvasViewport;
|
|
canvasBackgroundColor: string;
|
|
canvasBackgroundHexValue: string;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
isZoomMenuOpen: boolean;
|
|
isBackgroundSettingsOpen: boolean;
|
|
activeSidebarPanel: SidebarPanel | null;
|
|
isAgentConversationEnabled: boolean;
|
|
isAgentConversationOpen: boolean;
|
|
isMinimapOpen: boolean;
|
|
minimapModel: StageMinimapModel | null;
|
|
onFitLayers: () => void;
|
|
onUndoCanvasChange: () => void;
|
|
onRedoCanvasChange: () => void;
|
|
onUpdateScaleFromCenter: (nextScale: number) => void;
|
|
onToggleZoomMenu: () => void;
|
|
onCloseZoomMenu: () => void;
|
|
onToggleBackgroundSettings: () => void;
|
|
onApplyCanvasBackgroundColor: (color: string) => void;
|
|
onCanvasBackgroundHexChange: (value: string) => void;
|
|
onToggleSidebarPanel: (panel: SidebarPanel) => void;
|
|
onToggleAgentConversation: () => void;
|
|
onToggleMinimap: () => void;
|
|
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
};
|
|
|
|
type CanvasBackgroundHsv = {
|
|
hue: number;
|
|
saturation: number;
|
|
value: number;
|
|
};
|
|
|
|
const DEFAULT_CANVAS_BACKGROUND_PICKER_HUE = 210;
|
|
const ACHROMATIC_BACKGROUND_SATURATION_EPSILON = 0.001;
|
|
|
|
function hexToRgb(value: string) {
|
|
const normalizedValue = normalizeCanvasBackgroundHex(value);
|
|
if (!normalizedValue) {
|
|
return null;
|
|
}
|
|
return {
|
|
red: Number.parseInt(normalizedValue.slice(1, 3), 16),
|
|
green: Number.parseInt(normalizedValue.slice(3, 5), 16),
|
|
blue: Number.parseInt(normalizedValue.slice(5, 7), 16),
|
|
};
|
|
}
|
|
|
|
function rgbToHsv(red: number, green: number, blue: number) {
|
|
const normalizedRed = red / 255;
|
|
const normalizedGreen = green / 255;
|
|
const normalizedBlue = blue / 255;
|
|
const maxValue = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
|
|
const minValue = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
|
|
const delta = maxValue - minValue;
|
|
const saturation = maxValue === 0 ? 0 : delta / maxValue;
|
|
let hue = 0;
|
|
if (delta > 0) {
|
|
if (maxValue === normalizedRed) {
|
|
hue = 60 * (((normalizedGreen - normalizedBlue) / delta) % 6);
|
|
} else if (maxValue === normalizedGreen) {
|
|
hue = 60 * ((normalizedBlue - normalizedRed) / delta + 2);
|
|
} else {
|
|
hue = 60 * ((normalizedRed - normalizedGreen) / delta + 4);
|
|
}
|
|
}
|
|
return {
|
|
hue: (hue + 360) % 360,
|
|
saturation,
|
|
value: maxValue,
|
|
};
|
|
}
|
|
|
|
function hsvToHex({ hue, saturation, value }: CanvasBackgroundHsv) {
|
|
const chroma = value * saturation;
|
|
const normalizedHue = ((hue % 360) + 360) % 360;
|
|
const secondComponent =
|
|
chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
|
|
const matchValue = value - chroma;
|
|
const [redPrime, greenPrime, bluePrime] =
|
|
normalizedHue < 60
|
|
? [chroma, secondComponent, 0]
|
|
: normalizedHue < 120
|
|
? [secondComponent, chroma, 0]
|
|
: normalizedHue < 180
|
|
? [0, chroma, secondComponent]
|
|
: normalizedHue < 240
|
|
? [0, secondComponent, chroma]
|
|
: normalizedHue < 300
|
|
? [secondComponent, 0, chroma]
|
|
: [chroma, 0, secondComponent];
|
|
return `#${[redPrime, greenPrime, bluePrime]
|
|
.map((channel) =>
|
|
Math.round((channel + matchValue) * 255)
|
|
.toString(16)
|
|
.padStart(2, '0'),
|
|
)
|
|
.join('')}`;
|
|
}
|
|
|
|
function resolveCanvasBackgroundHsv(
|
|
color: string,
|
|
fallbackHue = DEFAULT_CANVAS_BACKGROUND_PICKER_HUE,
|
|
): CanvasBackgroundHsv {
|
|
const rgb = hexToRgb(color) ?? hexToRgb(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
|
if (!rgb) {
|
|
return { hue: fallbackHue, saturation: 0.02, value: 0.99 };
|
|
}
|
|
const hsv = rgbToHsv(rgb.red, rgb.green, rgb.blue);
|
|
return {
|
|
...hsv,
|
|
hue:
|
|
hsv.saturation <= ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
|
? fallbackHue
|
|
: hsv.hue,
|
|
};
|
|
}
|
|
|
|
function getPointerRatio(
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
axis: 'x' | 'y',
|
|
) {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const size = axis === 'x' ? rect.width : rect.height;
|
|
if (!Number.isFinite(size) || size <= 0) {
|
|
return 0.5;
|
|
}
|
|
const clientPosition = axis === 'x' ? event.clientX : event.clientY;
|
|
if (!Number.isFinite(clientPosition)) {
|
|
return 0.5;
|
|
}
|
|
const offset =
|
|
axis === 'x' ? clientPosition - rect.left : clientPosition - rect.top;
|
|
return clamp(offset / size, 0, 1);
|
|
}
|
|
|
|
function resolveSpectrumPointerColor(
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
currentColor: string,
|
|
fallbackHue: number,
|
|
) {
|
|
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
|
|
return hsvToHex({
|
|
hue: currentHsv.hue,
|
|
saturation: getPointerRatio(event, 'x'),
|
|
value: 1 - getPointerRatio(event, 'y'),
|
|
});
|
|
}
|
|
|
|
function resolveHuePointerColor(
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
currentColor: string,
|
|
fallbackHue: number,
|
|
) {
|
|
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
|
|
return hsvToHex({
|
|
hue: getPointerRatio(event, 'x') * 360,
|
|
saturation: currentHsv.saturation,
|
|
value: currentHsv.value,
|
|
});
|
|
}
|
|
|
|
function isPrimaryPointerDrag(event: ReactPointerEvent<HTMLElement>) {
|
|
return event.buttons === 1 || event.pointerType === 'touch';
|
|
}
|
|
|
|
export function ImageCanvasPanelDockView({
|
|
viewport,
|
|
canvasBackgroundColor,
|
|
canvasBackgroundHexValue,
|
|
canUndo,
|
|
canRedo,
|
|
isZoomMenuOpen,
|
|
isBackgroundSettingsOpen,
|
|
activeSidebarPanel,
|
|
isAgentConversationEnabled,
|
|
isAgentConversationOpen,
|
|
isMinimapOpen,
|
|
minimapModel,
|
|
onFitLayers,
|
|
onUndoCanvasChange,
|
|
onRedoCanvasChange,
|
|
onUpdateScaleFromCenter,
|
|
onToggleZoomMenu,
|
|
onCloseZoomMenu,
|
|
onToggleBackgroundSettings,
|
|
onApplyCanvasBackgroundColor,
|
|
onCanvasBackgroundHexChange,
|
|
onToggleSidebarPanel,
|
|
onToggleAgentConversation,
|
|
onToggleMinimap,
|
|
onMinimapPointerDown,
|
|
}: ImageCanvasPanelDockViewProps) {
|
|
const lastBackgroundHueRef = useRef(DEFAULT_CANVAS_BACKGROUND_PICKER_HUE);
|
|
const backgroundHsv = resolveCanvasBackgroundHsv(
|
|
canvasBackgroundColor,
|
|
lastBackgroundHueRef.current,
|
|
);
|
|
useEffect(() => {
|
|
if (
|
|
backgroundHsv.saturation > ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
|
) {
|
|
lastBackgroundHueRef.current = backgroundHsv.hue;
|
|
}
|
|
}, [backgroundHsv.hue, backgroundHsv.saturation]);
|
|
const backgroundHueColor = hsvToHex({
|
|
hue: backgroundHsv.hue,
|
|
saturation: 1,
|
|
value: 1,
|
|
});
|
|
const backgroundSpectrumStyle = {
|
|
'--image-canvas-background-hue-color': backgroundHueColor,
|
|
} as CSSProperties;
|
|
const backgroundSpectrumHandleStyle = {
|
|
'--image-canvas-background-spectrum-handle-left': `clamp(0.42rem, ${backgroundHsv.saturation * 100}%, calc(100% - 0.42rem))`,
|
|
'--image-canvas-background-spectrum-handle-top': `clamp(0.42rem, ${(1 - backgroundHsv.value) * 100}%, calc(100% - 0.42rem))`,
|
|
} as CSSProperties;
|
|
const backgroundHueHandleStyle = {
|
|
'--image-canvas-background-hue-handle-left': `clamp(0.39rem, ${(backgroundHsv.hue / 360) * 100}%, calc(100% - 0.39rem))`,
|
|
} as CSSProperties;
|
|
const applySpectrumPointerColor = (
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
) => {
|
|
event.preventDefault();
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
onApplyCanvasBackgroundColor(
|
|
resolveSpectrumPointerColor(
|
|
event,
|
|
canvasBackgroundColor,
|
|
backgroundHsv.hue,
|
|
),
|
|
);
|
|
};
|
|
const applyHuePointerColor = (event: ReactPointerEvent<HTMLElement>) => {
|
|
event.preventDefault();
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
onApplyCanvasBackgroundColor(
|
|
resolveHuePointerColor(event, canvasBackgroundColor, backgroundHsv.hue),
|
|
);
|
|
};
|
|
const handleSpectrumKeyDown = (
|
|
event: ReactKeyboardEvent<HTMLButtonElement>,
|
|
) => {
|
|
const step = event.shiftKey ? 0.1 : 0.03;
|
|
const nextHsv = { ...backgroundHsv };
|
|
if (event.key === 'ArrowLeft') {
|
|
nextHsv.saturation = clamp(nextHsv.saturation - step, 0, 1);
|
|
} else if (event.key === 'ArrowRight') {
|
|
nextHsv.saturation = clamp(nextHsv.saturation + step, 0, 1);
|
|
} else if (event.key === 'ArrowDown') {
|
|
nextHsv.value = clamp(nextHsv.value - step, 0, 1);
|
|
} else if (event.key === 'ArrowUp') {
|
|
nextHsv.value = clamp(nextHsv.value + step, 0, 1);
|
|
} else {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
|
|
};
|
|
const handleHueKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
const step = event.shiftKey ? 30 : 6;
|
|
const nextHsv = { ...backgroundHsv };
|
|
if (event.key === 'ArrowLeft') {
|
|
nextHsv.hue = (nextHsv.hue - step + 360) % 360;
|
|
} else if (event.key === 'ArrowRight') {
|
|
nextHsv.hue = (nextHsv.hue + step) % 360;
|
|
} else {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<EditorIconButton
|
|
className="image-canvas-editor__reset-button"
|
|
label="重置画布视图"
|
|
title="重置画布视图"
|
|
icon={RotateCcw}
|
|
onClick={() => onFitLayers()}
|
|
/>
|
|
|
|
<div
|
|
className={[
|
|
'image-canvas-editor__panel-dock',
|
|
activeSidebarPanel
|
|
? 'image-canvas-editor__panel-dock--sidebar-open'
|
|
: '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
role="toolbar"
|
|
aria-label="画布面板入口"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<EditorIconButton
|
|
label="撤销"
|
|
title="撤销"
|
|
icon={Undo2}
|
|
disabled={!canUndo}
|
|
onClick={onUndoCanvasChange}
|
|
/>
|
|
<EditorIconButton
|
|
label="恢复"
|
|
title="恢复上一次撤销"
|
|
icon={Redo2}
|
|
disabled={!canRedo}
|
|
onClick={onRedoCanvasChange}
|
|
/>
|
|
<div className="image-canvas-editor__zoom-menu-wrap">
|
|
<PlatformInlineOptionButton
|
|
className="image-canvas-editor__zoom-trigger"
|
|
aria-label={`当前缩放比例 ${formatCanvasDisplayScalePercent(viewport.scale)}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={isZoomMenuOpen}
|
|
onClick={onToggleZoomMenu}
|
|
>
|
|
{formatCanvasDisplayScalePercent(viewport.scale)}
|
|
</PlatformInlineOptionButton>
|
|
{isZoomMenuOpen ? (
|
|
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__zoom-menu-item"
|
|
onClick={() => {
|
|
onUpdateScaleFromCenter(viewport.scale * 1.16);
|
|
onCloseZoomMenu();
|
|
}}
|
|
>
|
|
放大
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__zoom-menu-item"
|
|
onClick={() => {
|
|
onUpdateScaleFromCenter(viewport.scale * 0.86);
|
|
onCloseZoomMenu();
|
|
}}
|
|
>
|
|
缩小
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__zoom-menu-item"
|
|
onClick={() => {
|
|
onFitLayers();
|
|
onCloseZoomMenu();
|
|
}}
|
|
>
|
|
显示画布所有元素
|
|
</PlatformFloatingMenuItem>
|
|
{[0.5, 1, 2].map((displayScale) => (
|
|
<PlatformFloatingMenuItem
|
|
key={displayScale}
|
|
className="image-canvas-editor__zoom-menu-item"
|
|
onClick={() => {
|
|
onUpdateScaleFromCenter(
|
|
canvasDisplayScaleToViewportScale(displayScale),
|
|
);
|
|
onCloseZoomMenu();
|
|
}}
|
|
>
|
|
缩放至{Math.round(displayScale * 100)}%
|
|
</PlatformFloatingMenuItem>
|
|
))}
|
|
</PlatformFloatingMenu>
|
|
) : null}
|
|
</div>
|
|
<div className="image-canvas-editor__background-control">
|
|
<PlatformIconButton
|
|
label="画布背景色"
|
|
title="画布背景色"
|
|
aria-expanded={isBackgroundSettingsOpen}
|
|
onClick={onToggleBackgroundSettings}
|
|
icon={
|
|
<span
|
|
className="image-canvas-editor__background-swatch-current"
|
|
style={{ backgroundColor: canvasBackgroundColor }}
|
|
/>
|
|
}
|
|
/>
|
|
{isBackgroundSettingsOpen ? (
|
|
<div
|
|
className="image-canvas-editor__background-panel"
|
|
role="dialog"
|
|
aria-label="画布背景设置"
|
|
>
|
|
<div className="image-canvas-editor__background-panel-head">
|
|
<span>画布背景</span>
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__background-close"
|
|
aria-label="关闭画布背景设置"
|
|
onClick={onToggleBackgroundSettings}
|
|
>
|
|
<X className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div className="image-canvas-editor__background-current-row">
|
|
<span
|
|
className="image-canvas-editor__background-current-preview"
|
|
style={{ backgroundColor: canvasBackgroundColor }}
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{canvasBackgroundColor}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__background-spectrum"
|
|
aria-label="画布背景色盘"
|
|
aria-valuetext={canvasBackgroundColor}
|
|
style={backgroundSpectrumStyle}
|
|
onPointerDown={applySpectrumPointerColor}
|
|
onPointerMove={(event) => {
|
|
if (isPrimaryPointerDrag(event)) {
|
|
applySpectrumPointerColor(event);
|
|
}
|
|
}}
|
|
onKeyDown={handleSpectrumKeyDown}
|
|
>
|
|
<span
|
|
className="image-canvas-editor__background-spectrum-surface"
|
|
aria-hidden="true"
|
|
/>
|
|
<span
|
|
className="image-canvas-editor__background-spectrum-handle"
|
|
aria-hidden="true"
|
|
style={backgroundSpectrumHandleStyle}
|
|
/>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__background-hue"
|
|
aria-label="画布背景色相"
|
|
aria-valuetext={canvasBackgroundColor}
|
|
onPointerDown={applyHuePointerColor}
|
|
onPointerMove={(event) => {
|
|
if (isPrimaryPointerDrag(event)) {
|
|
applyHuePointerColor(event);
|
|
}
|
|
}}
|
|
onKeyDown={handleHueKeyDown}
|
|
>
|
|
<span
|
|
className="image-canvas-editor__background-hue-handle"
|
|
aria-hidden="true"
|
|
style={backgroundHueHandleStyle}
|
|
/>
|
|
</button>
|
|
<div
|
|
className="image-canvas-editor__background-presets"
|
|
aria-label="画布背景预设色"
|
|
>
|
|
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className="image-canvas-editor__background-preset"
|
|
aria-label={option.label}
|
|
aria-pressed={canvasBackgroundColor === option.value}
|
|
onClick={() => onApplyCanvasBackgroundColor(option.value)}
|
|
>
|
|
<span
|
|
className="image-canvas-editor__background-swatch"
|
|
style={{ backgroundColor: option.value }}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="image-canvas-editor__background-footer">
|
|
<label className="image-canvas-editor__background-hex-field">
|
|
<span>HEX</span>
|
|
<input
|
|
aria-label="画布背景十六进制颜色"
|
|
value={canvasBackgroundHexValue}
|
|
spellCheck={false}
|
|
onChange={(event) =>
|
|
onCanvasBackgroundHexChange(event.currentTarget.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__background-reset"
|
|
onClick={() =>
|
|
onApplyCanvasBackgroundColor(
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
)
|
|
}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
|
恢复默认
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<EditorIconButton
|
|
label="打开素材"
|
|
title="素材"
|
|
icon={ImagePlus}
|
|
pressed={activeSidebarPanel === 'assets'}
|
|
onClick={() => onToggleSidebarPanel('assets')}
|
|
/>
|
|
<EditorIconButton
|
|
label="打开图层"
|
|
title="图层"
|
|
icon={Layers}
|
|
pressed={activeSidebarPanel === 'layers'}
|
|
onClick={() => onToggleSidebarPanel('layers')}
|
|
/>
|
|
{isAgentConversationEnabled ? (
|
|
<EditorIconButton
|
|
label="画布 Agent"
|
|
title="画布 Agent"
|
|
icon={MessageCircle}
|
|
pressed={isAgentConversationOpen}
|
|
onClick={onToggleAgentConversation}
|
|
/>
|
|
) : null}
|
|
<EditorIconButton
|
|
label="切换小地图"
|
|
title="小地图"
|
|
icon={MapIcon}
|
|
pressed={isMinimapOpen}
|
|
onClick={onToggleMinimap}
|
|
/>
|
|
</div>
|
|
|
|
{isMinimapOpen && minimapModel ? (
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__minimap"
|
|
aria-label="画布小地图"
|
|
title="拖拽移动视图"
|
|
onPointerDown={onMinimapPointerDown}
|
|
>
|
|
<span className="image-canvas-editor__minimap-stage">
|
|
{minimapModel.layers.map((layer) => (
|
|
<span
|
|
key={layer.id}
|
|
className="image-canvas-editor__minimap-layer"
|
|
title={layer.title}
|
|
style={layer.rect}
|
|
/>
|
|
))}
|
|
<span
|
|
className="image-canvas-editor__minimap-viewport"
|
|
style={minimapModel.viewport}
|
|
/>
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|