From 466e328477e71cbfc8f4320e1c7c46d350e0ccf3 Mon Sep 17 00:00:00 2001 From: KDSBrowne Date: Fri, 2 Aug 2024 18:47:39 -0400 Subject: [PATCH] fix: Ensure size estimate is applied to text shapes while typing (#20809) * ensure size estimate is applied to text shape while typing * add comment and comma --- .../ui/components/whiteboard/component.jsx | 40 ++++++- .../imports/ui/components/whiteboard/utils.js | 107 +++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index 2e1ac84288..337aaedbd1 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -16,7 +16,7 @@ import { import Styled from './styles'; import PanToolInjector from './pan-tool-injector/component'; import { - findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, usePrevious, + findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, usePrevious, TextUtil, } from './utils'; import { isEqual } from 'radash'; @@ -28,6 +28,14 @@ const TOOLBAR_SMALL = 28; const TOOLBAR_LARGE = 32; const MOUNTED_RESIZE_DELAY = 1500; +const LETTER_SPACING = '-0.03em'; +const LINE_HEIGHT = 1.2; +const fontSizeMap = { + small: 12, + medium: 16, + large: 20 +}; + export default function Whiteboard(props) { const { isPresenter, @@ -747,6 +755,24 @@ export default function Whiteboard(props) { const onPatch = (e, t, reason) => { if (!e?.pageState || !reason) return; + function updateTextShapeSize(shape, textUtil, fsm) { + // Create a new object for estimated shape + const estimatedShape = { + ...shape, + style: { + ...shape.style, + fontFamily: shape.style.fontFamily || 'Comic Sans MS, cursive, sans-serif', + fontSize: fsm[shape.style.size] || 16, + letterSpacing: LETTER_SPACING, + lineHeight: LINE_HEIGHT, + }, + }; + // Calculate and set size + const measuredBounds = textUtil.getBoundsEstimate(estimatedShape); + // Assign size to a new object to avoid modifying the original shape parameter + return { ...shape, size: [measuredBounds.width, measuredBounds.height] }; + } + if (((isPanning || panSelected) && (reason === 'selected' || reason === 'set_hovered_id'))) { e.patchState( { @@ -889,15 +915,16 @@ export default function Whiteboard(props) { } if (e?.session?.initialShape?.type === 'text' && !shapes[patchedShape.id]) { - // check for maxShapes + // Check for maxShapes const currentShapes = e?.document?.pages[e?.currentPageId]?.shapes; const shapeNumberExceeded = Object.keys(currentShapes).length - 1 > maxNumberOfAnnotations; if (shapeNumberExceeded) { notifyShapeNumberExceeded(intl, maxNumberOfAnnotations); e?.cancelSession?.(); } else { - patchedShape.userId = currentUser?.userId; - persistShape(patchedShape, whiteboardId, isModerator); + const updatedShape = { ...patchedShape, userId: currentUser?.userId }; + const newShapeWithSize = updateTextShapeSize(updatedShape, TextUtil, fontSizeMap); + persistShape(newShapeWithSize, whiteboardId, isModerator); } } else { const diff = { @@ -905,6 +932,11 @@ export default function Whiteboard(props) { point: patchedShape.point, text: patchedShape.text, }; + + if (patchedShape.type === 'text') { + const updatedShape = updateTextShapeSize(patchedShape, TextUtil, fontSizeMap); + diff.size = updatedShape.size; + } persistShape(diff, whiteboardId, isModerator); } } diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js b/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js index 476af5e148..5b21d7badd 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js @@ -275,11 +275,114 @@ const getTextSize = (text, style, padding) => { return [width + padding, height + padding]; }; + +// getBoundsEstimate here is a function inspired by implementations in tldraw-v1: +// TextUtil: https://github.com/tldraw/tldraw-v1/blob/f786c38ac0fdce337c4405c11cbfa9223d2ee6dd/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx#L24 +// getTextSize: https://github.com/tldraw/tldraw-v1/blob/f786c38ac0fdce337c4405c11cbfa9223d2ee6dd/packages/tldraw/src/state/shapes/shared/getTextSize.ts#L6 +// It calculates the estimated bounds of a text shape based on its content and style. +const TextUtil = { + getBoundsEstimate(shape) { + const { text, style } = shape; + const div = document.createElement('div'); + div.style.position = 'absolute'; + div.style.visibility = 'hidden'; + div.style.whiteSpace = 'pre'; + div.style.fontSize = this.getFontSize(style.size); + div.style.fontFamily = this.getFontFamily(style.font); + div.style.letterSpacing = style.letterSpacing || 'normal'; + div.style.lineHeight = style.lineHeight || 'normal'; + div.textContent = text; + document.body.appendChild(div); + + // Calculate the number of lines + const numberOfLines = text.split('\n').length; + + // Get the bounding box dimensions + const { width } = div.getBoundingClientRect(); + const lineHeight = parseFloat(getComputedStyle(div).lineHeight); + const height = lineHeight * numberOfLines; + document.body.removeChild(div); + + // Dynamic padding adjustment based on font size + const paddingAdjustment = parseInt(div.style.fontSize, 10) * 0.3; + let finalWidth = width + paddingAdjustment; + let finalHeight = height + paddingAdjustment; + + // Apply width and height multipliers based on font size and style + finalWidth *= this.getWidthMultiplier(style.size, style.font); + finalHeight *= this.getHeightMultiplier(style.size, style.font, numberOfLines); + + return { + width: finalWidth, + height: finalHeight, + }; + }, + + getFontSize(size) { + switch (size) { + case 'small': return '12px'; + case 'medium': return '16px'; + case 'large': return '20px'; + default: return '16px'; + } + }, + + getFontFamily(font) { + switch (font) { + case 'mono': return 'monospace'; + case 'script': return 'Comic Sans MS, cursive, sans-serif'; + case 'sans': return 'Arial, sans-serif'; + case 'serif': return 'Times New Roman, serif'; + default: return 'sans-serif'; + } + }, + + getWidthMultiplier(size, font) { + const fontMultiplier = { + small: { mono: 3, default: 2 }, + medium: { mono: 4, default: 3 }, + large: { mono: 7, default: 4 }, + }; + + if (font === 'mono') { + return fontMultiplier[size].mono; + } else if (['script', 'sans', 'serif'].includes(font)) { + return fontMultiplier[size].default; + } + + return 1; + }, + + getHeightMultiplier(size, font, numberOfLines) { + const baseMultiplier = 1.5; + + if (font === 'mono') { + return baseMultiplier + (numberOfLines > 1 ? 0.5 : 0); + } else if (['script', 'sans', 'serif'].includes(font)) { + return baseMultiplier + (numberOfLines > 1 ? 0.3 : 0); + } + + return baseMultiplier; + } +}; + const Utils = { - usePrevious, findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, getTextSize, + usePrevious, + findRemoved, + filterInvalidShapes, + mapLanguage, + sendShapeChanges, + getTextSize, + TextUtil }; export default Utils; export { - usePrevious, findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, getTextSize, + usePrevious, + findRemoved, + filterInvalidShapes, + mapLanguage, + sendShapeChanges, + getTextSize, + TextUtil };