UNPKG

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
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