feat: add edutainment drawing and visual package flows
This commit is contained in:
@@ -0,0 +1,932 @@
|
||||
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 {
|
||||
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 ? (
|
||||
<div className="baby-love-drawing-runtime__error">{error}</div>
|
||||
) : null}
|
||||
|
||||
{savedRecord ? (
|
||||
<div className="baby-love-drawing-runtime__saved" role="status">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
已保存
|
||||
</div>
|
||||
) : 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;
|
||||
Reference in New Issue
Block a user