mediasfu-reactnative-expo
Version:
mediasfu-reactnative-expo – Expo-managed React Native WebRTC SDK for video conferencing, webinars, live streaming, broadcast, screen sharing, whiteboard, chat, recording, live subtitles, translation, and AI agent rooms on iOS, Android, and web. Prebuilt r
1,157 lines • 102 kB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
import * as ExpoImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import { FontAwesome5 } from '@expo/vector-icons';
import { Image, Modal, ScrollView, Share, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native';
import ColorPicker, { Panel1, Swatches, Preview, OpacitySlider, HueSlider, } from 'reanimated-color-picker';
import RNPickerSelect from 'react-native-picker-select';
import { createThemedPickerSelectStyles, getModalBodyTheme } from '../../components_modern/core/modalBodyTheme';
const EMPTY_BOARD_ITEMS = [];
const LINE_THICKNESS_OPTIONS = [3, 6, 12, 18, 24, 36];
const BRUSH_THICKNESS_OPTIONS = [5, 10, 20, 40, 60];
const ERASER_THICKNESS_OPTIONS = [5, 10, 20, 30, 60];
const FONT_SIZE_OPTIONS = [10, 16, 20, 32, 48, 60];
const FONT_FAMILY_OPTIONS = [
{ label: 'System', value: 'System' },
{ label: 'Sans', value: 'sans-serif' },
{ label: 'Serif', value: 'serif' },
{ label: 'Mono', value: 'monospace' },
];
const LINE_TYPE_OPTIONS = [
{ label: 'Solid', value: 'solid' },
{ label: 'Dash', value: 'dashed' },
{ label: 'Dot', value: 'dotted' },
{ label: 'Mix', value: 'dashDot' },
];
const DEFAULT_BOARD_SIZE = { width: 1280, height: 720 };
const HIGH_RES_BOARD_SIZE = { width: 1920, height: 1080 };
const getTextContent = (value) => (typeof value === 'string' ? value.replace(/\r/g, '') : '');
const getTextMetrics = (shape) => {
const fontPx = Math.max(10, shape?.fontSize || 20);
const lines = getTextContent(shape?.text).split('\n');
const longestLine = lines.reduce((maxLength, line) => Math.max(maxLength, line.length), 0);
return {
width: Math.max(longestLine * fontPx * 0.58, fontPx * 0.9, 32),
height: Math.max(lines.length * fontPx * 1.2, 16),
};
};
const Whiteboard = (props) => {
const { isWhiteboardModalVisible, isVisible, onWhiteboardClose, parameters = {}, } = props;
const visible = Boolean(isWhiteboardModalVisible ?? isVisible);
const useInlineLayout = isWhiteboardModalVisible === undefined;
const compactInlineToolbar = useInlineLayout;
const isDarkMode = typeof props.isDarkMode === 'boolean'
? props.isDarkMode
: typeof parameters?.isDarkModeValue === 'boolean'
? parameters.isDarkModeValue
: true;
const theme = getModalBodyTheme(isDarkMode);
const compactPickerTheme = useMemo(() => {
const baseStyles = createThemedPickerSelectStyles(theme);
return {
...baseStyles,
inputIOS: {
...baseStyles.inputIOS,
fontSize: 12,
minHeight: 38,
paddingVertical: 8,
paddingHorizontal: 10,
paddingRight: 28,
borderRadius: 10,
},
inputAndroid: {
...baseStyles.inputAndroid,
fontSize: 12,
minHeight: 38,
paddingVertical: 8,
paddingHorizontal: 10,
paddingRight: 28,
borderRadius: 10,
marginVertical: 0,
},
inputWeb: {
...baseStyles.inputWeb,
fontSize: 12,
minHeight: 38,
paddingVertical: 7,
paddingHorizontal: 10,
paddingRight: 28,
borderRadius: 10,
marginBottom: 0,
},
iconContainer: {
top: 12,
right: 10,
},
viewContainer: {
minWidth: 84,
},
};
}, [theme]);
const { socket, roomName, islevel, member, targetResolution, targetResolutionHost, whiteboardStarted, whiteboardEnded, whiteboardUsers = EMPTY_BOARD_ITEMS, shapes = EMPTY_BOARD_ITEMS, redoStack = EMPTY_BOARD_ITEMS, undoStack = EMPTY_BOARD_ITEMS, useImageBackground, recordStarted, recordStopped, recordPaused, recordResumed, recordingMediaOptions, updateShapes, updateUseImageBackground, updateRedoStack, updateUndoStack, updateWhiteboardStarted, updateWhiteboardEnded, updateWhiteboardUsers, updateParticipants, updateScreenId, updateShareScreenStarted, updateIsWhiteboardModalVisible, onScreenChanges, captureCanvasStream, showAlert, } = parameters;
const [actionStatus, setActionStatus] = useState('Awaiting actions');
const [localShapes, setLocalShapes] = useState(Array.isArray(shapes) ? shapes : []);
const [localRedo, setLocalRedo] = useState(Array.isArray(redoStack) ? redoStack : []);
const [localUndo, setLocalUndo] = useState(Array.isArray(undoStack) ? undoStack : []);
const [tool, setTool] = useState('draw');
const [shapeType, setShapeType] = useState('rectangle');
const [lineType, setLineType] = useState('solid');
const [lineThickness, setLineThickness] = useState(6);
const [brushThickness, setBrushThickness] = useState(10);
const [eraserThickness, setEraserThickness] = useState(20);
const [fontSize, setFontSize] = useState(20);
const [fontFamily, setFontFamily] = useState('System');
const [selectedShapeIndex, setSelectedShapeIndex] = useState(null);
const [zoomScale, setZoomScale] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const [toolbarVisible, setToolbarVisible] = useState(true);
const [expandedPanel, setExpandedPanel] = useState(null);
const [isColorPickerVisible, setIsColorPickerVisible] = useState(false);
const [penColor, setPenColor] = useState('#0ea5e9');
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 });
const [draftPoints, setDraftPoints] = useState([]);
const [imageUrl, setImageUrl] = useState('');
const [isTextEditorVisible, setIsTextEditorVisible] = useState(false);
const [textDraftValue, setTextDraftValue] = useState('');
const [textDraftPoint, setTextDraftPoint] = useState(null);
const [editingTextIndex, setEditingTextIndex] = useState(null);
const startPointRef = useRef(null);
const dragPointRef = useRef(null);
const boardSize = useMemo(() => (targetResolution === 'qhd'
|| targetResolutionHost === 'qhd'
|| targetResolution === 'fhd'
|| targetResolutionHost === 'fhd'
? HIGH_RES_BOARD_SIZE
: DEFAULT_BOARD_SIZE), [targetResolution, targetResolutionHost]);
const fitScale = useMemo(() => {
if (surfaceSize.width <= 1 || surfaceSize.height <= 1) {
return 1;
}
return Math.min(surfaceSize.width / boardSize.width, surfaceSize.height / boardSize.height);
}, [boardSize.height, boardSize.width, surfaceSize.height, surfaceSize.width]);
const boardRenderScale = useMemo(() => Math.max(fitScale * zoomScale, 0.01), [fitScale, zoomScale]);
const boardBaseOffset = useMemo(() => ({
x: Math.max(0, (surfaceSize.width - boardSize.width * boardRenderScale) / 2),
y: Math.max(0, (surfaceSize.height - boardSize.height * boardRenderScale) / 2),
}), [boardRenderScale, boardSize.height, boardSize.width, surfaceSize.height, surfaceSize.width]);
const toolOptions = [
{ key: 'draw', label: 'Line', icon: 'pencil-alt' },
{ key: 'freehand', label: 'Brush', icon: 'paint-brush' },
{ key: 'shape', label: 'Shape', icon: 'draw-polygon' },
{ key: 'select', label: 'Select', icon: 'mouse-pointer' },
{ key: 'erase', label: 'Erase', icon: 'eraser' },
{ key: 'pan', label: 'Pan', icon: 'hand-paper' },
{ key: 'text', label: 'Text', icon: 'font' },
];
const shapeOptions = [
{ key: 'square', label: 'Square', icon: 'square' },
{ key: 'rectangle', label: 'Rect', icon: 'vector-square' },
{ key: 'circle', label: 'Circle', icon: 'circle' },
{ key: 'triangle', label: 'Tri', icon: 'play' },
{ key: 'rhombus', label: 'Rhombus', icon: 'gem' },
{ key: 'pentagon', label: 'Pent', icon: 'draw-polygon' },
{ key: 'hexagon', label: 'Hex', icon: 'certificate' },
{ key: 'octagon', label: 'Oct', icon: 'stop' },
{ key: 'parallelogram', label: 'Para', icon: 'italic' },
{ key: 'oval', label: 'Oval', icon: 'circle-notch' },
{ key: 'line', label: 'Line', icon: 'minus' },
];
const toolSupportsPanel = (value) => (value === 'draw'
|| value === 'freehand'
|| value === 'shape'
|| value === 'erase'
|| value === 'text');
const handleToolPress = (nextTool) => {
setTool(nextTool);
if (compactInlineToolbar) {
setExpandedPanel(null);
return;
}
if (toolSupportsPanel(nextTool)) {
setExpandedPanel((previous) => (previous === nextTool && tool === nextTool ? null : nextTool));
return;
}
setExpandedPanel(null);
};
const draftShape = useMemo(() => {
if (tool !== 'shape' || draftPoints.length < 2) {
return null;
}
return {
type: shapeType,
x1: draftPoints[0].x,
y1: draftPoints[0].y,
x2: draftPoints[1].x,
y2: draftPoints[1].y,
color: penColor,
thickness: lineThickness,
lineType,
};
}, [draftPoints, lineThickness, lineType, penColor, shapeType, tool]);
const toggleExpandedPanel = (panel) => {
setExpandedPanel((previous) => (previous === panel ? null : panel));
};
const handlePenColorSelect = ({ hex }) => {
setPenColor(hex);
setActionStatus(`Color ${hex.toUpperCase()}`);
};
const canEdit = useMemo(() => {
if (islevel === '2')
return true;
return Boolean(whiteboardUsers.find((user) => user?.name === member && user?.useBoard));
}, [islevel, member, whiteboardUsers]);
const notify = (message, type = 'info') => {
if (typeof showAlert === 'function') {
showAlert({ message, type });
}
setActionStatus(message);
};
const handleBoardLayout = (width, height) => {
const nextWidth = Math.max(1, Math.round(width));
const nextHeight = Math.max(1, Math.round(height));
setSurfaceSize((previous) => {
if (Math.abs(previous.width - nextWidth) < 1 && Math.abs(previous.height - nextHeight) < 1) {
return previous;
}
return { width: nextWidth, height: nextHeight };
});
};
const syncShapes = (next) => {
setLocalShapes(next);
updateShapes?.(next);
};
const syncRedo = (next) => {
setLocalRedo(next);
updateRedoStack?.(next);
};
const syncUndo = (next) => {
setLocalUndo(next);
updateUndoStack?.(next);
};
const handleServerResponse = (response) => {
if (!response?.success) {
notify(`Whiteboard action failed: ${response?.reason || 'unknown reason'}`, 'danger');
}
};
const normalizeIncomingShape = (action, payload) => {
if (action === 'draw' || action === 'shape') {
return {
type: payload?.type || (action === 'draw' ? 'freehand' : 'line'),
...payload,
};
}
if (action === 'text') {
const nextText = getTextContent(payload?.text).trim();
if (!nextText) {
return null;
}
return {
type: 'text',
...payload,
text: nextText,
};
}
return null;
};
const eraseShapesAt = (targetX, targetY, radius = 20) => {
const within = (x, y) => {
const dx = x - targetX;
const dy = y - targetY;
return dx * dx + dy * dy <= radius * radius;
};
return localShapes.filter((shape) => {
if (shape?.type === 'freehand' && Array.isArray(shape.points)) {
return !shape.points.some((p) => within(p?.x || 0, p?.y || 0));
}
if (shape?.type === 'text') {
return !within(shape?.x || 0, shape?.y || 0);
}
const x1 = shape?.x1 || 0;
const y1 = shape?.y1 || 0;
const x2 = shape?.x2 ?? x1;
const y2 = shape?.y2 ?? y1;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return !within(cx, cy);
});
};
const applyBoardAction = (action, payload) => {
const sameShape = (left, right) => {
if (!left || !right)
return false;
if (left === right)
return true;
return (left.type === right.type &&
left.src === right.src &&
left.text === right.text &&
left.x === right.x &&
left.y === right.y &&
left.x1 === right.x1 &&
left.y1 === right.y1 &&
left.x2 === right.x2 &&
left.y2 === right.y2);
};
switch (action) {
case 'draw':
case 'shape':
case 'text': {
const shape = fitShapeWithinBoard(normalizeIncomingShape(action, payload));
if (!shape)
return;
syncShapes([...localShapes, shape]);
break;
}
case 'erase': {
const nextShapes = eraseShapesAt(payload?.x || 0, payload?.y || 0, payload?.size || 20);
syncShapes(nextShapes);
break;
}
case 'clear': {
syncShapes([]);
syncRedo([]);
syncUndo([]);
break;
}
case 'uploadImage': {
if (payload && typeof payload === 'object') {
const nextShape = fitShapeWithinBoard({ type: 'image', ...payload });
if (nextShape) {
syncShapes([...localShapes, nextShape]);
}
}
break;
}
case 'undo': {
if (localShapes.length < 1)
return;
const nextShapes = [...localShapes];
const removed = nextShapes.pop();
syncShapes(nextShapes);
syncRedo([...(localRedo || []), removed]);
syncUndo([...(localUndo || []), 'undo']);
break;
}
case 'redo': {
if (localRedo.length < 1)
return;
const nextRedo = [...localRedo];
const restored = nextRedo.pop();
syncRedo(nextRedo);
syncShapes([...(localShapes || []), restored]);
syncUndo([...(localUndo || []), 'redo']);
break;
}
case 'toggleBackground': {
const nextValue = typeof payload === 'boolean' ? payload : !useImageBackground;
updateUseImageBackground?.(nextValue);
break;
}
case 'deleteShape': {
if (typeof payload?.index === 'number') {
syncShapes(localShapes.filter((_shape, index) => index !== payload.index));
}
else if (payload && typeof payload === 'object') {
syncShapes(localShapes.filter((shape) => !sameShape(shape, payload)));
}
break;
}
case 'shapes': {
if (payload?.shapes && Array.isArray(payload.shapes)) {
syncShapes(payload.shapes.map((shape) => fitShapeWithinBoard(shape)).filter(Boolean));
}
break;
}
default:
break;
}
};
useEffect(() => {
if (!socket || typeof socket.on !== 'function')
return;
const handleWhiteboardAction = (data) => {
const { action, payload } = data || {};
applyBoardAction(action, payload);
setActionStatus(`Last action: ${action || 'unknown'}`);
};
const handleWhiteboardUpdated = async (data) => {
try {
if (islevel === '2' && data?.members) {
const filteredParticipants = data.members.filter((participant) => !participant?.isBanned);
updateParticipants?.(filteredParticipants);
}
updateWhiteboardUsers?.(data?.whiteboardUsers || []);
if (data?.whiteboardData && typeof data.whiteboardData === 'object') {
if (Array.isArray(data.whiteboardData.shapes)) {
syncShapes(data.whiteboardData.shapes
.map((shape) => fitShapeWithinBoard(shape))
.filter(Boolean));
}
if (typeof data.whiteboardData.useImageBackground === 'boolean') {
updateUseImageBackground?.(data.whiteboardData.useImageBackground);
}
else {
updateUseImageBackground?.(true);
}
if (Array.isArray(data.whiteboardData.redoStack)) {
syncRedo(data.whiteboardData.redoStack);
}
if (Array.isArray(data.whiteboardData.undoStack)) {
syncUndo(data.whiteboardData.undoStack);
}
}
if (data?.status === 'started' && !whiteboardStarted) {
updateWhiteboardStarted?.(true);
updateWhiteboardEnded?.(false);
const nextScreenId = `whiteboard-${roomName}`;
updateScreenId?.(nextScreenId);
if (islevel !== '2') {
updateShareScreenStarted?.(true);
await onScreenChanges?.({ changed: true, parameters });
}
}
else if (data?.status === 'ended') {
const prevWhiteboardEnded = whiteboardEnded;
const prevWhiteboardStarted = whiteboardStarted;
updateWhiteboardStarted?.(false);
updateWhiteboardEnded?.(true);
if (!(islevel === '2' && prevWhiteboardEnded)) {
updateShareScreenStarted?.(false);
updateScreenId?.('');
await onScreenChanges?.({ changed: true, parameters });
}
if (prevWhiteboardStarted &&
islevel === '2' &&
(recordStarted || recordResumed) &&
!(recordPaused || recordStopped) &&
recordingMediaOptions === 'video') {
await captureCanvasStream?.({ parameters, start: false });
}
}
else if (data?.status === 'started' && whiteboardStarted) {
updateWhiteboardStarted?.(true);
updateWhiteboardEnded?.(false);
updateShareScreenStarted?.(true);
updateScreenId?.(`whiteboard-${roomName}`);
await onScreenChanges?.({ changed: true, parameters });
}
}
catch (error) {
console.error('Error in whiteboardUpdated:', error);
}
};
socket.on('whiteboardAction', handleWhiteboardAction);
socket.on('whiteboardUpdated', handleWhiteboardUpdated);
return () => {
if (typeof socket.off === 'function') {
socket.off('whiteboardAction', handleWhiteboardAction);
socket.off('whiteboardUpdated', handleWhiteboardUpdated);
}
};
}, [
socket,
roomName,
islevel,
parameters,
whiteboardStarted,
useImageBackground,
recordStarted,
recordStopped,
recordPaused,
recordResumed,
recordingMediaOptions,
onScreenChanges,
captureCanvasStream,
]);
const projectPoint = (point) => ({
x: point.x * boardRenderScale + panOffset.x + boardBaseOffset.x,
y: point.y * boardRenderScale + panOffset.y + boardBaseOffset.y,
});
const screenToBoardPoint = (pointX, pointY) => ({
x: (pointX - panOffset.x - boardBaseOffset.x) / boardRenderScale,
y: (pointY - panOffset.y - boardBaseOffset.y) / boardRenderScale,
});
const clampValue = (value, min, max) => {
if (!Number.isFinite(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
};
const hasBoardBounds = boardSize.width > 1 && boardSize.height > 1;
const isPointWithinBoard = (point) => {
if (!hasBoardBounds) {
return true;
}
return (point.x >= 0
&& point.x <= boardSize.width
&& point.y >= 0
&& point.y <= boardSize.height);
};
const clampBoardPoint = (point) => {
if (!hasBoardBounds) {
return point;
}
return {
x: clampValue(point.x, 0, boardSize.width),
y: clampValue(point.y, 0, boardSize.height),
};
};
const getShapeBounds = (shape) => {
if (!shape)
return null;
if (shape.type === 'freehand' && Array.isArray(shape.points) && shape.points.length) {
const xValues = shape.points.map((point) => point?.x || 0);
const yValues = shape.points.map((point) => point?.y || 0);
return {
left: Math.min(...xValues),
top: Math.min(...yValues),
width: Math.max(...xValues) - Math.min(...xValues),
height: Math.max(...yValues) - Math.min(...yValues),
};
}
if (shape.type === 'text') {
const textMetrics = getTextMetrics(shape);
return {
left: shape.x || 0,
top: shape.y || 0,
width: textMetrics.width,
height: textMetrics.height,
};
}
const x1 = shape.x1 || 0;
const y1 = shape.y1 || 0;
const x2 = shape.x2 ?? x1;
const y2 = shape.y2 ?? y1;
if (shape.type === 'square') {
const sideDelta = x2 - x1;
return {
left: Math.min(x1, x1 + sideDelta),
top: Math.min(y1, y1 + sideDelta),
width: Math.abs(sideDelta),
height: Math.abs(sideDelta),
};
}
if (shape.type === 'circle') {
const radius = Math.hypot(x2 - x1, y2 - y1);
return {
left: x1 - radius,
top: y1 - radius,
width: radius * 2,
height: radius * 2,
};
}
const left = Math.min(x1, x2);
const top = Math.min(y1, y2);
return {
left,
top,
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
};
};
const translateShape = (shape, deltaX, deltaY) => {
if (!shape)
return shape;
if (shape.type === 'freehand' && Array.isArray(shape.points)) {
return {
...shape,
points: shape.points.map((point) => ({
...point,
x: (point?.x || 0) + deltaX,
y: (point?.y || 0) + deltaY,
})),
};
}
if (shape.type === 'text') {
return { ...shape, x: (shape.x || 0) + deltaX, y: (shape.y || 0) + deltaY };
}
return {
...shape,
x1: (shape.x1 || 0) + deltaX,
y1: (shape.y1 || 0) + deltaY,
x2: (shape.x2 ?? shape.x1 ?? 0) + deltaX,
y2: (shape.y2 ?? shape.y1 ?? 0) + deltaY,
};
};
const fitShapeWithinBoard = (shape) => {
if (!shape || !hasBoardBounds) {
return shape;
}
let normalizedShape = shape;
if (shape.type === 'freehand' && Array.isArray(shape.points)) {
normalizedShape = {
...shape,
points: shape.points.map((point) => clampBoardPoint({
x: point?.x || 0,
y: point?.y || 0,
})),
};
}
else if (shape.type === 'text') {
normalizedShape = {
...shape,
text: getTextContent(shape.text),
x: clampValue(shape.x || 0, 0, boardSize.width),
y: clampValue(shape.y || 0, 0, boardSize.height),
};
}
else if (shape.type === 'image' || shape.x1 !== undefined || shape.y1 !== undefined) {
normalizedShape = {
...shape,
x1: clampValue(shape.x1 || 0, 0, boardSize.width),
y1: clampValue(shape.y1 || 0, 0, boardSize.height),
x2: clampValue(shape.x2 ?? shape.x1 ?? 0, 0, boardSize.width),
y2: clampValue(shape.y2 ?? shape.y1 ?? 0, 0, boardSize.height),
};
}
const bounds = getShapeBounds(normalizedShape);
if (!bounds) {
return normalizedShape;
}
const maxLeft = Math.max(0, boardSize.width - bounds.width);
const maxTop = Math.max(0, boardSize.height - bounds.height);
const deltaX = clampValue(bounds.left, 0, maxLeft) - bounds.left;
const deltaY = clampValue(bounds.top, 0, maxTop) - bounds.top;
if (deltaX === 0 && deltaY === 0) {
return normalizedShape;
}
return translateShape(normalizedShape, deltaX, deltaY);
};
const distanceToLine = (point, start, end) => {
const lineLengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
if (lineLengthSquared === 0) {
return Math.hypot(point.x - start.x, point.y - start.y);
}
const rawPosition = ((point.x - start.x) * (end.x - start.x) + (point.y - start.y) * (end.y - start.y)) / lineLengthSquared;
const position = Math.max(0, Math.min(1, rawPosition));
const projectedX = start.x + position * (end.x - start.x);
const projectedY = start.y + position * (end.y - start.y);
return Math.hypot(point.x - projectedX, point.y - projectedY);
};
const isPointInsideShape = (shape, point, tolerance = 16) => {
if (!shape)
return false;
if (shape.type === 'line') {
return distanceToLine(point, { x: shape.x1 || 0, y: shape.y1 || 0 }, { x: shape.x2 || 0, y: shape.y2 || 0 }) <= tolerance;
}
if (shape.type === 'circle') {
const centerX = shape.x1 || 0;
const centerY = shape.y1 || 0;
const radius = Math.hypot((shape.x2 ?? centerX) - centerX, (shape.y2 ?? centerY) - centerY);
return Math.hypot(point.x - centerX, point.y - centerY) <= radius + tolerance;
}
if (shape.type === 'freehand' && Array.isArray(shape.points)) {
return shape.points.some((shapePoint, index) => {
if (index === 0)
return false;
const previous = shape.points[index - 1];
return distanceToLine(point, { x: previous?.x || 0, y: previous?.y || 0 }, { x: shapePoint?.x || 0, y: shapePoint?.y || 0 }) <= tolerance;
});
}
const bounds = getShapeBounds(shape);
if (!bounds)
return false;
return (point.x >= bounds.left - tolerance &&
point.x <= bounds.left + bounds.width + tolerance &&
point.y >= bounds.top - tolerance &&
point.y <= bounds.top + bounds.height + tolerance);
};
const findShapeIndexAtPoint = (point) => {
for (let shapeIndex = localShapes.length - 1; shapeIndex >= 0; shapeIndex -= 1) {
if (isPointInsideShape(localShapes[shapeIndex], point)) {
return shapeIndex;
}
}
return null;
};
const lineStyle = (x1, y1, x2, y2, color = '#0ea5e9', thickness = 3) => {
const start = projectPoint({ x: x1, y: y1 });
const end = projectPoint({ x: x2, y: y2 });
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
const scaledThickness = Math.max(1, thickness * boardRenderScale);
return {
position: 'absolute',
left: start.x,
top: start.y,
width: length,
height: scaledThickness,
backgroundColor: color,
borderRadius: scaledThickness / 2,
transform: [{ rotateZ: `${angle}rad` }],
transformOrigin: 'left center',
};
};
const getLinePattern = (style, thickness) => {
if (style === 'dashed')
return [Math.max(12, thickness * 2.5), Math.max(8, thickness * 1.8)];
if (style === 'dotted')
return [Math.max(2, thickness), Math.max(8, thickness * 2)];
if (style === 'dashDot')
return [Math.max(12, thickness * 2.5), Math.max(6, thickness), Math.max(2, thickness), Math.max(6, thickness)];
return [];
};
const renderLineSegments = (keyPrefix, x1, y1, x2, y2, color = '#0ea5e9', thickness = 3, style = 'solid') => {
const lineLength = Math.hypot(x2 - x1, y2 - y1);
const pattern = getLinePattern(style, thickness);
if (style === 'solid' || pattern.length === 0 || lineLength <= 1) {
return <View key={keyPrefix} style={lineStyle(x1, y1, x2, y2, color, thickness)}/>;
}
const segments = [];
let distance = 0;
let patternIndex = 0;
while (distance < lineLength) {
const dashLength = Math.min(pattern[patternIndex % pattern.length], lineLength - distance);
if (patternIndex % 2 === 0 && dashLength > 0) {
const startRatio = distance / lineLength;
const endRatio = (distance + dashLength) / lineLength;
const segmentStartX = x1 + (x2 - x1) * startRatio;
const segmentStartY = y1 + (y2 - y1) * startRatio;
const segmentEndX = x1 + (x2 - x1) * endRatio;
const segmentEndY = y1 + (y2 - y1) * endRatio;
segments.push(<View key={`${keyPrefix}-${patternIndex}`} style={lineStyle(segmentStartX, segmentStartY, segmentEndX, segmentEndY, color, thickness)}/>);
}
distance += dashLength;
patternIndex += 1;
}
return segments;
};
const getPolygonVertices = (shape) => {
const x1 = shape.x1 || 0;
const y1 = shape.y1 || 0;
const x2 = shape.x2 ?? x1;
const y2 = shape.y2 ?? y1;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
const left = Math.min(x1, x2);
const top = Math.min(y1, y2);
const right = left + width;
const bottom = top + height;
if (shape.type === 'triangle')
return [{ x: centerX, y: top }, { x: right, y: bottom }, { x: left, y: bottom }];
if (shape.type === 'rhombus')
return [{ x: centerX, y: top }, { x: right, y: centerY }, { x: centerX, y: bottom }, { x: left, y: centerY }];
if (shape.type === 'parallelogram') {
return [{ x: centerX, y: y1 }, { x: x2, y: y2 }, { x: centerX, y: y2 }, { x: x1, y: y1 }];
}
const sides = shape.type === 'pentagon' ? 5 : shape.type === 'hexagon' ? 6 : shape.type === 'octagon' ? 8 : 4;
const radius = Math.min(width, height) / 2;
return Array.from({ length: sides }).map((_unused, vertexIndex) => {
const angle = (2 * Math.PI * vertexIndex) / sides - Math.PI / 2;
return {
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle),
};
});
};
const renderSelectionBounds = (shape, index) => {
if (selectedShapeIndex !== index)
return null;
const bounds = getShapeBounds(shape);
if (!bounds)
return null;
const topLeft = projectPoint({ x: bounds.left, y: bounds.top });
return (<View key={`selection-${index}`} pointerEvents="none" style={{
position: 'absolute',
left: topLeft.x - 6,
top: topLeft.y - 6,
width: Math.max(12, bounds.width * boardRenderScale + 12),
height: Math.max(12, bounds.height * boardRenderScale + 12),
borderWidth: 2,
borderColor: '#38bdf8',
borderStyle: 'dashed',
borderRadius: 8,
}}/>);
};
const renderShape = (shape, index, isDraft = false) => {
if (!shape)
return null;
if (shape.type === 'freehand' && Array.isArray(shape.points)) {
const points = shape.points;
return (<React.Fragment key={`fh-${index}`}>
{points.slice(1).map((point, idx) => renderLineSegments(`fh-${index}-${idx}`, points[idx]?.x || 0, points[idx]?.y || 0, point?.x || 0, point?.y || 0, shape.color, shape.thickness))}
{!isDraft && renderSelectionBounds(shape, index)}
</React.Fragment>);
}
if (shape.type === 'line') {
return (<React.Fragment key={`ln-${index}`}>
{renderLineSegments(`ln-${index}`, shape.x1 || 0, shape.y1 || 0, shape.x2 || 0, shape.y2 || 0, shape.color, shape.thickness, shape.lineType || 'solid')}
{!isDraft && renderSelectionBounds(shape, index)}
</React.Fragment>);
}
if (shape.type === 'rectangle' || shape.type === 'square' || shape.type === 'circle' || shape.type === 'oval') {
const bounds = getShapeBounds(shape) || { left: 0, top: 0, width: 0, height: 0 };
const topLeft = projectPoint({ x: bounds.left, y: bounds.top });
const projectedWidth = bounds.width * boardRenderScale;
const projectedHeight = bounds.height * boardRenderScale;
return (<React.Fragment key={`shape-${index}`}>
<View style={{
position: 'absolute',
left: topLeft.x,
top: topLeft.y,
width: projectedWidth,
height: projectedHeight,
borderColor: shape.color || '#0ea5e9',
borderWidth: Math.max(1, (shape.thickness || 2) * boardRenderScale),
borderRadius: shape.type === 'circle' || shape.type === 'oval' ? 999 : 0,
}}/>
{!isDraft && renderSelectionBounds(shape, index)}
</React.Fragment>);
}
if (['triangle', 'rhombus', 'pentagon', 'hexagon', 'octagon', 'parallelogram'].includes(shape.type)) {
const vertices = getPolygonVertices(shape);
return (<React.Fragment key={`poly-${index}`}>
{vertices.map((vertex, vertexIndex) => {
const nextVertex = vertices[(vertexIndex + 1) % vertices.length];
return renderLineSegments(`poly-${index}-${vertexIndex}`, vertex.x, vertex.y, nextVertex.x, nextVertex.y, shape.color, shape.thickness, shape.lineType || 'solid');
})}
{!isDraft && renderSelectionBounds(shape, index)}
</React.Fragment>);
}
if (shape.type === 'text') {
const point = projectPoint({ x: shape.x || 20, y: shape.y || 20 });
const textValue = getTextContent(shape.text);
return (<React.Fragment key={`txt-${index}`}>
<Text style={{
position: 'absolute',
left: point.x,
top: point.y,
color: shape.color || '#0f172a',
fontSize: Math.max(8, (shape.fontSize || 16) * boardRenderScale),
fontFamily: shape.font || undefined,
fontWeight: '600',
}}>
{textValue}
</Text>
{!isDraft && renderSelectionBounds(shape, index)}
</React.Fragment>);
}
if (shape.type === 'image') {
const bounds = getShapeBounds(shape) || { left: 0, top: 0, width: 1, height: 1 };
const topLeft = projectPoint({ x: bounds.left, y: bounds.top });
return (<React.Fragment key={`img-${index}`}>
<Image source={{ uri: shape.src }} style={{
position: 'absolute',
left: topLeft.x,
top: topLeft.y,
width: Math.max(1, bounds.width * boardRenderScale),
height: Math.max(1, bounds.height * boardRenderScale),
resizeMode: 'contain',
}}/>
{!isDraft && renderSelectionBounds(shape, index)}
</React.Fragment>);
}
return null;
};
const renderGridLines = () => {
if (!useImageBackground)
return null;
const spacing = 24;
const lineColor = isDarkMode ? 'rgba(148,163,184,0.18)' : 'rgba(59,130,246,0.18)';
const verticalLines = Math.ceil(boardSize.width / spacing) + 2;
const horizontalLines = Math.ceil(boardSize.height / spacing) + 2;
const frameLeft = panOffset.x + boardBaseOffset.x;
const frameTop = panOffset.y + boardBaseOffset.y;
return (<>
{Array.from({ length: verticalLines }).map((_unused, lineIndex) => (<View key={`grid-v-${lineIndex}`} style={{
position: 'absolute',
left: frameLeft + lineIndex * spacing * boardRenderScale,
top: frameTop,
width: 1,
height: boardSize.height * boardRenderScale,
backgroundColor: lineColor,
}}/>))}
{Array.from({ length: horizontalLines }).map((_unused, lineIndex) => (<View key={`grid-h-${lineIndex}`} style={{
position: 'absolute',
left: frameLeft,
top: frameTop + lineIndex * spacing * boardRenderScale,
width: boardSize.width * boardRenderScale,
height: 1,
backgroundColor: lineColor,
}}/>))}
</>);
};
const renderBoardBounds = () => {
if (!hasBoardBounds) {
return null;
}
const frameLeft = panOffset.x + boardBaseOffset.x;
const frameTop = panOffset.y + boardBaseOffset.y;
const frameWidth = Math.max(1, boardSize.width * boardRenderScale);
const frameHeight = Math.max(1, boardSize.height * boardRenderScale);
const cornerColor = '#ef4444';
const frameColor = isDarkMode ? 'rgba(226, 232, 240, 0.45)' : 'rgba(37, 99, 235, 0.35)';
const cornerSize = 18;
const cornerThickness = 3;
return (<>
<View pointerEvents="none" style={[
styles.boardBoundsFrame,
{
left: frameLeft,
top: frameTop,
width: frameWidth,
height: frameHeight,
borderColor: frameColor,
},
]}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft,
top: frameTop,
width: cornerSize,
height: cornerThickness,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft,
top: frameTop,
width: cornerThickness,
height: cornerSize,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft + frameWidth - cornerSize,
top: frameTop,
width: cornerSize,
height: cornerThickness,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft + frameWidth - cornerThickness,
top: frameTop,
width: cornerThickness,
height: cornerSize,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft,
top: frameTop + frameHeight - cornerThickness,
width: cornerSize,
height: cornerThickness,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft,
top: frameTop + frameHeight - cornerSize,
width: cornerThickness,
height: cornerSize,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft + frameWidth - cornerSize,
top: frameTop + frameHeight - cornerThickness,
width: cornerSize,
height: cornerThickness,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
<View pointerEvents="none" style={{
position: 'absolute',
left: frameLeft + frameWidth - cornerThickness,
top: frameTop + frameHeight - cornerSize,
width: cornerThickness,
height: cornerSize,
borderRadius: cornerThickness,
backgroundColor: cornerColor,
}}/>
</>);
};
const emitBoardAction = (action, payload) => {
socket?.emit?.('updateBoardAction', payload === undefined ? { action } : { action, payload }, handleServerResponse);
};
const beginDraw = (x, y) => {
setExpandedPanel(null);
if (tool === 'pan') {
startPointRef.current = { x, y };
dragPointRef.current = { x, y };
return;
}
const rawBoardPoint = screenToBoardPoint(x, y);
if (!isPointWithinBoard(rawBoardPoint)) {
setSelectedShapeIndex(null);
setActionStatus('Tap inside the highlighted board area.');
return;
}
const boardPoint = clampBoardPoint(rawBoardPoint);
startPointRef.current = boardPoint;
dragPointRef.current = boardPoint;
if (tool === 'text') {
const targetIndex = findShapeIndexAtPoint(boardPoint);
const existingShape = targetIndex !== null ? localShapes[targetIndex] : null;
if (existingShape?.type === 'text') {
setEditingTextIndex(targetIndex);
setTextDraftPoint({ x: existingShape.x || boardPoint.x, y: existingShape.y || boardPoint.y });
setTextDraftValue(getTextContent(existingShape.text));
setSelectedShapeIndex(targetIndex);
setActionStatus('Edit text');
}
else {
setEditingTextIndex(null);
setTextDraftPoint(boardPoint);
setTextDraftValue('');
setSelectedShapeIndex(null);
setActionStatus('Enter text');
}
setIsTextEditorVisible(true);
startPointRef.current = null;
dragPointRef.current = null;
return;
}
if (tool === 'select') {
setSelectedShapeIndex(findShapeIndexAtPoint(boardPoint));
return;
}
if (tool === 'draw' || tool === 'shape') {
setDraftPoints([boardPoint, boardPoint]);
}
else if (tool === 'freehand') {
setDraftPoints([boardPoint]);
}
else if (tool === 'erase') {
applyBoardAction('erase', { x: boardPoint.x, y: boardPoint.y, size: eraserThickness });
}
};
const moveDraw = (x, y) => {
if (!startPointRef.current)
return;
if (tool === 'pan') {
const previousPoint = dragPointRef.current || { x, y };
setPanOffset((previousOffset) => ({
x: previousOffset.x + (x - previousPoint.x),
y: previousOffset.y + (y - previousPoint.y),
}));
dragPointRef.current = { x, y };
return;
}
const boardPoint = clampBoardPoint(screenToBoardPoint(x, y));
if (tool === 'select') {
if (selectedShapeIndex === null || !dragPointRef.current)
return;
const deltaX = boardPoint.x - dragPointRef.current.x;
const deltaY = boardPoint.y - dragPointRef.current.y;
setLocalShapes((previousShapes) => {
const nextShapes = previousShapes.map((shape, index) => (index === selectedShapeIndex ? fitShapeWithinBoard(translateShape(shape, deltaX, deltaY)) : shape));
updateShapes?.(nextShapes);
return nextShapes;
});
dragPointRef.current = boardPoint;
return;
}
if (tool === 'erase') {
applyBoardAction('erase', { x: boardPoint.x, y: boardPoint.y, size: eraserThickness });
return;
}
if (tool === 'freehand') {
setDraftPoints((prev) => [...prev, boardPoint]);
}
else if (tool === 'draw' || tool === 'shape') {
setDraftPoints((prev) => [prev[0], boardPoint]);
}
};
const commitDraw = () => {
const origin = startPointRef.current;
if (!origin)
return;
if (tool === 'select') {
emitBoardAction('shapes', { shapes: localShapes });
setDraftPoints([]);
startPointRef.current = null;
dragPointRef.current = null;
return;
}
if (tool === 'pan') {
startPointRef.current = null;
dragPointRef.current = null;
return;
}
if (tool === 'draw' && draftPoints.length > 1) {
const payload = {
type: 'line',
x1: draftPoints[0].x,
y1: draftPoints[0].y,
x2: draftPoints[1].x,
y2: draftPoints[1].y,
color: penColor,
thickness: lineThickness,
lineType,
};
applyBoardAction('draw', payload);
emitBoardAction('draw', payload);
}
if (tool === 'freehand' && draftPoints.length > 1) {
const payload = {
type: 'freehand',
points: draftPoints,
color: penColor,
thickness: brushThickness,
};
applyBoardAction('draw', payload);
emitBoardAction('draw', payload);
}
if (tool === 'shape' && draftPoints.length > 1) {
const payload = {
type: shapeType,
x1: draftPoints[0].x,
y1: draftPoints[0].y,
x2: draftPoints[1].x,
y2: draftPoints[1].y,
color: penColor,
thickness: lineThickness,
lineType,
};
applyBoardAction('shape', payload);
emitBoardAction('shape', payload);
}
if (tool === 'erase') {
const payload = {
x: origin.x,
y: origin.y,
size: eraserThickness,
};
emitBoardAction('erase', payload);
}
setDraftPoints([]);
startPointRef.current = null;
dragPointRef.current = null;
};
const adjustZoom = (nextScale) => {
const clampedScale = Math.min(Math.max(nextScale, 0.6), 4);
if (surfaceSize.width <= 1 || surfaceSize.height <= 1) {
setZoomScale(clampedScale);
setActionStatus(`Zoom ${Math.round(clampedScale * 100)}%`);
return;
}
const centerX = surfaceSize.width / 2;
const centerY = surfaceSize.height / 2;
const boardCenter = screenToBoardPoint(centerX, centerY);
const nextBoardRenderScale = Math.max(fitScale * clampedScale, 0.01);
const nextBoardBaseOffset = {
x: Math.max(0, (surfaceSize.width - boardSize.width * nextBoardRenderScale) / 2),
y: Math.max(0, (surfaceSize.height - boardSize.height * nextBoardRenderScale) / 2),
};
setZoomScale(clampedScale);
setPanOffset({
x: centerX - nextBoardBaseOffset.x - boardCenter.x * nextBoardRenderScale,
y: centerY - nextBoardBaseOffset.y - boardCenter.y * nextBoardRenderScale,
});
setActionStatus(`Zoom ${Math.round(clampedScale * 100)}%`);
};
const zoomBoard = (mode) => {
if (mode === 'reset') {
setZoomScale(1);
setPanOffset({ x: 0, y