新增 PlatformRuntimeStatusToast 统一运行态短错误、成功和反馈 toast 迁移跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态状态 chip 补充公共组件与运行态回归测试,并更新 PlatformUiKit 文档和 Hermes 决策记录
943 lines
27 KiB
TypeScript
943 lines
27 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
Brush,
|
|
Check,
|
|
Eraser,
|
|
ImagePlus,
|
|
RotateCcw,
|
|
Save,
|
|
Sparkles,
|
|
} from 'lucide-react';
|
|
import {
|
|
type CSSProperties,
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import type {
|
|
BabyLoveDrawingPoint,
|
|
BabyLoveDrawingRecord,
|
|
BabyLoveDrawingStroke,
|
|
BabyLoveDrawingTool,
|
|
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
|
|
import { BABY_LOVE_DRAWING_RAINBOW_COLORS } from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
|
|
import {
|
|
createBabyLoveDrawingMagicImage,
|
|
saveBabyLoveDrawing,
|
|
} from '../../services/edutainment-baby-drawing';
|
|
import type { MocapHandInput } from '../../services/useMocapInput';
|
|
import { useMocapInput } from '../../services/useMocapInput';
|
|
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
|
|
import {
|
|
appendPointToStroke,
|
|
BABY_LOVE_DRAWING_BRUSH_SIZE,
|
|
BABY_LOVE_DRAWING_DEFAULT_COLOR,
|
|
BABY_LOVE_DRAWING_ERASER_SIZE,
|
|
type BabyLoveDrawingBounds,
|
|
type BabyLoveDrawingHandPoint,
|
|
type BabyLoveDrawingHoverTarget,
|
|
type BabyLoveDrawingPhase,
|
|
createBabyDrawingStroke,
|
|
hasHoverCompleted,
|
|
isPointInsideBounds,
|
|
resolveHoverProgress,
|
|
toCanvasPoint,
|
|
} from './babyLoveDrawingModel';
|
|
|
|
type BabyLoveDrawingRuntimeShellProps = {
|
|
onBack?: () => void;
|
|
};
|
|
|
|
type ActionButtonId = 'finish' | 'magic' | 'save' | 'restart' | 'back';
|
|
|
|
type RectMap = {
|
|
canvas: BabyLoveDrawingBounds | null;
|
|
colors: Record<string, BabyLoveDrawingBounds>;
|
|
tools: Record<BabyLoveDrawingTool, BabyLoveDrawingBounds | null>;
|
|
buttons: Record<ActionButtonId, BabyLoveDrawingBounds | null>;
|
|
};
|
|
|
|
type ActiveStrokeState = {
|
|
stroke: BabyLoveDrawingStroke;
|
|
lastPoint: BabyLoveDrawingPoint;
|
|
};
|
|
|
|
const BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS = 320;
|
|
const BABY_LOVE_DRAWING_HAND_SMOOTHING = 0.38;
|
|
const BABY_LOVE_DRAWING_RIGHT_HAND_MAX_FRAME_JUMP = 0.28;
|
|
|
|
const EMPTY_RECT_MAP: RectMap = {
|
|
canvas: null,
|
|
colors: {},
|
|
tools: {
|
|
brush: null,
|
|
eraser: null,
|
|
},
|
|
buttons: {
|
|
finish: null,
|
|
magic: null,
|
|
save: null,
|
|
restart: null,
|
|
back: null,
|
|
},
|
|
};
|
|
|
|
function pointFromPointer(
|
|
event: ReactPointerEvent<HTMLElement>,
|
|
element: HTMLElement,
|
|
): BabyLoveDrawingHandPoint {
|
|
const rect = element.getBoundingClientRect();
|
|
const width = rect.width || 1;
|
|
const height = rect.height || 1;
|
|
|
|
return {
|
|
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / width)),
|
|
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / height)),
|
|
state: event.buttons ? 'grab' : 'open_palm',
|
|
};
|
|
}
|
|
|
|
function handToPoint(hand: MocapHandInput | null | undefined) {
|
|
if (!hand) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: hand.x,
|
|
y: hand.y,
|
|
state: hand.state,
|
|
} satisfies BabyLoveDrawingHandPoint;
|
|
}
|
|
|
|
function commandToPlayerLeftHand(command: {
|
|
rightHand?: MocapHandInput | null;
|
|
}) {
|
|
// 本地 mocap handedness 当前按摄像头视角输出:画面右侧手对应用户身体左手。
|
|
return handToPoint(command.rightHand);
|
|
}
|
|
|
|
function commandToPlayerRightHand(command: {
|
|
leftHand?: MocapHandInput | null;
|
|
}) {
|
|
// 本地 mocap handedness 当前按摄像头视角输出:画面左侧手对应用户身体右手。
|
|
return handToPoint(command.leftHand);
|
|
}
|
|
|
|
function smoothHandPoint(
|
|
previous: BabyLoveDrawingHandPoint | null,
|
|
next: BabyLoveDrawingHandPoint,
|
|
): BabyLoveDrawingHandPoint {
|
|
if (!previous) {
|
|
return next;
|
|
}
|
|
|
|
return {
|
|
x:
|
|
previous.x +
|
|
(next.x - previous.x) * BABY_LOVE_DRAWING_HAND_SMOOTHING,
|
|
y:
|
|
previous.y +
|
|
(next.y - previous.y) * BABY_LOVE_DRAWING_HAND_SMOOTHING,
|
|
state: next.state,
|
|
};
|
|
}
|
|
|
|
function getHandPointDistance(
|
|
left: BabyLoveDrawingHandPoint,
|
|
right: BabyLoveDrawingHandPoint,
|
|
) {
|
|
return Math.hypot(left.x - right.x, left.y - right.y);
|
|
}
|
|
|
|
function canAcceptRightHandPoint(
|
|
previous: BabyLoveDrawingHandPoint | null,
|
|
next: BabyLoveDrawingHandPoint | null,
|
|
) {
|
|
if (!next || !previous) {
|
|
return Boolean(next);
|
|
}
|
|
|
|
return (
|
|
getHandPointDistance(previous, next) <=
|
|
BABY_LOVE_DRAWING_RIGHT_HAND_MAX_FRAME_JUMP
|
|
);
|
|
}
|
|
|
|
function sameHoverTarget(
|
|
left: BabyLoveDrawingHoverTarget,
|
|
right: BabyLoveDrawingHoverTarget,
|
|
) {
|
|
if (!left || !right) {
|
|
return left === right;
|
|
}
|
|
|
|
return left.kind === right.kind && left.id === right.id;
|
|
}
|
|
|
|
function findTargetInBounds<T extends string>(
|
|
point: BabyLoveDrawingHandPoint | null,
|
|
bounds: Record<T, BabyLoveDrawingBounds | null>,
|
|
): T | null {
|
|
if (!point) {
|
|
return null;
|
|
}
|
|
|
|
for (const [id, rect] of Object.entries(bounds) as Array<
|
|
[T, BabyLoveDrawingBounds | null]
|
|
>) {
|
|
if (rect && isPointInsideBounds(point, rect)) {
|
|
return id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function drawStrokeSegment(
|
|
context: CanvasRenderingContext2D,
|
|
stroke: BabyLoveDrawingStroke,
|
|
from: BabyLoveDrawingPoint,
|
|
to: BabyLoveDrawingPoint,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
context.save();
|
|
context.lineCap = 'round';
|
|
context.lineJoin = 'round';
|
|
context.lineWidth =
|
|
stroke.tool === 'brush'
|
|
? BABY_LOVE_DRAWING_BRUSH_SIZE
|
|
: BABY_LOVE_DRAWING_ERASER_SIZE;
|
|
if (stroke.tool === 'eraser') {
|
|
context.globalCompositeOperation = 'destination-out';
|
|
context.strokeStyle = 'rgba(0,0,0,1)';
|
|
} else {
|
|
context.globalCompositeOperation = 'source-over';
|
|
context.strokeStyle = stroke.color;
|
|
}
|
|
context.beginPath();
|
|
context.moveTo(from.x * width, from.y * height);
|
|
context.lineTo(to.x * width, to.y * height);
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
export function BabyLoveDrawingRuntimeShell({
|
|
onBack,
|
|
}: BabyLoveDrawingRuntimeShellProps) {
|
|
const shellRef = useRef<HTMLElement | null>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const rectMapRef = useRef<RectMap>(EMPTY_RECT_MAP);
|
|
const activeStrokeRef = useRef<ActiveStrokeState | null>(null);
|
|
const hoverTargetRef = useRef<BabyLoveDrawingHoverTarget>(null);
|
|
const hoverStartedAtRef = useRef<number | null>(null);
|
|
const hoverCompletedKeyRef = useRef<string | null>(null);
|
|
const previousToolGrabRef = useRef<string | null>(null);
|
|
const visibleLeftHandRef = useRef<BabyLoveDrawingHandPoint | null>(null);
|
|
const visibleRightHandRef = useRef<BabyLoveDrawingHandPoint | null>(null);
|
|
const leftHandSeenAtRef = useRef<number | null>(null);
|
|
const rightHandSeenAtRef = useRef<number | null>(null);
|
|
const [phase, setPhase] = useState<BabyLoveDrawingPhase>('drawing');
|
|
const [selectedColor, setSelectedColor] = useState<string>(
|
|
BABY_LOVE_DRAWING_DEFAULT_COLOR,
|
|
);
|
|
const [selectedTool, setSelectedTool] =
|
|
useState<BabyLoveDrawingTool>('brush');
|
|
const [strokes, setStrokes] = useState<BabyLoveDrawingStroke[]>([]);
|
|
const [rightHandPoint, setRightHandPoint] =
|
|
useState<BabyLoveDrawingHandPoint | null>(null);
|
|
const [leftHandPoint, setLeftHandPoint] =
|
|
useState<BabyLoveDrawingHandPoint | null>(null);
|
|
const [hoverTarget, setHoverTarget] =
|
|
useState<BabyLoveDrawingHoverTarget>(null);
|
|
const [hoverProgress, setHoverProgress] = useState(0);
|
|
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
|
|
const [magicImageSrc, setMagicImageSrc] = useState<string | null>(null);
|
|
const [savedRecord, setSavedRecord] = useState<BabyLoveDrawingRecord | null>(
|
|
null,
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const { latestCommand } = useMocapInput({ enabled: true });
|
|
|
|
const canUseMagic = phase === 'finished' || phase === 'magicReady';
|
|
const canSave = phase === 'finished' || phase === 'magicReady';
|
|
|
|
const actionButtons = useMemo(
|
|
() => [
|
|
{
|
|
id: 'finish' as const,
|
|
label: '完成',
|
|
icon: Check,
|
|
visible: phase === 'drawing',
|
|
},
|
|
{
|
|
id: 'magic' as const,
|
|
label: phase === 'magicPending' ? '魔法中' : '使用绘画魔法',
|
|
icon: Sparkles,
|
|
visible: phase === 'finished' || phase === 'magicReady' || phase === 'magicPending',
|
|
},
|
|
{
|
|
id: 'save' as const,
|
|
label: '保存',
|
|
icon: Save,
|
|
visible: canSave,
|
|
},
|
|
{
|
|
id: 'restart' as const,
|
|
label: '再画一张',
|
|
icon: RotateCcw,
|
|
visible: phase === 'saved',
|
|
},
|
|
{
|
|
id: 'back' as const,
|
|
label: '返回',
|
|
icon: ArrowLeft,
|
|
visible: phase === 'saved',
|
|
},
|
|
],
|
|
[canSave, phase],
|
|
);
|
|
|
|
const updateRectMap = useCallback(() => {
|
|
const shell = shellRef.current;
|
|
if (!shell) {
|
|
return;
|
|
}
|
|
|
|
const shellRect = shell.getBoundingClientRect();
|
|
const toUnitBounds = (element: Element | null): BabyLoveDrawingBounds | null => {
|
|
if (!(element instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
left: (rect.left - shellRect.left) / shellRect.width,
|
|
top: (rect.top - shellRect.top) / shellRect.height,
|
|
width: rect.width / shellRect.width,
|
|
height: rect.height / shellRect.height,
|
|
};
|
|
};
|
|
const colors: Record<string, BabyLoveDrawingBounds> = {};
|
|
BABY_LOVE_DRAWING_RAINBOW_COLORS.forEach((color) => {
|
|
const rect = toUnitBounds(
|
|
shell.querySelector(`[data-baby-drawing-color="${color.id}"]`),
|
|
);
|
|
if (rect) {
|
|
colors[color.id] = rect;
|
|
}
|
|
});
|
|
|
|
rectMapRef.current = {
|
|
canvas: toUnitBounds(shell.querySelector('[data-baby-drawing-canvas]')),
|
|
colors,
|
|
tools: {
|
|
brush: toUnitBounds(shell.querySelector('[data-baby-drawing-tool="brush"]')),
|
|
eraser: toUnitBounds(shell.querySelector('[data-baby-drawing-tool="eraser"]')),
|
|
},
|
|
buttons: {
|
|
finish: toUnitBounds(shell.querySelector('[data-baby-drawing-button="finish"]')),
|
|
magic: toUnitBounds(shell.querySelector('[data-baby-drawing-button="magic"]')),
|
|
save: toUnitBounds(shell.querySelector('[data-baby-drawing-button="save"]')),
|
|
restart: toUnitBounds(shell.querySelector('[data-baby-drawing-button="restart"]')),
|
|
back: toUnitBounds(shell.querySelector('[data-baby-drawing-button="back"]')),
|
|
},
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
updateRectMap();
|
|
window.addEventListener('resize', updateRectMap);
|
|
return () => window.removeEventListener('resize', updateRectMap);
|
|
}, [updateRectMap]);
|
|
|
|
useEffect(() => {
|
|
updateRectMap();
|
|
}, [actionButtons, phase, updateRectMap]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const resizeCanvas = () => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
const nextWidth = Math.max(1, Math.floor(rect.width * dpr));
|
|
const nextHeight = Math.max(1, Math.floor(rect.height * dpr));
|
|
if (canvas.width === nextWidth && canvas.height === nextHeight) {
|
|
return;
|
|
}
|
|
|
|
const previousImage = canvas.toDataURL('image/png');
|
|
canvas.width = nextWidth;
|
|
canvas.height = nextHeight;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
return;
|
|
}
|
|
context.fillStyle = '#fffdf4';
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
if (previousImage) {
|
|
const image = new Image();
|
|
image.onload = () => {
|
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
};
|
|
image.src = previousImage;
|
|
}
|
|
};
|
|
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
return () => window.removeEventListener('resize', resizeCanvas);
|
|
}, []);
|
|
|
|
const clearCanvas = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const context = canvas?.getContext('2d');
|
|
if (!canvas || !context) {
|
|
return;
|
|
}
|
|
|
|
context.globalCompositeOperation = 'source-over';
|
|
context.fillStyle = '#fffdf4';
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
clearCanvas();
|
|
}, [clearCanvas]);
|
|
|
|
const captureOriginalImage = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
return null;
|
|
}
|
|
|
|
return canvas.toDataURL('image/png');
|
|
}, []);
|
|
|
|
const finishDrawing = useCallback(() => {
|
|
const imageSrc = captureOriginalImage();
|
|
if (!imageSrc) {
|
|
return;
|
|
}
|
|
|
|
activeStrokeRef.current = null;
|
|
setOriginalImageSrc(imageSrc);
|
|
setPhase('finished');
|
|
setError(null);
|
|
}, [captureOriginalImage]);
|
|
|
|
const restartDrawing = useCallback(() => {
|
|
activeStrokeRef.current = null;
|
|
hoverTargetRef.current = null;
|
|
hoverStartedAtRef.current = null;
|
|
hoverCompletedKeyRef.current = null;
|
|
setPhase('drawing');
|
|
setSelectedColor(BABY_LOVE_DRAWING_DEFAULT_COLOR);
|
|
setSelectedTool('brush');
|
|
setStrokes([]);
|
|
setOriginalImageSrc(null);
|
|
setMagicImageSrc(null);
|
|
setSavedRecord(null);
|
|
setError(null);
|
|
setHoverTarget(null);
|
|
setHoverProgress(0);
|
|
clearCanvas();
|
|
}, [clearCanvas]);
|
|
|
|
const saveCurrentDrawing = useCallback(() => {
|
|
const imageSrc = originalImageSrc ?? captureOriginalImage();
|
|
if (!imageSrc) {
|
|
return;
|
|
}
|
|
|
|
const response = saveBabyLoveDrawing({
|
|
originalImageSrc: imageSrc,
|
|
magicImageSrc,
|
|
strokeTrace: strokes,
|
|
});
|
|
setOriginalImageSrc(imageSrc);
|
|
setSavedRecord(response.record);
|
|
setPhase('saved');
|
|
setError(null);
|
|
}, [captureOriginalImage, magicImageSrc, originalImageSrc, strokes]);
|
|
|
|
const generateMagicImage = useCallback(async () => {
|
|
const imageSrc = originalImageSrc ?? captureOriginalImage();
|
|
if (!imageSrc || phase === 'magicPending') {
|
|
return;
|
|
}
|
|
|
|
setOriginalImageSrc(imageSrc);
|
|
setPhase('magicPending');
|
|
setError(null);
|
|
try {
|
|
const response = await createBabyLoveDrawingMagicImage({
|
|
originalImageSrc: imageSrc,
|
|
strokeTrace: strokes,
|
|
});
|
|
setMagicImageSrc(response.magicImageSrc);
|
|
setPhase('magicReady');
|
|
} catch (magicError) {
|
|
setError(
|
|
magicError instanceof Error
|
|
? magicError.message
|
|
: '生成宝贝爱画魔法图片失败。',
|
|
);
|
|
setPhase('finished');
|
|
}
|
|
}, [captureOriginalImage, originalImageSrc, phase, strokes]);
|
|
|
|
const triggerButton = useCallback(
|
|
(buttonId: string) => {
|
|
if (buttonId === 'finish' && phase === 'drawing') {
|
|
finishDrawing();
|
|
return;
|
|
}
|
|
|
|
if (buttonId === 'magic' && canUseMagic) {
|
|
void generateMagicImage();
|
|
return;
|
|
}
|
|
|
|
if (buttonId === 'save' && canSave) {
|
|
saveCurrentDrawing();
|
|
return;
|
|
}
|
|
|
|
if (buttonId === 'restart' && phase === 'saved') {
|
|
restartDrawing();
|
|
return;
|
|
}
|
|
|
|
if (buttonId === 'back' && phase === 'saved') {
|
|
onBack?.();
|
|
}
|
|
},
|
|
[
|
|
canSave,
|
|
canUseMagic,
|
|
finishDrawing,
|
|
generateMagicImage,
|
|
onBack,
|
|
phase,
|
|
restartDrawing,
|
|
saveCurrentDrawing,
|
|
],
|
|
);
|
|
|
|
const applyHoverTarget = useCallback(
|
|
(nextTarget: BabyLoveDrawingHoverTarget) => {
|
|
const currentTarget = hoverTargetRef.current;
|
|
const now = Date.now();
|
|
if (!sameHoverTarget(currentTarget, nextTarget)) {
|
|
hoverTargetRef.current = nextTarget;
|
|
hoverStartedAtRef.current = nextTarget ? now : null;
|
|
hoverCompletedKeyRef.current = null;
|
|
setHoverTarget(nextTarget);
|
|
setHoverProgress(0);
|
|
return;
|
|
}
|
|
|
|
const startedAt = hoverStartedAtRef.current;
|
|
const progress = resolveHoverProgress(nextTarget, startedAt, now);
|
|
setHoverProgress(progress);
|
|
if (!hasHoverCompleted(nextTarget, startedAt, now) || !nextTarget) {
|
|
return;
|
|
}
|
|
|
|
const completeKey = `${nextTarget.kind}:${nextTarget.id}`;
|
|
if (hoverCompletedKeyRef.current === completeKey) {
|
|
return;
|
|
}
|
|
|
|
hoverCompletedKeyRef.current = completeKey;
|
|
if (nextTarget.kind === 'color') {
|
|
const color = BABY_LOVE_DRAWING_RAINBOW_COLORS.find(
|
|
(item) => item.id === nextTarget.id,
|
|
);
|
|
if (color) {
|
|
setSelectedColor(color.value);
|
|
setSelectedTool('brush');
|
|
}
|
|
return;
|
|
}
|
|
|
|
triggerButton(nextTarget.id);
|
|
},
|
|
[triggerButton],
|
|
);
|
|
|
|
const updateToolFromRightHand = useCallback((point: BabyLoveDrawingHandPoint | null) => {
|
|
if (!point || point.state !== 'grab') {
|
|
previousToolGrabRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const tool = findTargetInBounds(point, rectMapRef.current.tools);
|
|
if (!tool) {
|
|
previousToolGrabRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (previousToolGrabRef.current === tool) {
|
|
return;
|
|
}
|
|
|
|
previousToolGrabRef.current = tool;
|
|
setSelectedTool(tool);
|
|
}, []);
|
|
|
|
const drawWithRightHand = useCallback(
|
|
(point: BabyLoveDrawingHandPoint | null) => {
|
|
const canvasBounds = rectMapRef.current.canvas;
|
|
const canvas = canvasRef.current;
|
|
const context = canvas?.getContext('2d');
|
|
if (
|
|
phase !== 'drawing' ||
|
|
!point ||
|
|
point.state !== 'grab' ||
|
|
!canvasBounds ||
|
|
!canvas ||
|
|
!context ||
|
|
!isPointInsideBounds(point, canvasBounds)
|
|
) {
|
|
activeStrokeRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const nextPoint = toCanvasPoint(point, canvasBounds);
|
|
const activeStroke = activeStrokeRef.current;
|
|
if (!activeStroke) {
|
|
const stroke = createBabyDrawingStroke(
|
|
selectedTool,
|
|
selectedColor,
|
|
nextPoint,
|
|
);
|
|
activeStrokeRef.current = {
|
|
stroke,
|
|
lastPoint: nextPoint,
|
|
};
|
|
setStrokes((current) => [...current, stroke]);
|
|
return;
|
|
}
|
|
|
|
const nextStroke = appendPointToStroke(activeStroke.stroke, nextPoint);
|
|
drawStrokeSegment(
|
|
context,
|
|
nextStroke,
|
|
activeStroke.lastPoint,
|
|
nextPoint,
|
|
canvas.width,
|
|
canvas.height,
|
|
);
|
|
activeStrokeRef.current = {
|
|
stroke: nextStroke,
|
|
lastPoint: nextPoint,
|
|
};
|
|
setStrokes((current) =>
|
|
current.map((stroke) =>
|
|
stroke.strokeId === nextStroke.strokeId ? nextStroke : stroke,
|
|
),
|
|
);
|
|
},
|
|
[phase, selectedColor, selectedTool],
|
|
);
|
|
|
|
const updateInteraction = useCallback(
|
|
(
|
|
nextLeftHand: BabyLoveDrawingHandPoint | null,
|
|
nextRightHand: BabyLoveDrawingHandPoint | null,
|
|
) => {
|
|
const now = Date.now();
|
|
const previousLeftHand = visibleLeftHandRef.current;
|
|
const previousRightHand = visibleRightHandRef.current;
|
|
const acceptedRightHand = canAcceptRightHandPoint(
|
|
previousRightHand,
|
|
nextRightHand,
|
|
)
|
|
? nextRightHand
|
|
: null;
|
|
const visibleLeftHand = nextLeftHand
|
|
? smoothHandPoint(previousLeftHand, nextLeftHand)
|
|
: previousLeftHand &&
|
|
leftHandSeenAtRef.current !== null &&
|
|
now - leftHandSeenAtRef.current <=
|
|
BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS
|
|
? previousLeftHand
|
|
: null;
|
|
const visibleRightHand = acceptedRightHand
|
|
? smoothHandPoint(previousRightHand, acceptedRightHand)
|
|
: previousRightHand &&
|
|
rightHandSeenAtRef.current !== null &&
|
|
now - rightHandSeenAtRef.current <=
|
|
BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS
|
|
? previousRightHand
|
|
: null;
|
|
const activeRightHand = acceptedRightHand ? visibleRightHand : null;
|
|
|
|
if (nextLeftHand) {
|
|
leftHandSeenAtRef.current = now;
|
|
}
|
|
if (acceptedRightHand) {
|
|
rightHandSeenAtRef.current = now;
|
|
}
|
|
|
|
visibleLeftHandRef.current = visibleLeftHand;
|
|
visibleRightHandRef.current = visibleRightHand;
|
|
setLeftHandPoint(visibleLeftHand);
|
|
setRightHandPoint(visibleRightHand);
|
|
updateToolFromRightHand(activeRightHand);
|
|
drawWithRightHand(activeRightHand);
|
|
|
|
const colorId = findTargetInBounds(
|
|
visibleLeftHand,
|
|
rectMapRef.current.colors,
|
|
);
|
|
const buttonId =
|
|
findTargetInBounds(visibleLeftHand, rectMapRef.current.buttons) ??
|
|
findTargetInBounds(visibleRightHand, rectMapRef.current.buttons);
|
|
const nextHoverTarget: BabyLoveDrawingHoverTarget = colorId
|
|
? { kind: 'color', id: colorId }
|
|
: buttonId
|
|
? { kind: 'button', id: buttonId }
|
|
: null;
|
|
applyHoverTarget(nextHoverTarget);
|
|
},
|
|
[applyHoverTarget, drawWithRightHand, updateToolFromRightHand],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!latestCommand) {
|
|
return;
|
|
}
|
|
|
|
updateInteraction(
|
|
commandToPlayerLeftHand(latestCommand),
|
|
commandToPlayerRightHand(latestCommand),
|
|
);
|
|
}, [latestCommand, updateInteraction]);
|
|
|
|
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
|
const point = pointFromPointer(event, event.currentTarget);
|
|
if (event.button === 2) {
|
|
updateInteraction(leftHandPoint, { ...point, state: 'grab' as const });
|
|
return;
|
|
}
|
|
|
|
updateInteraction({ ...point, state: 'open_palm' as const }, null);
|
|
};
|
|
|
|
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
|
const point = pointFromPointer(event, event.currentTarget);
|
|
const nextState: BabyLoveDrawingHandPoint['state'] = event.buttons
|
|
? 'grab'
|
|
: 'open_palm';
|
|
const nextPoint: BabyLoveDrawingHandPoint = {
|
|
...point,
|
|
state: nextState,
|
|
};
|
|
if (event.buttons === 2) {
|
|
updateInteraction(leftHandPoint, nextPoint);
|
|
return;
|
|
}
|
|
updateInteraction(nextPoint, null);
|
|
};
|
|
|
|
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
|
|
const point = pointFromPointer(event, event.currentTarget);
|
|
if (event.button === 2) {
|
|
updateInteraction(leftHandPoint, { ...point, state: 'open_palm' as const });
|
|
return;
|
|
}
|
|
|
|
updateInteraction({ ...point, state: 'open_palm' as const }, null);
|
|
};
|
|
|
|
return (
|
|
<main
|
|
ref={shellRef}
|
|
className="baby-love-drawing-runtime"
|
|
data-testid="baby-love-drawing-runtime"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerUp}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="baby-love-drawing-runtime__back"
|
|
onClick={onBack}
|
|
aria-label="返回"
|
|
title="返回"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</button>
|
|
|
|
<div className="baby-love-drawing-runtime__colors" aria-label="颜色">
|
|
{BABY_LOVE_DRAWING_RAINBOW_COLORS.map((color) => (
|
|
<button
|
|
key={color.id}
|
|
type="button"
|
|
data-baby-drawing-color={color.id}
|
|
className={`baby-love-drawing-runtime__color${
|
|
selectedColor === color.value
|
|
? ' baby-love-drawing-runtime__color--active'
|
|
: ''
|
|
}`}
|
|
style={{ '--baby-drawing-color': color.value } as CSSProperties}
|
|
aria-label={color.label}
|
|
title={color.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<section className="baby-love-drawing-runtime__board">
|
|
<canvas
|
|
ref={canvasRef}
|
|
data-baby-drawing-canvas
|
|
className="baby-love-drawing-runtime__canvas"
|
|
aria-label="画板"
|
|
/>
|
|
{magicImageSrc && phase !== 'drawing' ? (
|
|
<img
|
|
src={magicImageSrc}
|
|
alt="绘画魔法结果"
|
|
className="baby-love-drawing-runtime__magic-image"
|
|
/>
|
|
) : null}
|
|
{phase === 'magicPending' ? (
|
|
<div className="baby-love-drawing-runtime__magic-pending">
|
|
<Sparkles className="h-7 w-7" />
|
|
绘画魔法
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<div className="baby-love-drawing-runtime__tools" aria-label="工具">
|
|
<button
|
|
type="button"
|
|
data-baby-drawing-tool="brush"
|
|
className={`baby-love-drawing-runtime__tool${
|
|
selectedTool === 'brush'
|
|
? ' baby-love-drawing-runtime__tool--active'
|
|
: ''
|
|
}`}
|
|
aria-label="画笔"
|
|
title="画笔"
|
|
>
|
|
<Brush className="h-7 w-7" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-baby-drawing-tool="eraser"
|
|
className={`baby-love-drawing-runtime__tool${
|
|
selectedTool === 'eraser'
|
|
? ' baby-love-drawing-runtime__tool--active'
|
|
: ''
|
|
}`}
|
|
aria-label="橡皮"
|
|
title="橡皮"
|
|
>
|
|
<Eraser className="h-7 w-7" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="baby-love-drawing-runtime__actions">
|
|
{actionButtons
|
|
.filter((button) => button.visible)
|
|
.map((button) => {
|
|
const Icon = button.icon;
|
|
const isHovering =
|
|
hoverTarget?.kind === 'button' && hoverTarget.id === button.id;
|
|
return (
|
|
<button
|
|
key={button.id}
|
|
type="button"
|
|
data-baby-drawing-button={button.id}
|
|
className="baby-love-drawing-runtime__action"
|
|
disabled={button.id === 'magic' && phase === 'magicPending'}
|
|
onClick={() => triggerButton(button.id)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
<span>{button.label}</span>
|
|
{isHovering ? (
|
|
<span
|
|
className="baby-love-drawing-runtime__action-progress"
|
|
style={
|
|
{
|
|
'--baby-drawing-hover-progress': `${hoverProgress * 100}%`,
|
|
} as CSSProperties
|
|
}
|
|
/>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{error ? (
|
|
<PlatformRuntimeStatusToast
|
|
tone="error"
|
|
className="baby-love-drawing-runtime__status baby-love-drawing-runtime__status--top"
|
|
>
|
|
{error}
|
|
</PlatformRuntimeStatusToast>
|
|
) : null}
|
|
|
|
{savedRecord ? (
|
|
<PlatformRuntimeStatusToast
|
|
tone="success"
|
|
role="status"
|
|
className="baby-love-drawing-runtime__status baby-love-drawing-runtime__status--saved"
|
|
>
|
|
<ImagePlus className="h-5 w-5" />
|
|
已保存
|
|
</PlatformRuntimeStatusToast>
|
|
) : null}
|
|
|
|
{leftHandPoint ? (
|
|
<div
|
|
className="baby-love-drawing-runtime__left-hand-indicator"
|
|
aria-hidden="true"
|
|
style={
|
|
{
|
|
left: `${leftHandPoint.x * 100}%`,
|
|
top: `${leftHandPoint.y * 100}%`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<span />
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={`baby-love-drawing-runtime__cursor baby-love-drawing-runtime__cursor--${selectedTool}`}
|
|
style={
|
|
{
|
|
left: `${(rightHandPoint?.x ?? 0.5) * 100}%`,
|
|
top: `${(rightHandPoint?.y ?? 0.5) * 100}%`,
|
|
'--baby-drawing-color': selectedColor,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
{selectedTool === 'brush' ? (
|
|
<Brush className="h-5 w-5" />
|
|
) : (
|
|
<Eraser className="h-5 w-5" />
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default BabyLoveDrawingRuntimeShell;
|